Initial commit

main
Talon 2023-05-02 18:44:10 +02:00
parent 43ca871505
commit 7f2452e9bb
48 changed files with 278163 additions and 0 deletions

17
index.html 100644
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<title>Vite + TS</title>
</head>
<body>
<div id="app"></div>
<div id="custom-keyboard"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

574
package-lock.json generated 100644
View File

@ -0,0 +1,574 @@
{
"name": "input-test",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "input-test",
"version": "0.0.0",
"dependencies": {
"@types/hammerjs": "^2.0.41",
"hammerjs": "^2.0.8"
},
"devDependencies": {
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz",
"integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz",
"integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz",
"integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz",
"integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz",
"integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz",
"integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz",
"integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz",
"integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz",
"integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz",
"integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz",
"integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz",
"integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz",
"integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz",
"integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz",
"integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz",
"integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz",
"integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz",
"integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz",
"integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz",
"integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz",
"integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz",
"integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@types/hammerjs": {
"version": "2.0.41",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz",
"integrity": "sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA=="
},
"node_modules/esbuild": {
"version": "0.17.18",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz",
"integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.17.18",
"@esbuild/android-arm64": "0.17.18",
"@esbuild/android-x64": "0.17.18",
"@esbuild/darwin-arm64": "0.17.18",
"@esbuild/darwin-x64": "0.17.18",
"@esbuild/freebsd-arm64": "0.17.18",
"@esbuild/freebsd-x64": "0.17.18",
"@esbuild/linux-arm": "0.17.18",
"@esbuild/linux-arm64": "0.17.18",
"@esbuild/linux-ia32": "0.17.18",
"@esbuild/linux-loong64": "0.17.18",
"@esbuild/linux-mips64el": "0.17.18",
"@esbuild/linux-ppc64": "0.17.18",
"@esbuild/linux-riscv64": "0.17.18",
"@esbuild/linux-s390x": "0.17.18",
"@esbuild/linux-x64": "0.17.18",
"@esbuild/netbsd-x64": "0.17.18",
"@esbuild/openbsd-x64": "0.17.18",
"@esbuild/sunos-x64": "0.17.18",
"@esbuild/win32-arm64": "0.17.18",
"@esbuild/win32-ia32": "0.17.18",
"@esbuild/win32-x64": "0.17.18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"node_modules/postcss": {
"version": "8.4.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz",
"integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "3.21.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.3.tgz",
"integrity": "sha512-VnPfEG51nIv2xPLnZaekkuN06q9ZbnyDcLkaBdJa/W7UddyhOfMP2yOPziYQfeY7k++fZM8FdQIummFN5y14kA==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=12.20"
}
},
"node_modules/vite": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.4.tgz",
"integrity": "sha512-f90aqGBoxSFxWph2b39ae2uHAxm5jFBBdnfueNxZAT1FTpM13ccFQExCaKbR2xFW5atowjleRniQ7onjJ22QEg==",
"dev": true,
"dependencies": {
"esbuild": "^0.17.5",
"postcss": "^8.4.23",
"rollup": "^3.21.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

19
package.json 100644
View File

@ -0,0 +1,19 @@
{
"name": "input-test",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.0.2",
"vite": "^4.3.2"
},
"dependencies": {
"@types/hammerjs": "^2.0.41",
"hammerjs": "^2.0.8"
}
}

274939
public/dictionary.json 100644

File diff suppressed because it is too large Load Diff

BIN
public/keyChar.wav 100644

Binary file not shown.

Binary file not shown.

BIN
public/keyEnter.wav 100644

Binary file not shown.

BIN
public/macroSet.wav 100644

Binary file not shown.

1
public/vite.svg 100644
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,36 @@
export class FrequencyCounter {
private wordFrequency: Map<string, number>;
private maxFrequency: number;
private weightedWords: Map<string, number>;
constructor(weightedWords: Map<string, number> = new Map()) {
this.wordFrequency = new Map();
this.maxFrequency = 0;
this.weightedWords = weightedWords;
}
public registerWord(word: string): void {
const currentFrequency = this.wordFrequency.get(word) || 0;
const newFrequency = currentFrequency + 1;
this.wordFrequency.set(word, newFrequency);
if (newFrequency > this.maxFrequency) {
this.maxFrequency = newFrequency;
}
}
public getWeight(word: string): number {
let weight = 0;
if (this.maxFrequency > 0) {
const frequency = this.wordFrequency.get(word) || 0;
weight += (frequency / this.maxFrequency) * 2; // You can adjust the multiplier as needed
}
if (this.weightedWords.has(word)) {
weight += this.weightedWords.get(word)!;
}
return weight;
}
}

View File

@ -0,0 +1,37 @@
import { levenshtein } from './levenshtein';
import { FrequencyCounter } from './frequencycounter';
export class AutoCorrect {
private dictionary: string[];
private frequencyCounter: FrequencyCounter;
constructor(dictionary: string[]) {
this.dictionary = dictionary;
this.frequencyCounter = new FrequencyCounter();
}
public registerTypedWord(word: string): void {
this.frequencyCounter.registerWord(word);
}
public correct(word: string, tolerance: number = 2): string | null {
let minDistance = Infinity;
let bestMatch: string | null = null;
for (const entry of this.dictionary) {
let distance = levenshtein(word, entry);
// Apply weight based on word frequency
const weight = this.frequencyCounter.getWeight(entry);
distance -= weight;
distance = Math.max(distance, 0); // Ensure distance doesn't become negative
if (distance < minDistance) {
minDistance = distance;
bestMatch = entry;
}
}
return minDistance <= tolerance ? bestMatch : null;
}
}

View File

@ -0,0 +1,25 @@
export function levenshtein(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= a.length; i++) {
matrix[i] = [];
matrix[i][0] = i;
}
for (let j = 0; j <= b.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
return matrix[a.length][b.length];
}

View File

@ -0,0 +1,272 @@
import { Manager, Swipe, Tap } from 'hammerjs';
import { MessagingSystem } from '../eventing/messaging-system';
import { ItemFocusEvent } from '../eventing/events';
export class DOMNavigator extends MessagingSystem {
private currentIndex: number;
private currentElement: HTMLElement;
constructor(private rootElement: HTMLElement) {
super();
this.currentIndex = 0;
this.currentElement = rootElement;
this.setupGestures();
}
private setupGestures(): void {
const manager = new Manager(this.rootElement);
const swipe = new Swipe();
const tap = new Tap({ event: 'doubletap', taps: 2 });
manager.add(swipe);
manager.add(tap);
manager.on('swipe', (event) => {
switch (event.direction) {
case 2: // Left
this.navigateSiblings(-1);
break;
case 4: // Right
this.navigateSiblings(1);
break;
case 8: // Up
this.navigateParent();
break;
case 16: // Down
this.navigateChildren();
break;
}
});
manager.on('doubletap', () => {
this.currentElement.click();
});
this.rootElement.addEventListener("keydown", e => {
switch (e.key) {
case "ArrowRight":
this.navigateSiblings(1);
break;
case "ArrowLeft":
this.navigateSiblings(-1);
break;
case "ArrowUp":
this.navigateParent();
break;
case "ArrowDown":
this.navigateChildren();
break;
case "Enter":
this.currentElement.click();
break;
}
})
}
private navigateSiblings(direction: number): void {
const parent = this.currentElement.parentElement;
if (!parent) return;
const siblings = Array.from(parent.children);
const newIndex = this.currentIndex + direction;
if (newIndex >= 0 && newIndex < siblings.length) {
this.currentIndex = newIndex;
this.currentElement = siblings[newIndex] as HTMLElement;
}
this.handleFocus();
}
private navigateParent2(): void {
const parent = this.currentElement.parentElement;
if (!parent || parent === this.rootElement) return;
this.currentElement = parent;
this.currentIndex = Array.from(parent.parentElement.children).indexOf(parent);
this.handleFocus();
}
private navigateChildren2(): void {
if (!this.currentElement.children.length) return;
this.currentElement = this.currentElement.children[0] as HTMLElement;
this.currentIndex = 0;
this.handleFocus();
}
private handleFocus() {
let element = this.currentElement;
if (this.isSingleWrapper(element)) {
element = this.currentElement.children[0] as HTMLElement;
}
element.focus();
this.sendMessage<ItemFocusEvent>({
type: "ItemFocusEvent",
data: {
node: element,
friendlyName: `${this.getFriendlyName(element)} ${this.getFriendlyTypeName(element)}`
}
});
}
private isContainer(element: HTMLElement): boolean {
return ['div', 'label'].includes(element.tagName.toLowerCase());
}
private isSingleDivWrapper(element: HTMLElement): boolean {
return this.isContainer(element) && element.children.length === 1 && this.isContainer(element.children[0] as HTMLElement);
}
private isSingleWrapper(element: HTMLElement): boolean {
return this.isContainer(element) && element.children.length === 1 && !this.isContainer(element.children[0] as HTMLElement);
}
private navigateParent(): void {
let currentParent = this.currentElement.parentElement;
while (currentParent && currentParent !== this.rootElement) {
if (!this.isSingleDivWrapper(currentParent)) {
this.currentElement = currentParent;
this.currentIndex = Array.from(currentParent.parentElement.children).indexOf(currentParent);
break;
}
currentParent = currentParent.parentElement;
}
this.handleFocus();
}
private navigateChildren(): void {
const children = Array.from(this.currentElement.children);
let targetChild = children[0] as HTMLElement;
while (targetChild && this.isSingleDivWrapper(targetChild)) {
targetChild = targetChild.children[0] as HTMLElement;
}
if (targetChild) {
this.currentElement = targetChild;
this.currentIndex = children.indexOf(targetChild);
this.handleFocus();
}
}
public hasSiblings(): boolean {
const parent = this.currentElement.parentElement;
if (!parent) return false;
const siblings = Array.from(parent.children);
return siblings.length > 1 && (this.isContainer(this.currentElement));
}
private getFriendlyName(element: HTMLElement) {
if (!element) return '';
// Check if the element is a child of a label
let parentLabel: HTMLLabelElement | null = null;
let currentParent = element.parentElement;
while (currentParent) {
if (currentParent.tagName.toLowerCase() === 'label') {
parentLabel = currentParent as HTMLLabelElement;
break;
}
currentParent = currentParent.parentElement;
}
// Check if the element has a label with the 'for' or 'aria-labelledby' attribute
const labelledById = element.getAttribute('aria-labelledby');
const labelledBy = labelledById ? document.getElementById(labelledById) : null;
if (element.tagName.toLowerCase() === 'div') {
const ariaLabel = element.getAttribute('aria-label');
const legend = element.querySelector('legend');
const groupName = ariaLabel || (legend ? legend.textContent : '');
if (groupName) {
return groupName;
}
}
if (parentLabel) {
return parentLabel.textContent?.trim() || '';
}
if (labelledBy) {
return labelledBy.textContent?.trim() || '';
}
// Check for aria-label attribute
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel) {
return ariaLabel;
}
// Check for title attribute
const title = element.getAttribute("title");
if (title) {
return title;
}
// Check if the element is a fieldset with a legend child
if (element.tagName.toLowerCase() === "fieldset") {
const legend = element.querySelector("legend");
if (legend) {
return legend.textContent || null;
}
}
// Return the text content of the element, but only if it's not a container (e.g., div)
if (element.tagName.toLowerCase() !== "div") {
return element.textContent || null;
}
// If none of the above conditions are met, return null
return "Group";
}
private getFriendlyTypeName(element: HTMLElement) {
const tagName = element.tagName.toLowerCase();
const inputType = (element as HTMLInputElement).type?.toLowerCase();
switch (tagName) {
case 'button':
return 'Button';
case 'input':
if (inputType === 'checkbox') {
const checked = (element as HTMLInputElement).checked;
return `Checkbox (${checked ? 'Checked' : 'Unchecked'})`;
}
if (inputType === 'radio') {
const checked = (element as HTMLInputElement).checked;
return `Radio Button (${checked ? 'Selected' : 'Unselected'})`;
}
return 'Input';
case 'select':
return 'Select';
case 'textarea':
return 'Text Area';
case 'li':
return 'List item';
case 'ul':
return 'List';
case 'span':
case "p":
return '';
default:
return tagName.charAt(0).toUpperCase() + tagName.slice(1);
}
}
}

