Compare commits

...

9 Commits

38 changed files with 1193 additions and 50 deletions

View File

@@ -1,37 +1,37 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false "ignoreUnknown": false
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 2 "indentWidth": 2
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true
} }
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "single", "quoteStyle": "single",
"trailingCommas": "none", "trailingCommas": "none",
"semicolons": "asNeeded" "semicolons": "asNeeded"
} }
}, },
"assist": { "assist": {
"enabled": true, "enabled": true,
"actions": { "actions": {
"source": { "source": {
"organizeImports": "on" "organizeImports": "on"
} }
} }
} }
} }

View File

@@ -3,11 +3,18 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "st-randomness-helpers", "name": "st-randomness-helpers",
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1",
"zod": "^4.0.17",
"zustand": "^5.0.7",
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.1.4", "@biomejs/biome": "2.1.4",
"@commitlint/cli": "^19.8.1", "@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1", "@commitlint/config-conventional": "^19.8.1",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react-dom": "^19.1.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.5", "lint-staged": "^16.1.5",
}, },
@@ -81,6 +88,8 @@
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -255,6 +264,10 @@
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -265,6 +278,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -305,6 +320,10 @@
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"zustand": ["zustand@5.0.7", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],

View File

@@ -1 +1 @@
export default { extends: ['@commitlint/config-conventional'] }; export default { extends: ['@commitlint/config-conventional'] }

1
dist/index.css vendored Normal file
View File

@@ -0,0 +1 @@
.st-rnd-details{border:1px solid var(--color-neutral-6);background-color:var(--color-neutral-3);color:var(--color-neutral-12);border-radius:4px}.st-rnd-details[open] summary{background-color:var(--color-neutral-5)}.st-rnd-details>summary{cursor:pointer;padding:4px 8px;font-size:16px;font-weight:600;line-height:20px}.st-rnd-details>summary:hover{background-color:var(--color-neutral-4)}.st-rnd-details>div{padding:8px}.st-rnd-button{--btn-background-color:var(--color-primary-3);--btn-background-color-hover:var(--color-primary-4);--btn-background-color-active:var(--color-primary-5);--btn-border-color:var(--color-primary-6);--btn-color:var(--color-primary-12)}.st-rnd-button.button-neutral{--btn-background-color:var(--color-neutral-3);--btn-background-color-hover:var(--color-neutral-4);--btn-background-color-active:var(--color-neutral-5);--btn-border-color:var(--color-neutral-6);--btn-color:var(--color-neutral-12)}.st-rnd-button{display:inline-block;border:1px solid var(--btn-border-color);background-color:var(--btn-background-color);color:var(--btn-color);text-align:center;cursor:pointer;border-radius:4px;padding:4px 8px;font-weight:600}.st-rnd-button:hover{background-color:var(--btn-background-color-hover)}.st-rnd-button:active{background-color:var(--btn-background-color-active)}.st-rnd-button:disabled{background-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-background-color)40%);border-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-border-color)40%);color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-color)40%);cursor:not-allowed}.st-rnd-button:disabled:hover{background-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-background-color)40%);border-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-border-color)40%);color:color-mix(in srgb,var(--color-neutral-1)60%,var(--btn-color)40%)}.st-rnd-button.button-icon{aspect-ratio:1;width:auto;height:auto;padding:4px;font-size:.8em}#st-rnd-upload-wordlist-container{display:flex;flex-direction:column}#st-rnd-wordlist-controls{display:flex;flex-direction:row;gap:8px}#st-rnd-wordlist-controls>select{flex-grow:1}#st-rnd-wordlist-controls>button{flex-shrink:0}.st-rnd-small-text{color:var(--color-neutral-11);font-size:.8em}.st-rnd-select{border:1px solid var(--color-neutral-6);background-color:var(--color-neutral-3);color:var(--color-neutral-12);border-radius:4px;padding:4px 8px}.st-rnd-select:active{background-color:var(--color-neutral-12)}.st-rnd-select:disabled{background-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--color-neutral-3)40%);color:color-mix(in srgb,var(--color-neutral-1)60%,var(--color-neutral-12)40%);cursor:not-allowed}.st-rnd-select:disabled:active{background-color:color-mix(in srgb,var(--color-neutral-1)60%,var(--color-neutral-3)40%);color:color-mix(in srgb,var(--color-neutral-1)60%,var(--color-neutral-12)40%)}.st-rnd-dialog{border:1px solid var(--color-neutral-6);background-color:var(--color-neutral-1);color:var(--color-neutral-12);border-radius:4px;padding:8px}.st-rnd-dialog::backdrop{background-color:color-mix(in srgb,var(--color-neutral-2)80%,transparent 20%)}#st-rnd-wordlist-edit-dialog{position:relative;width:clamp(400px,100%,1200px)}#st-rnd-wordlist-edit-dialog>div{display:flex;flex-direction:column;gap:8px}#st-rnd-wordlist-edit-dialog #st-rnd-wordlist-edit-dialog-title{text-align:center;width:100%}#st-rnd-wordlist-edit-dialog #st-rnd-wordlist-edit-dialog-title>span{color:var(--color-primary-9)}#st-rnd-wordlist-edit-dialog #st-rnd-wordlist-actions{display:flex;justify-content:flex-end;gap:8px}.st-rnd-input{display:block;border:1px solid var(--color-neutral-6);background-color:var(--color-neutral-3);color:var(--color-neutral-12);border-radius:4px;width:100%;padding:4px 8px}.st-rnd-textarea{display:block;border:1px solid var(--color-neutral-6);background-color:var(--color-neutral-3);color:var(--color-neutral-12);border-radius:4px;width:100%;padding:4px 8px}:root{--color-neutral-1:#111113;--color-neutral-2:#18191b;--color-neutral-3:#212225;--color-neutral-4:#272a2d;--color-neutral-5:#2e3135;--color-neutral-6:#363a3f;--color-neutral-7:#43484e;--color-neutral-8:#5a6169;--color-neutral-9:#696e77;--color-neutral-10:#777b84;--color-neutral-11:#b0b4ba;--color-neutral-12:#edeef0;--color-primary-1:#13131e;--color-primary-2:#171625;--color-primary-3:#202248;--color-primary-4:#262a65;--color-primary-5:#303374;--color-primary-6:#3d3e82;--color-primary-7:#4a4a95;--color-primary-8:#5958b1;--color-primary-9:#5b5bd6;--color-primary-10:#6e6ade;--color-primary-11:#b1a9ff;--color-primary-12:#e0dffe}

239
dist/index.js vendored

File diff suppressed because one or more lines are too long

13
manifest.json Normal file
View File

@@ -0,0 +1,13 @@
{
"display_name": "St Randomness Helpers",
"loading_order": 1,
"requires": [],
"optional": [],
"dependencies": [],
"author": "Your name",
"version": "1.0.0",
"homePage": "",
"js": "dist/index.js",
"css": "dist/index.css",
"auto_update": true
}

View File

@@ -8,8 +8,13 @@
"@commitlint/cli": "^19.8.1", "@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1", "@commitlint/config-conventional": "^19.8.1",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react-dom": "^19.1.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.5" "lint-staged": "^16.1.5",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"zod": "^4.0.17",
"zustand": "^5.0.7"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
@@ -17,6 +22,6 @@
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
"dev": "bun build --watch src/index.ts --outdir ./dist --target browser", "dev": "bun build --watch src/index.ts --outdir ./dist --target browser",
"build": "bun build src/index.ts --outdir ./dist --target browser" "build": "bun build src/index.ts --outdir ./dist --target browser --minify"
} }
} }

12
src/Settings.tsx Normal file
View File

@@ -0,0 +1,12 @@
import Details from '@/components/ui/Details'
import WordLists from '@/components/WordLists'
const Settings = () => {
return (
<Details title="Randomness Helpers">
<WordLists />
</Details>
)
}
export default Settings