View File

@ -0,0 +1,4 @@
export type ItemFocusEvent = {
node: HTMLElement;
friendlyName: string;
}

View File

@ -0,0 +1,63 @@
// Define a generic type for messages
export type Message<T> = {
type: string,
data?: T,
};
// Define a generic type for message handlers
export type MessageHandler<T> = (message: Message<T>) => void;
// Define a messaging system class
export class MessagingSystem {
private handlers: Record<string, MessageHandler<any>[]> = {};
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
if (!this.handlers[type]) {
this.handlers[type] = [];
}
if (!this.handlers[type].includes(handler)) {
this.handlers[type].push(handler);
}
}
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
if (this.handlers[type]) {
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
}
}
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
const wrappedHandler = (message: Message<T>) => {
handler(message);
this.unregisterHandler(type, wrappedHandler);
};
this.registerHandler(type, wrappedHandler);
}
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const handler = (message: Message<T>) => {
if (timer) clearTimeout(timer);
resolve(message.data);
this.unregisterHandler(type, handler);
};
this.registerHandler(type, handler);
let timer: ReturnType<typeof setTimeout> | undefined;
if (timeout) {
timer = setTimeout(() => {
this.unregisterHandler(type, handler);
reject(new Error(`Timeout waiting for message of type '${type}'`));
}, timeout);
}
});
}
public sendMessage<T>(message: Message<T>): void {
const handlers = this.handlers[message.type];
if (handlers) {
handlers.forEach(handler => handler(message));
}
}
}

View File