View File

@@ -0,0 +1,131 @@
import Button from '@/components/ui/Button'
import UploadButton from '@/components/ui/UploadButton'
import '@/components/wordLists.css'
import {
type ChangeEventHandler,
useCallback,
useEffect,
useState
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import Select from '@/components/ui/Select'
import EditWordList from '@/components/wordList/EditWordList'
import { useStore } from '@/store'
const WordLists = () => {
const wordLists = useStore(
useShallow((state) => Object.keys(state.wordLists))
)
const addWordList = useStore((state) => state.addWordList)
const removeWordList = useStore((state) => state.removeWordList)
const [selectedWordList, setSelectedWordList] = useState('')
const handleUpload: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const file = event.target.files?.[0]
if (file !== undefined && file.type === 'text/plain') {
const reader = new FileReader()
reader.onload = (e) => {
const fileName = file.name.replace('.txt', '')
if (wordLists.includes(fileName)) {
alert(`A word list with the name ${fileName} already exists`)
return
}
const text = e.target?.result
if (typeof text === 'string') {
const words = text.split('\n')
addWordList(fileName, words)
}
}
reader.readAsText(file)
}
},
[addWordList, wordLists]
)
useEffect(() => {
const selectedExist = wordLists.includes(selectedWordList)
if (!selectedExist) {
setSelectedWordList('')
}
}, [wordLists, selectedWordList])
return (
<>
<strong>Word Lists</strong>
<div id="st-rnd-wordlist-controls">
<Select
id="st-rnd-wordlist-select"
disabled={wordLists.length === 0}
value={selectedWordList}
onChange={(event) => {
const value = event.target.value
if (value !== '') {
setSelectedWordList(value)
}
}}
>
<option value="" disabled>
Select a word list
</option>
{wordLists.map((name) => (
<option key={name}>{name}</option>
))}
</Select>
<EditWordList currentWordList={selectedWordList} />
<Button
type="button"
icon
disabled={selectedWordList === ''}
onClick={() => {
if (selectedWordList !== '') {
const wordList = useStore.getState().wordLists[selectedWordList]
if (wordList === undefined) {
return
}
const blob = new Blob([wordList.join('\n')], {
type: 'text/plain'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${selectedWordList}.txt`
link.click()
URL.revokeObjectURL(url)
}
}}
>
<i className="fa fa-fw fa-download"></i>
</Button>
<Button
type="button"
icon
disabled={selectedWordList === ''}
onClick={() => {
if (selectedWordList !== '') {
removeWordList(selectedWordList)
setSelectedWordList('')
}
}}
>
<i className="fa fa-fw fa-trash"></i>
</Button>
</div>
<p className="st-rnd-small-text">
Wordlists are txt files with one (or more) word per line, are used in
placeholders and macros.
</p>
<div id="st-rnd-upload-wordlist-container">
<UploadButton
id="upload-wordlist"
onChange={handleUpload}
accept=".txt"
/>
</div>
</>
)
}
export default WordLists

View File

@@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/button.css'
export type ButtonProps = MergeOmitting<
DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>,
{
neutral?: boolean
icon?: boolean
}
>
const Button: FC<ButtonProps> = ({
children,
className,
neutral,
icon,
...buttonProps
}) => {
return (
<button
className={`st-rnd-button${neutral === true ? ' button-neutral' : ''}${icon === true ? ' button-icon' : ''}${
className ? ` ${className}` : ''
}`}
{...buttonProps}
>
{children}
</button>
)
}
export default Button

View File

@@ -0,0 +1,24 @@
import '@/components/ui/details.css'
import type { FC, ReactNode } from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type DetailsProps = MergeOmitting<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDetailsElement>,
HTMLDetailsElement
>,
{
title: string
children: ReactNode
}
>
const Details: FC<DetailsProps> = ({ title, className, children }) => {
return (
<details className={`st-rnd-details${className ? ` ${className}` : ''}`}>
<summary>{title}</summary>
<div>{children}</div>
</details>
)
}
export default Details

View File

@@ -0,0 +1,28 @@
import '@/components/ui/dialog.css'
import type {
DetailedHTMLProps,
DialogHTMLAttributes,
FC,
ReactNode
} from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type DialogProps = MergeOmitting<
DetailedHTMLProps<DialogHTMLAttributes<HTMLDialogElement>, HTMLDialogElement>,
{
children: ReactNode
}
>
const Dialog: FC<DialogProps> = ({ children, className, ...dialogProps }) => {
return (
<dialog
className={`st-rnd-dialog${className ? ` ${className}` : ''}`}
{...dialogProps}
>
{children}
</dialog>
)
}
export default Dialog

View File

@@ -0,0 +1,19 @@
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/input.css'
export type InputProps = MergeOmitting<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
{ fullWidth?: boolean }
>
const Input: FC<InputProps> = ({ className, ...inputProps }) => {
return (
<input
className={`st-rnd-input${className ? ` ${className}` : ''}`}
{...inputProps}
/>
)
}
export default Input

View File

@@ -0,0 +1,28 @@
import type {
DetailedHTMLProps,
FC,
ReactNode,
SelectHTMLAttributes
} from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/select.css'
type SelectProps = MergeOmitting<
DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
{
children: ReactNode
}
>
const Select: FC<SelectProps> = ({ children, className, ...selectProps }) => {
return (
<select
className={`st-rnd-select${className ? ` ${className}` : ''}`}
{...selectProps}
>
{children}
</select>
)
}
export default Select

View File

@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, FC, TextareaHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/textarea.css'
export type TextareaProps = MergeOmitting<
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>,
{
className?: string
}
>
const Textarea: FC<TextareaProps> = ({ className, ...textareaProps }) => {
return (
<textarea
className={`st-rnd-textarea${className ? ` ${className}` : ''}`}
{...textareaProps}
/>
)
}
export default Textarea

View File

@@ -0,0 +1,23 @@
import '@/components/ui/button.css'
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type UploadButtonProps = MergeOmitting<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
{
id: string
label?: string
}
>
const UploadButton: FC<UploadButtonProps> = ({ id, label, ...inputProps }) => {
return (
<>
<input type="file" id={id} {...inputProps} />
<label htmlFor={id} className={`st-rnd-button button-neutral`}>
{label || 'Upload'}
</label>
</>
)
}
export default UploadButton

View File

@@ -0,0 +1,81 @@
.st-rnd-button {
--btn-background-color: var(--color-primary-3);
--btn-background-color-hover: var(--color-primary-4);
--btn-background-color-active: var(--color-primary-5);
--btn-border-color: var(--color-primary-6);
--btn-color: var(--color-primary-12);
&.button-neutral {
--btn-background-color: var(--color-neutral-3);
--btn-background-color-hover: var(--color-neutral-4);
--btn-background-color-active: var(--color-neutral-5);
--btn-border-color: var(--color-neutral-6);
--btn-color: var(--color-neutral-12);
}
}
.st-rnd-button {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--btn-border-color);
background-color: var(--btn-background-color);
color: var(--btn-color);
font-weight: 600;
text-align: center;
cursor: pointer;
&:hover {
background-color: var(--btn-background-color-hover);
}
&:active {
background-color: var(--btn-background-color-active);
}
&:disabled {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-background-color) 40%
);
border-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-border-color) 40%
);
color: color-mix(in srgb, var(--color-neutral-1) 60%, var(--btn-color) 40%);
cursor: not-allowed;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-background-color) 40%
);
border-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-border-color) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-color) 40%
);
}
}
&.button-icon {
padding: 4px;
height: auto;
width: auto ;
aspect-ratio: 1;
font-size: 0.8em;
}
}

View File

@@ -0,0 +1,29 @@
.st-rnd-details {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
&[open] {
summary {
background-color: var(--color-neutral-5);
}
}
> summary {
padding: 4px 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
line-height: 20px;
&:hover {
background-color: var(--color-neutral-4);
}
}
> div {
padding: 8px;
}
}

View File

@@ -0,0 +1,15 @@
.st-rnd-dialog {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 8px;
background-color: var(--color-neutral-1);
color: var(--color-neutral-12);
&::backdrop {
background-color: color-mix(
in srgb,
var(--color-neutral-2) 80%,
transparent 20%
);
}
}

View File

@@ -0,0 +1,10 @@
.st-rnd-input {
display: block;
width: 100%;
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 4px 8px;
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
}

View File

@@ -0,0 +1,38 @@
.st-rnd-select {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 4px 8px;
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
&:active {
background-color: var(--color-neutral-12);
}
&:disabled {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-3) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-12) 40%
);
cursor: not-allowed;
&:active {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-3) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-12) 40%
);
}
}
}

View File

@@ -0,0 +1,9 @@
.st-rnd-textarea {
display: block;
width: 100%;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
}

View File

@@ -0,0 +1,114 @@
import { type FC, useRef, useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import { useStore } from '@/store'
import '@/components/wordList/editWordlList.css'
import Input from '@/components/ui/Input'
import Textarea from '@/components/ui/Textarea'
export interface RenameWordListProps {
currentWordList: string
}
const EditWordList: FC<RenameWordListProps> = ({ currentWordList }) => {
const [internalStatus, setInternalStatus] = useState<string>('')
const [newName, setNewName] = useState<string>('')
const dialogRef = useRef<HTMLDialogElement>(null)
return (
<>
<Button
type="button"
icon
disabled={currentWordList === ''}
onClick={() => {
if (dialogRef.current === null || currentWordList === '') {
return
}
const wordList = useStore.getState().wordLists[currentWordList]
if (wordList === undefined) {
return
}
setNewName(currentWordList)
setInternalStatus(wordList.join('\n'))
dialogRef.current.showModal()
}}
>
<i className="fa fa-fw fa-pencil"></i>
</Button>
<Dialog id="st-rnd-wordlist-edit-dialog" ref={dialogRef}>
<div>
<h2 id="st-rnd-wordlist-edit-dialog-title">
Edit Word List: <span>{currentWordList}</span>
</h2>
<label htmlFor="st-rnd-wordlist-name">Name</label>
<p className="st-rnd-small-text">
Name of the word list, used in placeholder and macros.
</p>
<Input
id="st-rnd-wordlist-name"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<label htmlFor="st-rnd-wordlist-words">Words</label>
<p className="st-rnd-small-text">
Words separated by newlines, words can be more than one word!
</p>
<Textarea
id="st-rnd-wordlist-words"
value={internalStatus}
onChange={(e) => setInternalStatus(e.target.value)}
rows={10}
/>
<div id="st-rnd-wordlist-actions">
<Button
type="button"
neutral
onClick={() => {
if (dialogRef.current === null) {
return
}
dialogRef.current.close()
setInternalStatus('')
setNewName('')
}}
>
Close
</Button>
<Button
type="button"
onClick={() => {
if (
currentWordList === '' ||
newName === undefined ||
internalStatus === undefined ||
dialogRef.current === null
) {
return
}
if (newName === '') {
alert('Name cannot be empty')
return
}
const addWordList = useStore.getState().addWordList
if (currentWordList !== newName) {
const removeWordList = useStore.getState().removeWordList
removeWordList(currentWordList)
}
addWordList(newName, internalStatus.split('\n'))
dialogRef.current.close()
setInternalStatus('')
setNewName('')
}}
>
Save
</Button>
</div>
</div>
</Dialog>
</>
)
}
export default EditWordList

View File

@@ -0,0 +1,24 @@
#st-rnd-wordlist-edit-dialog {
position: relative;
width: clamp(400px, 100%, 1200px);
& > div {
display: flex;
flex-direction: column;
gap: 8px;
}
#st-rnd-wordlist-edit-dialog-title {
width: 100%;
text-align: center;
& > span {
color: var(--color-primary-9);
}
}
#st-rnd-wordlist-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}

View File

@@ -0,0 +1,21 @@
#st-rnd-upload-wordlist-container {
display: flex;
flex-direction: column;
}
#st-rnd-wordlist-controls {
display: flex;
flex-direction: row;
gap: 8px;
& > select {
flex-grow: 1;
}
& > button {
flex-shrink: 0;
}
}
.st-rnd-small-text {
font-size: 0.8em;
color: var(--color-neutral-11);
}

1
src/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const MODULE_NAME = 'st-randomness-helpers'

27
src/gui.css Normal file
View File

@@ -0,0 +1,27 @@
:root {
--color-neutral-1: #111113;
--color-neutral-2: #18191b;
--color-neutral-3: #212225;
--color-neutral-4: #272a2d;
--color-neutral-5: #2e3135;
--color-neutral-6: #363a3f;
--color-neutral-7: #43484e;
--color-neutral-8: #5a6169;
--color-neutral-9: #696e77;
--color-neutral-10: #777b84;
--color-neutral-11: #b0b4ba;
--color-neutral-12: #edeef0;
--color-primary-1: #13131e;
--color-primary-2: #171625;
--color-primary-3: #202248;
--color-primary-4: #262a65;
--color-primary-5: #303374;
--color-primary-6: #3d3e82;
--color-primary-7: #4a4a95;
--color-primary-8: #5958b1;
--color-primary-9: #5b5bd6;
--color-primary-10: #6e6ade;
--color-primary-11: #b1a9ff;
--color-primary-12: #e0dffe;
}

24
src/gui.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import Settings from '@/Settings'
import '@/gui.css'
const renderSettingsGui = () => {
const rootContainer = document.getElementById('extensions_settings')
if (rootContainer === null) {
throw new Error('[st-randomness-helpers] root container not found')
}
const rootElement = document.createElement('div')
rootContainer.appendChild(rootElement)
ReactDOM.createRoot(rootElement).render(
<StrictMode>
<Settings />
</StrictMode>
)
}
export default renderSettingsGui

View File

@@ -1,4 +1,10 @@
console.log('Hello via Bun!') import renderSettingsGui from '@/gui'
import initializeMacros from '@/macros/initializeMacros'
import initializePlaceholders from '@/placeholders/initializePlaceholders'
const context = SillyTavern.getContext() // Initialize the React GUI
console.log(context) renderSettingsGui()
// Setup the macros
initializeMacros()
// Setup the placeholders
initializePlaceholders()

View File

@@ -0,0 +1,7 @@
import { setupRandomWordMacros } from '@/services/randomWord'
const initializeMacros = () => {
setupRandomWordMacros()
}
export default initializeMacros

View File

@@ -0,0 +1,7 @@
import { setupRandomWordPlaceholders } from '@/services/randomWord'
const initializePlaceholders = () => {
setupRandomWordPlaceholders()
}
export default initializePlaceholders

View File

@@ -0,0 +1,70 @@
import { useStore } from '@/store'
import type { generationData } from '@/types/SillyTavern'
const registerRandomWordMacros = (dictName: string) => {
const { registerMacro } = SillyTavern.getContext()
const words = useStore.getState().wordLists[dictName]
if (words === undefined || words.length === 0) {
// If the word list is empty or undefined, don't register the macro
return
}
registerMacro(
`randomWord::${dictName}`,
() => {
const words = useStore.getState().wordLists[dictName] as string[]
return words[Math.floor(Math.random() * words.length)] as string
},
`Generates a random word from the word list '${dictName}'`
)
registerMacro(
`placeholder::randomWord::${dictName}`,
() => {
return `%%randomWord::${dictName}%%`
},
`Returns a placeholder for a random word from the word list '${dictName}'`
)
}
export const setupRandomWordMacros = () => {
for (const wordList of Object.keys(useStore.getState().wordLists)) {
registerRandomWordMacros(wordList)
}
useStore.subscribe(
(state) => Object.keys(state.wordLists),
(wordLists, oldWordLists) => {
const wordListsSet = new Set(wordLists)
const oldWordListsSet = new Set(oldWordLists)
const deletedWordLists = oldWordListsSet.difference(wordListsSet)
const newWordLists = wordListsSet.difference(oldWordListsSet)
const { unregisterMacro } = SillyTavern.getContext()
for (const wordList of deletedWordLists) {
unregisterMacro(`randomWord::${wordList}`)
unregisterMacro(`placeholder::randomWord::${wordList}`)
}
for (const wordList of newWordLists) {
registerRandomWordMacros(wordList)
}
}
)
}
const randomWordPlaceholderRegex = /%%randomWord::([\w\W]*?)%%/g
export const setupRandomWordPlaceholders = () => {
const { eventTypes, eventSource } = SillyTavern.getContext()
eventSource.on(eventTypes.GENERATE_AFTER_DATA, (chat: generationData) => {
chat.prompt = chat.prompt.replaceAll(
randomWordPlaceholderRegex,
(_, wordList: string) => {
const words = useStore.getState().wordLists[wordList]
if (words === undefined || words.length === 0) {
return ''
}
return words[Math.floor(Math.random() * words.length)] ?? ''
}
)
})
}

40
src/store/configSlice.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { StateCreator } from 'zustand'
import { MODULE_NAME } from '@/constants'
export type WordList = string[]
export type WordLists = Record<string, string[]>
export interface Config {
wordLists: WordLists
}
export interface ConfigSlice extends Config {
addWordList: (name: string, words: string[]) => void
removeWordList: (name: string) => void
}
export const createConfigSlice: StateCreator<ConfigSlice> = (set) => {
const context = SillyTavern.getContext()
// biome-ignore lint/suspicious/noExplicitAny: SillyTavern's extensionSettings is not properly typed
const extensionSettings: Record<string, any> = context.extensionSettings
if (extensionSettings[MODULE_NAME] === undefined) {
extensionSettings[MODULE_NAME] = {
wordLists: {}
}
}
const settings = extensionSettings[MODULE_NAME]
return {
wordLists: settings.wordLists,
addWordList: (name, words) =>
set({ wordLists: { ...settings.wordLists, [name]: words } }),
removeWordList: (name) => {
const newWordLists = { ...settings.wordLists }
delete newWordLists[name]
set({ wordLists: newWordLists })
}
}
}

19
src/store/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import { MODULE_NAME } from '@/constants'
import { type ConfigSlice, createConfigSlice } from '@/store/configSlice'
export const useStore = create<ConfigSlice>()(
subscribeWithSelector(createConfigSlice)
)
useStore.subscribe(
(state) => state.wordLists,
(wordLists) => {
const context = SillyTavern.getContext()
// biome-ignore lint/suspicious/noExplicitAny: SillyTavern's extensionSettings is not properly typed
const extensionSettings: Record<string, any> = context.extensionSettings
extensionSettings[MODULE_NAME].wordLists = wordLists
context.saveSettingsDebounced()
}
)

4
src/types/SillyTavern.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface generationData {
[key: string]: unknown
prompt: string
}

5
src/types/helpers.ts Normal file
View File

@@ -0,0 +1,5 @@
export type MergeOmitting<ReplaceableType, ReplacerType> = Omit<
ReplaceableType,
keyof ReplacerType
> &
ReplacerType

View File

@@ -24,7 +24,12 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false,
//Paths
"paths": {
"@/*": ["./src/*"]
}
}, },
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

5
types/global.d.ts vendored
View File

@@ -1,6 +1,3 @@
export {}
import '../../../../../public/global' import '../../../../../public/global'
declare global { declare global {}
}