@ -0,0 +1,224 @@
export interface CustomKeyboardOptions {
leftKeys: string[][];
rightKeys: string[][];
}
export class CustomKeyboard {
private container: HTMLElement;
private options: CustomKeyboardOptions;
private keys: Map<string, HTMLElement> = new Map();
private pressedKeys: Map<number, { element: HTMLElement; key: string }> = new Map();
constructor(container: HTMLElement, options: CustomKeyboardOptions) {
this.container = container;
this.options = options;
this.createKeyboard();
this.attachEvents();
}
private createKeyboard(): void {
const leftSection = document.createElement('div');
leftSection.classList.add('keyboard-section', 'left');
this.createKeys(leftSection, this.options.leftKeys);
const rightSection = document.createElement('div');
rightSection.classList.add('keyboard-section', 'right');
this.createKeys(rightSection, this.options.rightKeys);
this.container.appendChild(leftSection);
this.container.appendChild(rightSection);
}
private createKeys(section: HTMLElement, keysLayout: string[][]): void {
keysLayout.forEach((rowKeys) => {
const row = document.createElement('div');
row.classList.add('keyboard-row');
rowKeys.forEach((key) => {
const keyElement = document.createElement('button');
keyElement.textContent = key;
keyElement.classList.add('keyboard-key');
keyElement.dataset.key = key;
row.appendChild(keyElement);
this.keys.set(key, keyElement);
});
section.appendChild(row);
});
}
private attachEvents(): void {
// this.container.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
// this.container.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
// this.container.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
this.container.addEventListener('pointerdown', this.handlePointerDown.bind(this), { passive: false });
this.container.addEventListener('pointerup', this.handlePointerUp.bind(this), { passive: false });
this.container.addEventListener('pointermove', this.handlePointerMove.bind(this), { passive: false });
this.container.addEventListener('contextmenu', this.handleContextMenu.bind(this), { passive: false });
// this.container.addEventListener('click', this.handleClick.bind(this), { passive: false });
}
private handleTouchStart(event: TouchEvent): void {
event.preventDefault();
const target = event.target as HTMLElement;
const key = target.dataset.key;
if (key) {
const touch = event.changedTouches[0];
this.pressedKeys.set(touch.identifier, { element: target, key });
this.keys.get(key)?.classList.add('pressed');
this.emit('keyDown', key);
}
}
private handleTouchEnd(event: TouchEvent): void {
event.preventDefault();
const touch = event.changedTouches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement;
const key = target.dataset.key;
const pressedKeyData = this.pressedKeys.get(touch.identifier);
if (pressedKeyData) {
// Remove the 'pressed' class from the original key pressed
pressedKeyData.element.classList.remove('pressed');
this.pressedKeys.delete(touch.identifier);
// Emit the 'keyUp' event for the initially pressed key
this.emit('keyUp', pressedKeyData.key);
// If the finger is released on a different key, emit the 'keyUp' event for that key
if (key && pressedKeyData.key !== key) {
this.emit('keyUp', key);
}
}
}
private handleTouchMove(event: TouchEvent): void {
event.preventDefault();
const touch = event.changedTouches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement;
const key = target.dataset.key;
const pressedKeyData = this.pressedKeys.get(touch.identifier);
if (pressedKeyData) {
// If the finger slides over a different key, emit the 'keyDown' event for that key
// and the 'keyUp' event for the previous key
if (key && pressedKeyData.key !== key) {
// Remove the 'pressed' class from the previous key and update the pressedKeys Map
pressedKeyData.element.classList.remove('pressed');
this.pressedKeys.set(touch.identifier, { element: target, key });
// Add the 'pressed' class to the new key
this.keys.get(key)?.classList.add('pressed');
// Emit the 'keyDown' event for the new key
this.emit('keyDown', key);
// Emit the 'keyUp' event for the previous key
// this.emit('keyUp', pressedKeyData.key);
}
}
}
private handleClick(event: MouseEvent) {
event.preventDefault();
const target = event.target as HTMLElement;
const key = target.dataset.key;
if (key) {
this.keys.get(key)?.classList.add('pressed');
// Emit the 'keyUp' event for the clicked key
this.emit('keyUp', key);
// Remove the 'pressed' class after a short delay
setTimeout(() => {
this.keys.get(key)?.classList.remove('pressed');
}, 100);
}
}
private handlePointerDown(event: PointerEvent): void {
event.preventDefault();
const target = event.target as HTMLElement;
const key = target.dataset.key;
if (key) {
this.pressedKeys.set(event.pointerId, { element: target, key });
this.keys.get(key)?.classList.add('pressed');
this.emit('keyDown', key);
}
}
private handlePointerUp(event: PointerEvent): void {
event.preventDefault();
const target = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement;
const key = target.dataset.key;
const pressedKeyData = this.pressedKeys.get(event.pointerId);
if (pressedKeyData) {
// Remove the 'pressed' class from the original key pressed
pressedKeyData.element.classList.remove('pressed');
this.pressedKeys.delete(event.pointerId);
// Emit the 'keyUp' event for the initially pressed key
this.emit('keyUp', pressedKeyData.key);
// If the finger is released on a different key, emit the 'keyUp' event for that key
if (key && pressedKeyData.key !== key) {
this.emit('keyUp', key);
}
}
}
private handlePointerMove(event: PointerEvent): void {
event.preventDefault();
const target = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement;
const key = target.dataset.key;
const pressedKeyData = this.pressedKeys.get(event.pointerId);
if (pressedKeyData) {
// If the finger slides over a different key, emit the 'keyDown' event for that key
// and the 'keyUp' event for the previous key
if (key && pressedKeyData.key !== key) {
// Remove the 'pressed' class from the previous key and update the pressedKeys Map
pressedKeyData.element.classList.remove('pressed');
this.pressedKeys.set(event.pointerId, { element: target, key });
// Add the 'pressed' class to the new key
this.keys.get(key)?.classList.add('pressed');
// Emit the 'keyDown' event for the new key
this.emit('keyDown', key);
// Emit the 'keyUp' event for the previous key
// this.emit('keyUp', pressedKeyData.key);
}
}
}
private handleContextMenu(event: MouseEvent): void {
event.preventDefault();
}
private emit(eventName: string, key: string): void {
const event = new CustomEvent(eventName, { detail: { key } });
this.container.dispatchEvent(event);
}
on(eventName: any, callback: (event: CustomEvent) => void): void {
this.container.addEventListener(eventName, callback);
}
off(eventName: any, callback: (event: CustomEvent) => void): void {
this.container.removeEventListener(eventName, callback);
}
show(): void {
this.container.classList.add('visible');
}
hide(): void {
this.container.classList.remove('visible');
}
}

119
src/main.ts 100644
View File

@ -0,0 +1,119 @@
import './styles.css'
import { CustomKeyboard, CustomKeyboardOptions } from "./keyboard";
import { AutoCorrect } from './autocorrect';
import { WebTTSOutput } from "audiogame-tools/engine/tts/outputs/webtts";
import { TTS } from "audiogame-tools/engine/tts";
import Resonator from "audiogame-tools/engine/resonator";
import { UIWindow, Button, Text, List, ListItem, Checkbox } from "./ui";
import { DOMNavigator } from './dom-nav';
import { ItemFocusEvent } from './eventing/events';
import { RadioGroup } from './ui/radio-group';
const tts = new TTS(new WebTTSOutput());
const sound = new Resonator();
const keyChar = sound.loadImmediate(`keyChar.wav`);
const keyFocus = sound.loadImmediate(`keyDelete.wav`);
const sfxCorrect = sound.loadImmediate(`macroSet.wav`);
let corrector: AutoCorrect;
let currentWord = '';
setupAutoCorrect();
const keyboardContainer = document.getElementById('custom-keyboard') as HTMLElement;
const keyboardLayout: CustomKeyboardOptions = {
leftKeys: [
['q', 'w', 'e', 'r', 't'],
['a', 's', 'd', 'f', 'g'],
['z', 'x', 'c', 'v'],
['⇧', '⌫', ' '],
],
rightKeys: [
['y', 'u', 'i', 'o', 'p'],
['h', 'j', 'k', 'l', ';'],
['b', 'n', 'm', ', ', '.'],
[' ', '↩', '⇩'],
],
};
const customKeyboard = new CustomKeyboard(keyboardContainer, keyboardLayout);
customKeyboard.on('keyDown', (event: CustomEvent) => {
keyFocus.play();
speak(event.detail.key);
});
customKeyboard.on('keyUp', (event: CustomEvent) => {
keyChar.play();
// speak(event.detail.key);
speak(currentWord);
if (event.detail.key !== " ") {
currentWord += event.detail.key;
} else {
if (!corrector) return;
const corrected = corrector.correct(currentWord, 4);
if (corrected) {
speak(corrected);
sfxCorrect.play();
}
currentWord = '';
}
});
customKeyboard.show();
async function setupAutoCorrect() {
const res = await fetch(`dictionary.json`);
const dictionary = await res.json();
corrector = new AutoCorrect(dictionary);
sfxCorrect.play();
}
function speak(text: string) {
tts.stop();
tts.speak(text);
}
/**
const win = new UIWindow("Test window");
const btn = new Button("Here is a button");
btn.onClick(() => speak(`Button clicked`));
win.add(btn);
win.add(new Button("Here is another button"));
win.add(new Text("Here is some text"));
win.add(new Text("And some more text"));
const list = new List("A list view");
list.add(new ListItem("Entry one"));
list.add(new ListItem("Entry two"));
win.add(list);
const radio = new RadioGroup("Select one", [{
key: "meow", value: "Meow"
},
{ key: "woof", value: "Woof" },
{ key: "rawr", value: "Rawr" }]);
win.add(radio);
const checkbox = new Checkbox("Toggle me");
win.add(checkbox);
document.getElementById("app")?.appendChild(win.show() as any);
const nav = new DOMNavigator(document.body);
nav.registerHandler<ItemFocusEvent>("ItemFocusEvent", (ev) => {
keyFocus.play();
speak(ev.data?.friendlyName!);
if (nav.hasSiblings()) {
sfxCorrect.play();
}
})
*/
const testTTS = document.createElement("button");
testTTS.addEventListener("click", e => {
speak(`Meow`);
});
document.getElementById("app")?.appendChild(testTTS);

79
src/styles.css 100644
View File

@ -0,0 +1,79 @@
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
.app {
width: 100%;
height: 100%;
}
#custom-keyboard {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
background-color: #eee;
padding: 10px;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
transform: translateY(100%);
touch-action: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#custom-keyboard.visible {
transform: translateY(0);
user-select: none;
}
.keyboard-section {
display: flex;
flex-direction: column;
user-select: none;
}
.keyboard-row {
display: flex;
user-select: none;
}
/* Apply an offset to every second row */
.keyboard-row:nth-child(even) {
margin-left: 4px;
}
/* Apply an offset to every third row (if needed) */
.keyboard-row:nth-child(3n) {
margin-left: 8px;
}
.keyboard-key {
border: 1px solid #ccc;
padding: 10px;
margin: 0px;
background-color: #fff;
border-radius: 3px;
touch-action: none;
user-select: none;
}
.keyboard-key.pressed {
background-color: #ddd;
}
/* Media query for landscape orientation */
@media (orientation: landscape) {
.keyboard-key {
padding: 10px;
/* Increase the padding to make the keys larger */
}
}

35
src/ui/button.ts 100644
View File

@ -0,0 +1,35 @@
import { UINode } from "./node";
export class Button extends UINode {
private buttonElement: HTMLButtonElement;
public constructor(title: string, hasPopup: boolean = false) {
super(title);
this.buttonElement = document.createElement("button");
this.buttonElement.innerText = title;
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
this.element.appendChild(this.buttonElement);
this.element.setAttribute("aria-label", this.title);
}
public focus() {
this.buttonElement.focus();
}
public click() {
this.buttonElement.click();
}
public getElement(): HTMLElement {
return this.buttonElement;
}
public setText(text: string) {
this.title = text;
this.buttonElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
}
public setDisabled(val: boolean) {
this.buttonElement.disabled = val;
}
}

24
src/ui/canvas.ts 100644
View File

@ -0,0 +1,24 @@
import { UINode } from "./node";
export class Canvas extends UINode {
private canvasElement: HTMLCanvasElement;
public constructor(title: string) {
super(title);
this.canvasElement = document.createElement("canvas");
this.canvasElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.canvasElement);
}
public focus() {
this.canvasElement.focus();
}
public click() {
this.canvasElement.click();
}
public getElement(): HTMLElement {
return this.canvasElement;
}
}

46
src/ui/checkbox.ts 100644
View File

@ -0,0 +1,46 @@
import { UINode } from "./node";
export class Checkbox extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private checkboxElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.id = `chkbx_title_${this.id}`;
this.checkboxElement = document.createElement("input");
this.checkboxElement.id = `chkbx_${this.id}`;
this.checkboxElement.type = "checkbox";
this.titleElement.appendChild(this.checkboxElement);
this.titleElement.appendChild(document.createTextNode(this.title));
this.element.appendChild(this.titleElement);
}
public focus() {
this.checkboxElement.focus();
}
public click() {
this.checkboxElement.click();
}
public getElement(): HTMLElement {
return this.checkboxElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.element.setAttribute("aria-roledescription", "checkbox");
}
public isChecked(): boolean {
return this.checkboxElement.checked;
}
public setChecked(value: boolean) {
this.checkboxElement.checked = value;
}
}

View File

@ -0,0 +1,40 @@
import { Container } from "./container";
export class CollapsableContainer extends Container {
private detailsElement: HTMLDetailsElement;
private summaryElement: HTMLElement;
private wrapperElement: HTMLDivElement;
public constructor(title: string) {
super(title);
this.wrapperElement = document.createElement("div");
this.detailsElement = document.createElement("details");
this.summaryElement = document.createElement("summary");
this.summaryElement.innerText = title;
this.detailsElement.appendChild(this.summaryElement);
this.detailsElement.appendChild(this.containerElement);
this.wrapperElement.appendChild(this.detailsElement);
}
public render() {
return this.wrapperElement;
}
public setTitle(text: string): void {
this.title = text;
this.summaryElement.innerText = text;
}
public isCollapsed(): boolean {
return this.detailsElement.hasAttribute("open");
}
public expand(val: boolean) {
if (val) {
this.detailsElement.setAttribute("open", "true");
} else {
this.detailsElement.removeAttribute("open");
}
}
}

View File

@ -0,0 +1,55 @@
import { UINode } from "./node";
export class Container extends UINode {
public children: UINode[];
protected containerElement: HTMLDivElement;
private focused: number = 0;
public constructor(title: string) {
super(title);
this.children = [];
this.containerElement = document.createElement("div");
this.containerElement.setAttribute("tabindex", "-1");
this.focused = 0;
}
public focus() {
this.containerElement.focus();
}
public _onFocus() {
this.children[this.focused].focus();
}
public add(node: UINode) {
this.children.push(node);
node._onConnect();
this.containerElement.appendChild(node.render());
}
public remove(node: UINode) {
this.children.splice(this.children.indexOf(node), 1);
node._onDisconnect();
this.containerElement.removeChild(node.render());
}
public render() {
return this.containerElement;
}
public getChildren(): UINode[] {
return this.children;
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
return false;
}
public getElement() {
return this.containerElement;
}
public setAriaLabel(text: string): void {
this.containerElement.setAttribute("aria-label", text);
}
}

View File

@ -0,0 +1,41 @@
import { UINode } from "./node";
export class DatePicker extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `datepicker_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `datepicker_${this.id}`;
this.inputElement.type = "date";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
}
}

69
src/ui/dialog.ts 100644
View File

@ -0,0 +1,69 @@
import { Container } from "./container";
import { UINode } from "./node";
import { UIWindow } from "./window";
import { Button } from "./button";
export class Dialog<T> extends UIWindow {
private resolvePromise: (value: T | PromiseLike<T>) => void;
private rejectPromise: (reason?: any) => void;
private promise: Promise<T>;
private okButton: Button;
private cancelButton: Button;
private previouslyFocusedElement: HTMLElement;
public constructor(title: string) {
super(title, "dialog", false);
this.promise = new Promise<T>((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
// Automatically add OK and Cancel buttons
this.okButton = new Button("OK");
this.okButton.setPosition(70, 90, 10, 5);
this.okButton.onClick(() => this.choose(undefined)); // Default OK action
this.cancelButton = new Button("Cancel");
this.cancelButton.setPosition(20, 90, 10, 5);
this.cancelButton.onClick(() => this.cancel()); // Default Cancel action
}
public setOkAction(action: () => T): void {
this.okButton.onClick(() => {
const result = action();
this.choose(result);
});
}
public setCancelAction(action: () => void): void {
this.cancelButton.onClick(() => {
action();
this.cancel();
});
}
public choose(item: T) {
this.resolvePromise(item);
document.body.removeChild(this.getElement());
this.hide();
this.previouslyFocusedElement.focus();
}
public cancel(reason?: any) {
this.rejectPromise(reason);
document.body.removeChild(this.getElement());
this.hide();
this.previouslyFocusedElement.focus();
}
public open(): Promise<T> {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
document.body.appendChild( this.show());
this.add(this.okButton);
this.add(this.cancelButton);
this.container.focus();
return this.promise;
}
}

46
src/ui/dropdown.ts 100644
View File

@ -0,0 +1,46 @@
import { UINode } from "./node";
export class Dropdown extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private selectElement: HTMLSelectElement;
public constructor(title: string, options: { key: string; value: string }[]) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `dd_title_${this.id}`;
this.selectElement = document.createElement("select");
this.selectElement.id = `dd_${this.id}`;
this.titleElement.appendChild(this.selectElement);
this.element.appendChild(this.titleElement);
options.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = option.key;
optionElement.innerText = option.value;
this.selectElement.appendChild(optionElement);
});
}
public focus() {
this.selectElement.focus();
}
public getElement(): HTMLElement {
return this.selectElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getSelectedValue(): string {
return this.selectElement.value;
}
public setSelectedValue(value: string) {
this.selectElement.value = value;
}
}

View File

@ -0,0 +1,44 @@
import { UINode } from "./node";
export class FileInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string, multiple: boolean = false) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `fileinpt_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `fileinpt_${this.id}`;
this.inputElement.type = "file";
if (multiple) {
this.inputElement.multiple = true;
}
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getFiles(): FileList | null {
return this.inputElement.files;
}
public setAccept(accept: string) {
this.inputElement.accept = accept;
}
}

30
src/ui/image.ts 100644
View File

@ -0,0 +1,30 @@
import { UINode } from "./node";
export class Image extends UINode {
private imgElement: HTMLImageElement;
public constructor(title: string, src: string, altText: string = "") {
super(title);
this.imgElement = document.createElement("img");
this.imgElement.src = src;
this.imgElement.alt = altText;
this.element.appendChild(this.imgElement);
this.element.setAttribute("aria-label", title);
}
public getElement(): HTMLElement {
return this.imgElement;
}
public setText(text: string) {
this.title = text;
this.element.setAttribute("aria-label", text);
}
public setSource(src: string) {
this.imgElement.src = src;
}
public setAltText(altText: string) {
this.imgElement.alt = altText;
}
}

13
src/ui/index.ts 100644
View File

@ -0,0 +1,13 @@
export { UIWindow } from "./window";
export { Button } from "./button";
export { Container } from "./container";
export { UINode } from "./node";
export { List } from "./list";
export { Text } from "./text";
export { ViewManager } from "./view-manager";
export { ListItem } from "./list-item";
export { Checkbox } from "./checkbox";
export { TextInput } from "./text-input";
export { TabBar } from "./tab-bar";
export { TabbedView } from "./tabbed-view";
export { Canvas } from "./canvas";

View File

@ -0,0 +1,34 @@
import { UINode } from "./node";
export class ListItem extends UINode {
private listElement: HTMLLIElement;
public constructor(title: string) {
super(title);
this.listElement = document.createElement("li");
this.listElement.innerText = this.title;
this.listElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.listElement);
this.listElement.setAttribute("aria-label", this.title);
this.listElement.setAttribute("role", "option");
}
public focus() {
this.listElement.focus();
}
public click() {
this.listElement.click();
}
public getElement(): HTMLElement {
return this.listElement;
}
public setText(text: string) {
this.title = text;
this.listElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.listElement.setAttribute("aria-label", this.title);
}
}

134
src/ui/list.ts 100644
View File

@ -0,0 +1,134 @@
import { UINode } from "./node";
// a list of UINodes navigable with the arrow keys
export class List extends UINode {
public children: UINode[];
protected listElement: HTMLUListElement;
private focused: number;
protected selectCallback: (id: number) => void;
public constructor(title: string) {
super(title);
this.children = [];
this.listElement = document.createElement("ul");
this.listElement.setAttribute("role", "listbox");
this.listElement.style.listStyle = "none";
this.element.appendChild(this.listElement);
this.element.setAttribute("aria-label", this.title);
this.focused = 0;
}
public add(node: UINode) {
this.children.push(node);
node._onConnect();
this.listElement.appendChild(node.render());
if (this.children.length === 1) this.calculateTabIndex();
node.onFocus(() => this.calculateFocused(node));
}
public remove(node: UINode) {
const idx = this.children.indexOf(node);
this.children.splice(idx, 1);
node._onDisconnect();
this.listElement.removeChild(node.render());
if (idx === this.focused) {
if (this.focused > 0) this.focused--;
this.calculateTabIndex();
}
}
public _onFocus() {
super._onFocus();
this.children[this.focused].focus();
}
public _onClick() {
this.children[this.focused]._onClick();
}
public _onSelect(id: number) {
if (this.selectCallback) this.selectCallback(id);
}
protected calculateStyle(): void {
super.calculateStyle();
this.element.style.overflowY = "scroll";
this.listElement.style.overflowY = "scroll";
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
switch (key) {
case "ArrowUp":
this.children[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
case "ArrowDown":
this.children[this.focused].setTabbable(false);
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
case "Enter":
this.children[this.focused].click();
return true;
break;
default:
return this.children[this.focused]._onKeydown(key);
break;
}
return false;
}
protected renderAsListItem(node: UINode) {
let li = document.createElement("li");
li.appendChild(node.render());
return li;
}
public getElement(): HTMLElement {
return this.listElement;
}
public isItemFocused(): boolean {
const has = this.children.find((child) => child.isFocused);
if (has) {
console.log("I have child", has);
return true;
}
console.log("No children selected");
return false;
}
private calculateTabIndex() {
if (this.children.length < 1) return;
this.children[this.focused].setTabbable(true);
}
public clear() {
this.children.forEach((child) => this.remove(child));
this.children = [];
this.listElement.innerHTML = '';
this.focused = 0;
}
public getFocusedChild() {
return this.children[this.focused];
}
public getFocus() {
return this.focused;
}
public onSelect(f: (id: number) => void) {
this.selectCallback = f;
}
protected calculateFocused(node: UINode) {
const idx = this.children.indexOf(node);
this._onSelect(idx);
}
}

View File

@ -0,0 +1,43 @@
import { UINode } from "./node";
export class MultilineInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private textareaElement: HTMLTextAreaElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `txtarea_title_${this.id}`;
this.textareaElement = document.createElement("textarea");
this.textareaElement.id = `txtarea_${this.id}`;
this.titleElement.appendChild(this.textareaElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.textareaElement.focus();
}
public click() {
this.textareaElement.click();
}
public getElement(): HTMLElement {
return this.textareaElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): string {
return this.textareaElement.value;
}
public setValue(value: string) {
this.textareaElement.value = value;
}
}

154
src/ui/node.ts 100644
View File

@ -0,0 +1,154 @@
import { UITab } from "./tab";
export class UINode {
protected title: string;
protected element: HTMLDivElement;
protected position: {x: number, y: number, width: number, height: number};
protected positionType: string = "fixed";
protected calculateOwnStyle: boolean = true;
protected keyDownCallback: (key: string) => void;
protected focusCallback: () => void;
protected blurCallback: () => void;
protected clickCallback: () => void;
protected globalKeydown: boolean = false;
protected visible: boolean;
public isFocused: boolean;
private userdata: any;
public constructor(title: string) {
this.title = title;
this.element = document.createElement("div");
this.element.setAttribute("tabindex", "-1");
this.visible = false;
this.isFocused = false;
}
public focus() {
this.element.focus();
}
public click() {
this.element.click();
}
public _onConnect() {
this.calculateStyle();
this.addListeners();
return;
}
public _onDisconnect() {
return;
}
public _onFocus() {
if (this.focusCallback) this.focusCallback();
this.isFocused = true;
return;
}
public _onBlur() {
if (this.blurCallback) this.blurCallback();
this.isFocused = false;
return;
}
public _onClick() {
if (this.clickCallback) this.clickCallback();
return;
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
if (this.keyDownCallback) {
if (this.globalKeydown || (!this.globalKeydown && document.activeElement === this.getElement())) {
this.keyDownCallback(key);
return true;
}
}
return false;
}
public render(): HTMLElement {
this.visible = true;
return this.element;
}
protected addListeners() {
const elem = this.element;
this.getElement().addEventListener("focus", (e) => this._onFocus());
elem.addEventListener("blur", (e) => this._onBlur());
elem.addEventListener("click", (e) => this._onClick());
elem.addEventListener("keydown", e => this._onKeydown(e.key));
}
protected calculateStyle() {
if (!this.calculateOwnStyle || !this.position) return;
this.element.style.position = this.positionType;
this.element.style.left = `${this.position.x}%`;
this.element.style.top = `${this.position.y}%`;
this.element.style.width = `${this.position.width}%`;
this.element.style.height = `${this.position.height}%`;
}
public setPosition(x: number, y: number, width: number, height: number, type: string = "fixed") {
this.position = {
x: x,
y: y,
width: width,
height: height,
};
this.positionType = type;
this.calculateOwnStyle = true;
this.calculateStyle();
}
public onClick(f: () => void) {
this.clickCallback = f;
return this;
}
public onFocus(f: () => void) {
this.focusCallback = f;
return this;
}
public onKeyDown(f: (key: string) => void, global: boolean = false) {
this.keyDownCallback = f;
return this;
}
public onBlur(f: () => void) {
this.blurCallback = f;
return this;
}
public getElement(): HTMLElement {
return this.element;
}
public setTabbable(val: boolean) {
this.getElement().setAttribute("tabindex",
(val === true) ? "0" :
"-1");
}
public setAriaLabel(text: string) {
this.element.setAttribute("aria-label", text);
}
public setRole(role: string) {
this.getElement().setAttribute("role", role);
}
public getUserData(): any {
return this.userdata;
}
public setUserData(obj: any) {
this.userdata = obj;
}
public setAccessKey(key: string) {
this.getElement().accessKey = key;
}
}

View File

@ -0,0 +1,37 @@
import { UINode } from "./node";
export class ProgressBar extends UINode {
private progressElement: HTMLProgressElement;
public constructor(title: string, max: number) {
super(title);
this.progressElement = document.createElement("progress");
this.progressElement.max = max;
this.element.appendChild(this.progressElement);
this.element.setAttribute("aria-label", title);
}
public getElement(): HTMLElement {
return this.progressElement;
}
public setText(text: string) {
this.title = text;
this.element.setAttribute("aria-label", text);
}
public getValue(): number {
return this.progressElement.value;
}
public setValue(value: number) {
this.progressElement.value = value;
}
public getMax(): number {
return this.progressElement.max;
}
public setMax(max: number) {
this.progressElement.max = max;
}
}

View File

@ -0,0 +1,77 @@
import { UINode } from "./node";
export class RadioGroup extends UINode {
private id: string;
private titleElement: HTMLLegendElement;
private containerElement: HTMLFieldSetElement;
private radioElements: Map<string, HTMLInputElement>;
private radioLabels: Map<string, HTMLLabelElement>;
public constructor(title: string, options: { key: string; value: string }[]) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("legend");
this.titleElement.innerText = title;
this.titleElement.id = `rdgrp_title_${this.id}`;
this.containerElement = document.createElement("fieldset");
this.containerElement.appendChild(this.titleElement);
this.element.appendChild(this.containerElement);
this.radioElements = new Map();
this.radioLabels = new Map();
options.forEach((option) => {
const radioId = `rd_${this.id}_${option.key}`;
const radioElement = document.createElement("input");
radioElement.id = radioId;
radioElement.type = "radio";
radioElement.name = `rdgrp_${this.id}`;
radioElement.value = option.key;
radioElement.setAttribute("aria-labeledby", `${radioId}_label`);
radioElement.title = option.value;
const radioLabel = document.createElement("label");
radioLabel.innerText = option.value;
radioLabel.id = `${radioId}_label`;
radioLabel.setAttribute("for", radioId);
this.radioElements.set(option.key, radioElement);
this.radioLabels.set(option.key, radioLabel);
this.containerElement.appendChild(radioElement);
this.containerElement.appendChild(radioLabel);
});
}
public focus() {
const firstRadioElement = this.radioElements.values().next().value;
if (firstRadioElement) {
firstRadioElement.focus();
}
}
public getElement(): HTMLElement {
return this.containerElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getSelectedValue(): string | null {
for (const [key, radioElement] of this.radioElements.entries()) {
if (radioElement.checked) {
return key;
}
}
return null;
}
public setSelectedValue(value: string) {
const radioElement = this.radioElements.get(value);
if (radioElement) {
radioElement.checked = true;
}
}
}

47
src/ui/slider.ts 100644
View File

@ -0,0 +1,47 @@
import { UINode } from "./node";
export class Slider extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private sliderElement: HTMLInputElement;
public constructor(title: string, min: number, max: number, step: number = 1) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `sldr_title_${this.id}`;
this.sliderElement = document.createElement("input");
this.sliderElement.id = `sldr_${this.id}`;
this.sliderElement.type = "range";
this.sliderElement.min = min.toString();
this.sliderElement.max = max.toString();
this.sliderElement.step = step.toString();
this.titleElement.appendChild(this.sliderElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.sliderElement.focus();
}
public click() {
this.sliderElement.click();
}
public getElement(): HTMLElement {
return this.sliderElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): number {
return parseInt(this.sliderElement.value);
}
public setValue(value: number) {
this.sliderElement.value = value.toString();
}
}

97
src/ui/tab-bar.ts 100644
View File

@ -0,0 +1,97 @@
import { UINode } from "./node";
import { UITab } from "./tab";
export class TabBar extends UINode {
private tabs: UITab[];
private tabBarContainer: HTMLDivElement;
private onTabChangeCallback: (index: number) => void;
private focused: number;
public constructor(title: string = "tab bar") {
super(title);
this.tabs = [];
this.tabBarContainer = document.createElement("div");
this.tabBarContainer.setAttribute("role", "tablist");
this.tabBarContainer.style.display = "flex";
this.tabBarContainer.style.alignItems = "center";
// this.tabBarContainer.style.justifyContent = "space-between";
this.tabBarContainer.style.overflow = "hidden";
this.element.appendChild(this.tabBarContainer);
this.focused = 0;
}
public _onFocus() {
this.tabs[this.focused].focus();
}
public focus() {
this.tabs[this.focused].focus();
}
public add(title: string) {
const idx = this.tabs.length;
const elem = new UITab(title);
elem.onClick(() => {
this.selectTab(idx);
});
this.tabs.push(elem);
this.tabBarContainer.appendChild(elem.render());
elem._onConnect();
if (this.tabs.length === 1) this.calculateTabIndex();
}
public onTabChange(f: (index: number) => void) {
this.onTabChangeCallback = f;
}
private selectTab(idx: number) {
if (idx !== this.focused) {
this.tabs[this.focused].setTabbable(false);
this.focused = idx;
}
if (!this.onTabChangeCallback) return;
this.onTabChangeCallback(idx);
this.tabs[idx].setTabbable(true);
this.tabs[idx].focus();
this.updateView();
}
public _onKeydown(key: string): boolean {
switch (key) {
case "ArrowLeft":
this.tabs[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.tabs[this.focused].setTabbable(true);
this.selectTab(this.focused);
return true;
break;
case "ArrowRight":
this.tabs[this.focused].setTabbable(false);
this.focused = Math.min(this.tabs.length - 1, this.focused + 1);
this.tabs[this.focused].setTabbable(true);
this.selectTab(this.focused);
return true;
break;
default:
return false;
break;
}
return false;
}
private updateView() {
for (let i = 0; i < this.tabs.length; i++) {
this.tabs[i].setSelected(i === this.focused);
}
}
public getElement(): HTMLElement {
return this.element;
}
public calculateTabIndex() {
this.tabs[this.focused].setTabbable(true);
}
}

40
src/ui/tab.ts 100644
View File

@ -0,0 +1,40 @@
import { UINode } from "./node";
export class UITab extends UINode {
private textElement: HTMLButtonElement;
private selected: boolean;
public constructor(title: string) {
super(title);
this.title = title;
this.textElement = document.createElement("button");
this.textElement.innerText = title;
this.textElement.setAttribute("tabindex", "-1");
this.textElement.setAttribute("role", "tab");
this.textElement.setAttribute("aria-selected", "false");
this.element.appendChild(this.textElement);
this.selected = false;
}
public focus() {
this.textElement.focus();
}
public click() {
this.textElement.click();
}
public getElement(): HTMLElement {
return this.textElement;
}
public setText(text: string) {
this.title = text;
this.textElement.innerText = text;
}
public setSelected(val: boolean) {
this.selected = val;
this.textElement.setAttribute("aria-selected", this.selected.toString());
}
}

View File

@ -0,0 +1,50 @@
import { UINode } from "./node";
import { TabBar } from "./tab-bar";
import { Container } from "./container";
export class TabbedView extends UINode {
private bar: TabBar;
private containers: Container[];
private containerElement: HTMLDivElement;
private barAtTop: boolean;
private currentView: Container;
public constructor(title: string, barAtTop: boolean = true) {
super(title);
this.bar = new TabBar();
this.bar._onConnect();
this.bar.onTabChange((index: number) => this.onTabChanged(index));
this.containers = [];
this.containerElement = document.createElement("div");
this.element.appendChild(this.bar.render());
this.element.appendChild(this.containerElement);
this.element.setAttribute("tabindex", "-1");
this.barAtTop = barAtTop;
}
public add(name: string, container: Container) {
this.bar.add(name);
container.setRole("tabpanel");
this.containers.push(container);
}
private onTabChanged(idx: number) {
if (this.currentView) {
this.containerElement.removeChild(this.currentView.render());
}
this.currentView = this.containers[idx];
this.containerElement.appendChild(this.currentView.render());
}
public getElement(): HTMLElement {
return this.containerElement;
}
protected calculateStyle(): void {
if (this.barAtTop) {
this.bar.setPosition(0, 0, 100, 5);
} else {
this.bar.setPosition(0, 90, 100, 5);
}
}
}

View File

@ -0,0 +1,44 @@
import { UINode } from "./node";
export class TextInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `inpt_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `inpt_${this.id}`;
this.inputElement.type = "text";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public click() {
this.inputElement.click();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
}
}

29
src/ui/text.ts 100644
View File

@ -0,0 +1,29 @@
import { UINode } from "./node";
export class Text extends UINode {
private textElement: HTMLSpanElement;
public constructor(title: string) {
super(title);
this.textElement = document.createElement("span");
this.textElement.innerText = title;
this.textElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.textElement);
}
public focus() {
this.textElement.focus();
}
public click() {
this.textElement.click();
}
public getElement(): HTMLElement {
return this.textElement;
}
public setText(text: string) {
this.title = text;
this.textElement.innerText = text;
}
}

View File

@ -0,0 +1,40 @@
import { UINode } from "./node";
export class TimePicker extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `timepicker_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `timepicker_${this.id}`;
this.inputElement.type = "time";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
}
}

View File

@ -0,0 +1,197 @@
import { UINode } from "./node";
import { Treeview } from "./treeview";
export class TreeviewItem extends UINode {
private listElement: HTMLLIElement;
private childContainer: HTMLUListElement;
public children: TreeviewItem[];
private expanded: boolean;
private focused: number;
private parent: TreeviewItem;
private root: Treeview;
private previousItem: TreeviewItem;
private nextItem: TreeviewItem;
public constructor(title: string) {
super(title);
this.listElement = document.createElement("li");
this.listElement.innerText = this.title;
this.listElement.setAttribute("tabindex", "-1");
this.listElement.setAttribute("role", "treeitem");
this.element.appendChild(this.listElement);
this.listElement.setAttribute("aria-label", this.title);
this.children = [];
this.focused = 0;
}
public focus() {
this.listElement.focus();
}
public click() {
this.listElement.click();
}
public getElement(): HTMLElement {
return this.listElement;
}
public setText(text: string) {
this.title = text;
this.listElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.listElement.setAttribute("aria-label", this.title);
}
public add(node: TreeviewItem) {
this.children.push(node);
node.setParent(this);
this.setExpanded(false);
if (this.children.length > 0) {
this.previousItem = this.children[this.children.length - 1];
this.previousItem.setNextItem(node);
}
}
public remove(node: TreeviewItem) {
const idx = this.children.indexOf(node);
if (idx > -1) {
this.children.splice(idx, 1);
this.updateShownItems();
}
}
public expand() {
if (!this.isExpandable()) return;
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
this.children[this.focused].expand();
return;
}
this.setExpanded(true);
this.updateShownItems();
this.children[this.focused].focus();
}
public collapse(ignoreFocus: boolean = false) {
if (!this.isExpandable()) {
if (this.getElement() !== document.activeElement && ignoreFocus) return;
this.parent.collapse(true);
return;
}
this.setExpanded(false);
this.updateShownItems();
setTimeout(() => this.focus(), 0);
}
public isExpandable(): boolean {
return this.children.length > 0;
}
public isExpanded() {
return this.expanded;
}
private setExpanded(val: boolean) {
this.expanded = val;
if (this.expanded) {
this.listElement.setAttribute("aria-expanded", "true");
return;
}
this.listElement.setAttribute("aria-expanded", "false");
}
private updateShownItems() {
if (this.expanded) {
if (!this.childContainer) {
this.childContainer = document.createElement("ul");
this.childContainer.setAttribute("role", "group");
this.children.forEach((child) => this.childContainer.appendChild(child.render()));
this.listElement.appendChild(this.childContainer);
} else {
this.childContainer.hidden = false;
}
} else {
this.childContainer.hidden = true;
// this.listElement.removeChild(this.childContainer);
}
}
public focusNext(): boolean {
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
return this.children[this.focused].focusNext();
}
this.children[this.focused].setTabbable(false);
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
}
public focusPrevious(): boolean {
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
return this.children[this.focused].focusPrevious();
}
this.children[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
}
public setParent(item: TreeviewItem) {
this.parent = this.parent;
}
public getParent(): TreeviewItem {
return this.parent;
}
public setPrevious(node: TreeviewItem) {
this.previousItem = node;
}
public setNextItem(node: TreeviewItem) {
this.nextItem = node;
}
public getPrevious(): TreeviewItem {
return this.previousItem;
}
public getNext(): TreeviewItem {
return this.nextItem;
}
public _onKeydown(key: string, alt?: boolean, shift?: boolean, ctrl?: boolean): boolean {
switch (key) {
case "ArrowUp":
this.focusPrevious();
return true;
break;
case "ArrowDown":
this.focusNext();
return true;
case "ArrowLeft":
this.collapse();
return true;
case "ArrowRight":
if (this.children[this.focused].isExpandable() && !this.children[this.focused].isExpanded()) {
this.children[this.focused].expand();
return true;
}
break;
default:
return false;
}
}
public focusOnItem() {
this.children[this.focused].focus();
}
}

153
src/ui/treeview.ts 100644
View File

@ -0,0 +1,153 @@
import { UINode } from "./node";
import { TreeviewItem } from "./treeview-item";
export class Treeview extends UINode {
public children: TreeviewItem[];
protected listElement: HTMLUListElement;
private focused: number;
protected selectCallback: (id: number) => void;
public constructor(title: string) {
super(title);
this.children = [];
this.listElement = document.createElement("ul");
this.listElement.setAttribute("role", "tree");
this.listElement.style.listStyle = "none";
this.element.appendChild(this.listElement);
this.element.setAttribute("aria-label", this.title);
this.focused = 0;
}
public add(node: TreeviewItem) {
this.children.push(node);
node._onConnect();
this.listElement.appendChild(node.render());
if (this.children.length === 1) this.calculateTabIndex();
node.onFocus(() => this.calculateFocused(node));
}
public remove(node: TreeviewItem) {
const idx = this.children.indexOf(node);
this.children.splice(idx, 1);
node._onDisconnect();
this.listElement.removeChild(node.render());
if (idx === this.focused) {
if (this.focused > 0) this.focused--;
this.calculateTabIndex();
}
}
public _onFocus() {
super._onFocus();
this.children[this.focused].focus();
}
public _onClick() {
this.children[this.focused]._onClick();
}
public _onSelect(id: number) {
if (this.selectCallback) this.selectCallback(id);
}
protected calculateStyle(): void {
super.calculateStyle();
this.element.style.overflowY = "scroll";
this.listElement.style.overflowY = "scroll";
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
switch (key) {
case "ArrowUp":
return this.focusPrevious();
break;
case "ArrowDown":
return this.focusNext();
break;
case "Enter":
this.children[this.focused].click();
return true;
break;
case "ArrowLeft":
// this.children[this.focused].collapse();
return true;
break;
case "ArrowRight":
this.children[this.focused].expand();
return true;
break;
default:
return this.children[this.focused]._onKeydown(key);
break;
}
return false;
}
protected renderAsListItem(node: UINode) {
let li = document.createElement("li");
li.appendChild(node.render());
return li;
}
public getElement(): HTMLElement {
return this.listElement;
}
public isItemFocused(): boolean {
const has = this.children.find((child) => child.isFocused);
if (has) {
console.log("I have child", has);
return true;
}
console.log("No children selected");
return false;
}
private calculateTabIndex() {
this.children[this.focused].setTabbable(true);
}
public clear() {
this.children.forEach((child) => this.remove(child));
this.children = [];
this.listElement.innerHTML = '';
this.focused = 0;
}
public getFocusedChild() {
return this.children[this.focused];
}
public getFocus() {
return this.focused;
}
public onSelect(f: (id: number) => void) {
this.selectCallback = f;
}
protected calculateFocused(node: TreeviewItem) {
const idx = this.children.indexOf(node);
this._onSelect(idx);
}
public focusPrevious() {
if (this.children[this.focused].isExpanded()) {
// return this.children[this.focused].focusPrevious();
} else {
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].focus();
}
return true;
}
public focusNext() {
if (this.children[this.focused].isExpanded()) {
// return this.children[this.focused].focusNext();
} else {
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].focus();
}
return true;
}
}

View File

@ -0,0 +1,35 @@
import { UIWindow } from "./window";
export class ViewManager {
private currentWindow: UIWindow;
private windows: UIWindow[];
private viewElement: HTMLDivElement;
public constructor() {
this.windows = [];
this.viewElement = document.createElement("div");
}
public add(window: UIWindow) {
this.windows.push(window);
window.onConnect();
this.viewElement.appendChild(window.show());
}
public remove(window: UIWindow) {
this.windows.splice(this.windows.indexOf(window), 1);
window.onDisconnect();
this.viewElement.removeChild(window.show());
}
public switchTo(window: UIWindow) {
if (this.currentWindow !== undefined) {
this.currentWindow.onDisconnect();
}
this.currentWindow = window;
this.currentWindow.onConnect();
}
public render() {
return this.viewElement;
}
}

76
src/ui/window.ts 100644
View File

@ -0,0 +1,76 @@
import { Container } from "./container";
import { UINode } from "./node";
export class UIWindow {
public title: string;
public width: number;
public height: number;
public position: { x: number; y: number; };
public container: Container;
public visible: boolean;
private element: HTMLDivElement;
private rendered: boolean;
private keyDown: (e: KeyboardEvent) => void;
public constructor(
title: string,
classname?: string,
private setTitle: boolean = true
) {
this.title = title;
this.container = new Container(this.title);
this.container._onConnect();
this.element = document.createElement("div");
if (classname) {
this.element.className = classname;
}
this.keyDown = this.onKeyDown.bind(this);
this.visible = false;
}
public add(node: UINode) {
this.container.add(node);
}
public remove(node: UINode) {
this.container.remove(node);
}
public show() {
if (this.visible) return;
if (this.setTitle) document.title = this.title;
if (this.rendered) return this.element;
this.element.appendChild(this.container.render());
this.element.addEventListener("keydown", this.keyDown);
this.element.focus();
this.visible = true;
this.rendered = true;
return this.element;
}
public hide() {
if (!this.visible) return;
this.visible = false;
this.rendered = false;
this.element.replaceChildren();
this.element.removeEventListener("keydown", this.keyDown);
}
public onKeyDown(e: KeyboardEvent) {
if (this.container._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey)) {
e.preventDefault();
}
}
public onConnect() {
return;
}
public onDisconnect() {
return;
}
public getElement(): HTMLElement {
return this.element;
}
}

1
src/vite-env.d.ts vendored 100644
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

23
tsconfig.json 100644
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}