├── .eslintrc.json ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── patches └── monaco-editor+0.52.0.patch ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── site.webmanifest ├── src ├── Classes │ ├── CustomElement.tsx │ ├── ParameterSet.tsx │ └── SwalReact.ts ├── Components │ ├── App.tsx │ ├── CreateSchemaDialog.tsx │ ├── ExplorerFolder.tsx │ ├── Main.tsx │ ├── Ruby.tsx │ ├── SchemaEditor.tsx │ ├── Spinner.tsx │ ├── Table.tsx │ ├── Tooltip.tsx │ ├── TooltipChar.tsx │ └── TooltipLabel.tsx ├── Yitizi.d.ts ├── actions.ts ├── consts.ts ├── editor │ ├── global.d.ts │ ├── initialize.ts │ ├── libs.ts │ ├── setup.ts │ └── types.d.ts ├── evaluate.ts ├── index.tsx ├── options.tsx ├── samples.ts ├── state.ts ├── utils.tsx └── vite-env.d.ts ├── tsconfig.json ├── vite.config.ts └── vite.cos.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "plugin:react/jsx-runtime", 8 | "plugin:react-hooks/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:import/recommended", 11 | "plugin:import/typescript", 12 | "prettier" 13 | ], 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "project": true 17 | }, 18 | "plugins": ["react", "@typescript-eslint", "import"], 19 | "settings": { 20 | "import/core-modules": ["react", "react-dom", "react-dom/client"] 21 | }, 22 | "rules": { 23 | "@typescript-eslint/no-unused-expressions": [ 24 | "error", 25 | { 26 | "allowShortCircuit": true, 27 | "allowTaggedTemplates": true 28 | } 29 | ], 30 | 31 | "import/order": [ 32 | "error", 33 | { 34 | "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"], "type", "unknown", "object"], 35 | "pathGroups": [ 36 | { 37 | "pattern": "@**/**", 38 | "group": "external", 39 | "position": "after" 40 | } 41 | ], 42 | "pathGroupsExcludedImportTypes": ["@**/**"], 43 | "newlines-between": "always", 44 | "alphabetize": { "order": "asc", "orderImportKind": "asc", "caseInsensitive": true }, 45 | "warnOnUnassignedImports": true 46 | } 47 | ], 48 | "import/no-unresolved": ["error", { "ignore": ["\\?(?:worker(&inline|&url)?|raw)$"] }] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to gh-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.11' 20 | - run: npm ci 21 | - run: npm test 22 | - run: npm run build:cos 23 | - name: Install coscmd 24 | run: pip install coscmd 25 | - name: Configure coscmd 26 | run: coscmd config -a ${{ secrets.SecretId }} -s "${{ secrets.SecretKey }}" -b ${{ vars.BUCKET }} -r ${{ vars.REGION }} 27 | - name: Publish static files to COS 28 | run: | 29 | cd ./build/ 30 | #coscmd upload -rs --delete -f ./ / --ignore index.html 31 | #find . -mindepth 1 ! -name index.html -exec rm -rf {} + 32 | coscmd upload -rs --delete -f ./assets /assets 33 | rm -rf assets 34 | - name: Upload Artifact 35 | uses: actions/upload-pages-artifact@v3 36 | with: 37 | path: ./build 38 | 39 | deploy: 40 | runs-on: ubuntu-latest 41 | needs: build 42 | permissions: 43 | pages: write 44 | id-token: write 45 | environment: 46 | name: github-pages 47 | url: ${{ steps.deployment.outputs.page_url }} 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | - dev-* 9 | paths-ignore: 10 | - "**.md" 11 | - "LICENSE" 12 | pull_request: 13 | branches: 14 | - main 15 | - dev 16 | - dev-* 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 22 26 | - run: npm ci 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # caching 15 | /.parcel-cache 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "bracketSameLine": true, 5 | "quoteProps": "consistent" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "styled-components.vscode-styled-components", 6 | "bierner.comment-tagged-templates" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[jsonc]": { 4 | "editor.formatOnSave": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 nk2028 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tshet-uinh Autoderiver 切韻音系自動推導器 2 | 3 | Tshet-uinh Autoderiver is an online tool that automatically generates phonological reconstructions of the Tshet-uinh phonological system, as well as extrapolated historical and modern phonological systems derived from it. 4 | 5 | This tool is part of the nk2028 organisation’s suite of computational linguistics projects, which aim to advance research in historical Chinese phonology and beyond. 6 | 7 | ## Try It Online 8 | 9 | The tool is available as a web-based interface at . 10 | 11 | ## How It Works 12 | 13 | Tshet-uinh Autoderiver uses [TshetUinh.js](https://github.com/nk2028/tshet-uinh-js) as its underlying library, which provides the computational framework for deriving phonological systems. By leveraging this JavaScript-based library, the tool is capable of handling a wide range of reconstructions, extrapolations, and creative phonological experiments. 14 | 15 | The interface is user-friendly and allows for instant results without requiring advanced technical knowledge. However, for users with programming experience, the tool can be extended by modifying or adding derivation scripts, with the examples in the [tshet-uinh-examples](https://github.com/nk2028/tshet-uinh-examples) repository serving as a helpful reference. 16 | 17 | ## Features 18 | 19 | 1. 簡體字、異體字轉換 20 | 1. 根據釋義手動選擇多音字 21 | 1. (待實現)預測多音字最可能的發音 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 切韻音系自動推導器 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tshet-uinh-autoderiver", 3 | "version": "0.2.0", 4 | "description": "An automatic tool to generate derivatives of the Qieyun phonological system", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "postinstall": "patch-package", 9 | "start": "vite", 10 | "build": "vite build", 11 | "build:cos": "vite build -c vite.cos.config.ts", 12 | "preview": "vite preview", 13 | "typecheck": "tsc --noEmit", 14 | "lint": "eslint ./src", 15 | "lint:fix": "eslint ./src --fix", 16 | "format": "prettier ./src --list-different", 17 | "format:fix": "prettier ./src --write", 18 | "test": "npm run typecheck && npm run lint && npm run format" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/nk2028/tshet-uinh-autoderiver.git" 23 | }, 24 | "keywords": [ 25 | "historical-linguistics", 26 | "linguistics", 27 | "middle-chinese", 28 | "tshet-uinh", 29 | "qieyun" 30 | ], 31 | "dependencies": { 32 | "@emotion/css": "^11.13.0", 33 | "@emotion/react": "^11.13.3", 34 | "@emotion/styled": "^11.13.0", 35 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 36 | "@fortawesome/free-regular-svg-icons": "^6.6.0", 37 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 38 | "@fortawesome/react-fontawesome": "^0.2.2", 39 | "@monaco-editor/react": "^4.6.0", 40 | "monaco-editor": "^0.52.0", 41 | "purecss": "^3.0.0", 42 | "react": "^18.3.1", 43 | "react-dom": "^18.3.1", 44 | "sweetalert2": "<=11.4.8", 45 | "sweetalert2-react-content": "^5.0.7", 46 | "tshet-uinh": "^0.15.0", 47 | "tshet-uinh-deriver-tools": "^0.2.0", 48 | "yitizi": "^0.1.2" 49 | }, 50 | "devDependencies": { 51 | "@emotion/babel-plugin": "^11.12.0", 52 | "@types/node": "^22.7.4", 53 | "@types/react": "^18.3.10", 54 | "@types/react-dom": "^18.3.0", 55 | "@types/wicg-file-system-access": "^2023.10.5", 56 | "@typescript-eslint/eslint-plugin": "^8.7.0", 57 | "@typescript-eslint/parser": "^8.7.0", 58 | "@vitejs/plugin-react": "^4.3.2", 59 | "eslint": "^8.57.1", 60 | "eslint-config-prettier": "^9.1.0", 61 | "eslint-import-resolver-typescript": "^3.6.3", 62 | "eslint-plugin-import": "^2.30.0", 63 | "eslint-plugin-react": "^7.37.0", 64 | "eslint-plugin-react-hooks": "^4.6.2", 65 | "patch-package": "^8.0.0", 66 | "prettier": "^3.3.3", 67 | "typescript": "^5.6.2", 68 | "vite": "^5.4.8" 69 | }, 70 | "author": "Project NK2028", 71 | "license": "CC0-1.0", 72 | "bugs": { 73 | "url": "https://github.com/nk2028/tshet-uinh-autoderiver/issues" 74 | }, 75 | "homepage": "https://nk2028.shn.hk/tshet-uinh-autoderiver/" 76 | } 77 | -------------------------------------------------------------------------------- /patches/monaco-editor+0.52.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/monaco-editor/esm/metadata.d.ts b/node_modules/monaco-editor/esm/metadata.d.ts 2 | index 11b22bd..93322b5 100644 3 | --- a/node_modules/monaco-editor/esm/metadata.d.ts 4 | +++ b/node_modules/monaco-editor/esm/metadata.d.ts 5 | @@ -20,7 +20,7 @@ export const features: IFeatureDefinition[]; 6 | 7 | export const languages: IFeatureDefinition[]; 8 | 9 | -export type EditorLanguage = 'abap' | 'apex' | 'azcli' | 'bat' | 'bicep' | 'cameligo' | 'clojure' | 'coffee' | 'cpp' | 'csharp' | 'csp' | 'css' | 'cypher' | 'dart' | 'dockerfile' | 'ecl' | 'elixir' | 'flow9' | 'freemarker2' | 'fsharp' | 'go' | 'graphql' | 'handlebars' | 'hcl' | 'html' | 'ini' | 'java' | 'javascript' | 'json' | 'julia' | 'kotlin' | 'less' | 'lexon' | 'liquid' | 'lua' | 'm3' | 'markdown' | 'mdx' | 'mips' | 'msdax' | 'mysql' | 'objective-c' | 'pascal' | 'pascaligo' | 'perl' | 'pgsql' | 'php' | 'pla' | 'postiats' | 'powerquery' | 'powershell' | 'protobuf' | 'pug' | 'python' | 'qsharp' | 'r' | 'razor' | 'redis' | 'redshift' | 'restructuredtext' | 'ruby' | 'rust' | 'sb' | 'scala' | 'scheme' | 'scss' | 'shell' | 'solidity' | 'sophia' | 'sparql' | 'sql' | 'st' | 'swift' | 'systemverilog' | 'tcl' | 'twig' | 'typescript' | 'typespec' | 'vb' | 'wgsl' | 'xml' | 'yaml'; 10 | +export type EditorLanguage = 'javascript' | 'typescript'; 11 | 12 | export type EditorFeature = 'anchorSelect' | 'bracketMatching' | 'browser' | 'caretOperations' | 'clipboard' | 'codeAction' | 'codeEditor' | 'codelens' | 'colorPicker' | 'comment' | 'contextmenu' | 'cursorUndo' | 'diffEditor' | 'diffEditorBreadcrumbs' | 'dnd' | 'documentSymbols' | 'dropOrPasteInto' | 'find' | 'folding' | 'fontZoom' | 'format' | 'gotoError' | 'gotoLine' | 'gotoSymbol' | 'hover' | 'iPadShowKeyboard' | 'inPlaceReplace' | 'indentation' | 'inlayHints' | 'inlineCompletions' | 'inlineEdit' | 'inlineEdits' | 'inlineProgress' | 'inspectTokens' | 'lineSelection' | 'linesOperations' | 'linkedEditing' | 'links' | 'longLinesHelper' | 'multicursor' | 'parameterHints' | 'placeholderText' | 'quickCommand' | 'quickHelp' | 'quickOutline' | 'readOnlyMessage' | 'referenceSearch' | 'rename' | 'sectionHeaders' | 'semanticTokens' | 'smartSelect' | 'snippet' | 'stickyScroll' | 'suggest' | 'toggleHighContrast' | 'toggleTabFocusMode' | 'tokenization' | 'unicodeHighlighter' | 'unusualLineTerminators' | 'wordHighlighter' | 'wordOperations' | 'wordPartOperations'; 13 | 14 | diff --git a/node_modules/monaco-editor/esm/metadata.js b/node_modules/monaco-editor/esm/metadata.js 15 | index 1e98235..f8ea2c7 100644 16 | --- a/node_modules/monaco-editor/esm/metadata.js 17 | +++ b/node_modules/monaco-editor/esm/metadata.js 18 | @@ -268,328 +268,10 @@ exports.features = [ 19 | } 20 | ]; 21 | exports.languages = [ 22 | - { 23 | - "label": "abap", 24 | - "entry": "vs/basic-languages/abap/abap.contribution" 25 | - }, 26 | - { 27 | - "label": "apex", 28 | - "entry": "vs/basic-languages/apex/apex.contribution" 29 | - }, 30 | - { 31 | - "label": "azcli", 32 | - "entry": "vs/basic-languages/azcli/azcli.contribution" 33 | - }, 34 | - { 35 | - "label": "bat", 36 | - "entry": "vs/basic-languages/bat/bat.contribution" 37 | - }, 38 | - { 39 | - "label": "bicep", 40 | - "entry": "vs/basic-languages/bicep/bicep.contribution" 41 | - }, 42 | - { 43 | - "label": "cameligo", 44 | - "entry": "vs/basic-languages/cameligo/cameligo.contribution" 45 | - }, 46 | - { 47 | - "label": "clojure", 48 | - "entry": "vs/basic-languages/clojure/clojure.contribution" 49 | - }, 50 | - { 51 | - "label": "coffee", 52 | - "entry": "vs/basic-languages/coffee/coffee.contribution" 53 | - }, 54 | - { 55 | - "label": "cpp", 56 | - "entry": "vs/basic-languages/cpp/cpp.contribution" 57 | - }, 58 | - { 59 | - "label": "csharp", 60 | - "entry": "vs/basic-languages/csharp/csharp.contribution" 61 | - }, 62 | - { 63 | - "label": "csp", 64 | - "entry": "vs/basic-languages/csp/csp.contribution" 65 | - }, 66 | - { 67 | - "label": "css", 68 | - "entry": [ 69 | - "vs/basic-languages/css/css.contribution", 70 | - "vs/language/css/monaco.contribution" 71 | - ], 72 | - "worker": { 73 | - "id": "vs/language/css/cssWorker", 74 | - "entry": "vs/language/css/css.worker" 75 | - } 76 | - }, 77 | - { 78 | - "label": "cypher", 79 | - "entry": "vs/basic-languages/cypher/cypher.contribution" 80 | - }, 81 | - { 82 | - "label": "dart", 83 | - "entry": "vs/basic-languages/dart/dart.contribution" 84 | - }, 85 | - { 86 | - "label": "dockerfile", 87 | - "entry": "vs/basic-languages/dockerfile/dockerfile.contribution" 88 | - }, 89 | - { 90 | - "label": "ecl", 91 | - "entry": "vs/basic-languages/ecl/ecl.contribution" 92 | - }, 93 | - { 94 | - "label": "elixir", 95 | - "entry": "vs/basic-languages/elixir/elixir.contribution" 96 | - }, 97 | - { 98 | - "label": "flow9", 99 | - "entry": "vs/basic-languages/flow9/flow9.contribution" 100 | - }, 101 | - { 102 | - "label": "freemarker2", 103 | - "entry": "vs/basic-languages/freemarker2/freemarker2.contribution" 104 | - }, 105 | - { 106 | - "label": "fsharp", 107 | - "entry": "vs/basic-languages/fsharp/fsharp.contribution" 108 | - }, 109 | - { 110 | - "label": "go", 111 | - "entry": "vs/basic-languages/go/go.contribution" 112 | - }, 113 | - { 114 | - "label": "graphql", 115 | - "entry": "vs/basic-languages/graphql/graphql.contribution" 116 | - }, 117 | - { 118 | - "label": "handlebars", 119 | - "entry": "vs/basic-languages/handlebars/handlebars.contribution" 120 | - }, 121 | - { 122 | - "label": "hcl", 123 | - "entry": "vs/basic-languages/hcl/hcl.contribution" 124 | - }, 125 | - { 126 | - "label": "html", 127 | - "entry": [ 128 | - "vs/basic-languages/html/html.contribution", 129 | - "vs/language/html/monaco.contribution" 130 | - ], 131 | - "worker": { 132 | - "id": "vs/language/html/htmlWorker", 133 | - "entry": "vs/language/html/html.worker" 134 | - } 135 | - }, 136 | - { 137 | - "label": "ini", 138 | - "entry": "vs/basic-languages/ini/ini.contribution" 139 | - }, 140 | - { 141 | - "label": "java", 142 | - "entry": "vs/basic-languages/java/java.contribution" 143 | - }, 144 | { 145 | "label": "javascript", 146 | "entry": "vs/basic-languages/javascript/javascript.contribution" 147 | }, 148 | - { 149 | - "label": "json", 150 | - "entry": "vs/language/json/monaco.contribution", 151 | - "worker": { 152 | - "id": "vs/language/json/jsonWorker", 153 | - "entry": "vs/language/json/json.worker" 154 | - } 155 | - }, 156 | - { 157 | - "label": "julia", 158 | - "entry": "vs/basic-languages/julia/julia.contribution" 159 | - }, 160 | - { 161 | - "label": "kotlin", 162 | - "entry": "vs/basic-languages/kotlin/kotlin.contribution" 163 | - }, 164 | - { 165 | - "label": "less", 166 | - "entry": "vs/basic-languages/less/less.contribution" 167 | - }, 168 | - { 169 | - "label": "lexon", 170 | - "entry": "vs/basic-languages/lexon/lexon.contribution" 171 | - }, 172 | - { 173 | - "label": "liquid", 174 | - "entry": "vs/basic-languages/liquid/liquid.contribution" 175 | - }, 176 | - { 177 | - "label": "lua", 178 | - "entry": "vs/basic-languages/lua/lua.contribution" 179 | - }, 180 | - { 181 | - "label": "m3", 182 | - "entry": "vs/basic-languages/m3/m3.contribution" 183 | - }, 184 | - { 185 | - "label": "markdown", 186 | - "entry": "vs/basic-languages/markdown/markdown.contribution" 187 | - }, 188 | - { 189 | - "label": "mdx", 190 | - "entry": "vs/basic-languages/mdx/mdx.contribution" 191 | - }, 192 | - { 193 | - "label": "mips", 194 | - "entry": "vs/basic-languages/mips/mips.contribution" 195 | - }, 196 | - { 197 | - "label": "msdax", 198 | - "entry": "vs/basic-languages/msdax/msdax.contribution" 199 | - }, 200 | - { 201 | - "label": "mysql", 202 | - "entry": "vs/basic-languages/mysql/mysql.contribution" 203 | - }, 204 | - { 205 | - "label": "objective-c", 206 | - "entry": "vs/basic-languages/objective-c/objective-c.contribution" 207 | - }, 208 | - { 209 | - "label": "pascal", 210 | - "entry": "vs/basic-languages/pascal/pascal.contribution" 211 | - }, 212 | - { 213 | - "label": "pascaligo", 214 | - "entry": "vs/basic-languages/pascaligo/pascaligo.contribution" 215 | - }, 216 | - { 217 | - "label": "perl", 218 | - "entry": "vs/basic-languages/perl/perl.contribution" 219 | - }, 220 | - { 221 | - "label": "pgsql", 222 | - "entry": "vs/basic-languages/pgsql/pgsql.contribution" 223 | - }, 224 | - { 225 | - "label": "php", 226 | - "entry": "vs/basic-languages/php/php.contribution" 227 | - }, 228 | - { 229 | - "label": "pla", 230 | - "entry": "vs/basic-languages/pla/pla.contribution" 231 | - }, 232 | - { 233 | - "label": "postiats", 234 | - "entry": "vs/basic-languages/postiats/postiats.contribution" 235 | - }, 236 | - { 237 | - "label": "powerquery", 238 | - "entry": "vs/basic-languages/powerquery/powerquery.contribution" 239 | - }, 240 | - { 241 | - "label": "powershell", 242 | - "entry": "vs/basic-languages/powershell/powershell.contribution" 243 | - }, 244 | - { 245 | - "label": "protobuf", 246 | - "entry": "vs/basic-languages/protobuf/protobuf.contribution" 247 | - }, 248 | - { 249 | - "label": "pug", 250 | - "entry": "vs/basic-languages/pug/pug.contribution" 251 | - }, 252 | - { 253 | - "label": "python", 254 | - "entry": "vs/basic-languages/python/python.contribution" 255 | - }, 256 | - { 257 | - "label": "qsharp", 258 | - "entry": "vs/basic-languages/qsharp/qsharp.contribution" 259 | - }, 260 | - { 261 | - "label": "r", 262 | - "entry": "vs/basic-languages/r/r.contribution" 263 | - }, 264 | - { 265 | - "label": "razor", 266 | - "entry": "vs/basic-languages/razor/razor.contribution" 267 | - }, 268 | - { 269 | - "label": "redis", 270 | - "entry": "vs/basic-languages/redis/redis.contribution" 271 | - }, 272 | - { 273 | - "label": "redshift", 274 | - "entry": "vs/basic-languages/redshift/redshift.contribution" 275 | - }, 276 | - { 277 | - "label": "restructuredtext", 278 | - "entry": "vs/basic-languages/restructuredtext/restructuredtext.contribution" 279 | - }, 280 | - { 281 | - "label": "ruby", 282 | - "entry": "vs/basic-languages/ruby/ruby.contribution" 283 | - }, 284 | - { 285 | - "label": "rust", 286 | - "entry": "vs/basic-languages/rust/rust.contribution" 287 | - }, 288 | - { 289 | - "label": "sb", 290 | - "entry": "vs/basic-languages/sb/sb.contribution" 291 | - }, 292 | - { 293 | - "label": "scala", 294 | - "entry": "vs/basic-languages/scala/scala.contribution" 295 | - }, 296 | - { 297 | - "label": "scheme", 298 | - "entry": "vs/basic-languages/scheme/scheme.contribution" 299 | - }, 300 | - { 301 | - "label": "scss", 302 | - "entry": "vs/basic-languages/scss/scss.contribution" 303 | - }, 304 | - { 305 | - "label": "shell", 306 | - "entry": "vs/basic-languages/shell/shell.contribution" 307 | - }, 308 | - { 309 | - "label": "solidity", 310 | - "entry": "vs/basic-languages/solidity/solidity.contribution" 311 | - }, 312 | - { 313 | - "label": "sophia", 314 | - "entry": "vs/basic-languages/sophia/sophia.contribution" 315 | - }, 316 | - { 317 | - "label": "sparql", 318 | - "entry": "vs/basic-languages/sparql/sparql.contribution" 319 | - }, 320 | - { 321 | - "label": "sql", 322 | - "entry": "vs/basic-languages/sql/sql.contribution" 323 | - }, 324 | - { 325 | - "label": "st", 326 | - "entry": "vs/basic-languages/st/st.contribution" 327 | - }, 328 | - { 329 | - "label": "swift", 330 | - "entry": "vs/basic-languages/swift/swift.contribution" 331 | - }, 332 | - { 333 | - "label": "systemverilog", 334 | - "entry": "vs/basic-languages/systemverilog/systemverilog.contribution" 335 | - }, 336 | - { 337 | - "label": "tcl", 338 | - "entry": "vs/basic-languages/tcl/tcl.contribution" 339 | - }, 340 | - { 341 | - "label": "twig", 342 | - "entry": "vs/basic-languages/twig/twig.contribution" 343 | - }, 344 | { 345 | "label": "typescript", 346 | "entry": [ 347 | @@ -600,25 +282,5 @@ exports.languages = [ 348 | "id": "vs/language/typescript/tsWorker", 349 | "entry": "vs/language/typescript/ts.worker" 350 | } 351 | - }, 352 | - { 353 | - "label": "typespec", 354 | - "entry": "vs/basic-languages/typespec/typespec.contribution" 355 | - }, 356 | - { 357 | - "label": "vb", 358 | - "entry": "vs/basic-languages/vb/vb.contribution" 359 | - }, 360 | - { 361 | - "label": "wgsl", 362 | - "entry": "vs/basic-languages/wgsl/wgsl.contribution" 363 | - }, 364 | - { 365 | - "label": "xml", 366 | - "entry": "vs/basic-languages/xml/xml.contribution" 367 | - }, 368 | - { 369 | - "label": "yaml", 370 | - "entry": "vs/basic-languages/yaml/yaml.contribution" 371 | } 372 | ]; 373 | diff --git a/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css b/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css 374 | index 1d7ede8..23b85be 100644 375 | --- a/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css 376 | +++ b/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css 377 | @@ -3,6 +3,6 @@ 378 | * Licensed under the MIT License. See License.txt in the project root for license information. 379 | *--------------------------------------------------------------------------------------------*/ 380 | 381 | -.monaco-mouse-cursor-text { 382 | +body:not(.dragging) .monaco-mouse-cursor-text { 383 | cursor: text; 384 | } 385 | diff --git a/node_modules/monaco-editor/esm/vs/base/common/buffer.js b/node_modules/monaco-editor/esm/vs/base/common/buffer.js 386 | index 4534981..024fdcc 100644 387 | --- a/node_modules/monaco-editor/esm/vs/base/common/buffer.js 388 | +++ b/node_modules/monaco-editor/esm/vs/base/common/buffer.js 389 | @@ -2,22 +2,9 @@ 390 | * Copyright (c) Microsoft Corporation. All rights reserved. 391 | * Licensed under the MIT License. See License.txt in the project root for license information. 392 | *--------------------------------------------------------------------------------------------*/ 393 | -import { Lazy } from './lazy.js'; 394 | -const hasBuffer = (typeof Buffer !== 'undefined'); 395 | -const indexOfTable = new Lazy(() => new Uint8Array(256)); 396 | let textDecoder; 397 | export class VSBuffer { 398 | - /** 399 | - * When running in a nodejs context, if `actual` is not a nodejs Buffer, the backing store for 400 | - * the returned `VSBuffer` instance might use a nodejs Buffer allocated from node's Buffer pool, 401 | - * which is not transferrable. 402 | - */ 403 | static wrap(actual) { 404 | - if (hasBuffer && !(Buffer.isBuffer(actual))) { 405 | - // https://nodejs.org/dist/latest-v10.x/docs/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length 406 | - // Create a zero-copy Buffer wrapper around the ArrayBuffer pointed to by the Uint8Array 407 | - actual = Buffer.from(actual.buffer, actual.byteOffset, actual.byteLength); 408 | - } 409 | return new VSBuffer(actual); 410 | } 411 | constructor(buffer) { 412 | @@ -25,15 +12,10 @@ export class VSBuffer { 413 | this.byteLength = this.buffer.byteLength; 414 | } 415 | toString() { 416 | - if (hasBuffer) { 417 | - return this.buffer.toString(); 418 | - } 419 | - else { 420 | - if (!textDecoder) { 421 | - textDecoder = new TextDecoder(); 422 | - } 423 | - return textDecoder.decode(this.buffer); 424 | + if (!textDecoder) { 425 | + textDecoder = new TextDecoder(); 426 | } 427 | + return textDecoder.decode(this.buffer); 428 | } 429 | } 430 | export function readUInt16LE(source, offset) { 431 | diff --git a/node_modules/monaco-editor/esm/vs/basic-languages/monaco.contribution.js b/node_modules/monaco-editor/esm/vs/basic-languages/monaco.contribution.js 432 | index 4681708..e5f01c4 100644 433 | --- a/node_modules/monaco-editor/esm/vs/basic-languages/monaco.contribution.js 434 | +++ b/node_modules/monaco-editor/esm/vs/basic-languages/monaco.contribution.js 435 | @@ -8,84 +8,5 @@ import '../editor/editor.api.js'; 436 | 437 | 438 | // src/basic-languages/monaco.contribution.ts 439 | -import "./abap/abap.contribution.js"; 440 | -import "./apex/apex.contribution.js"; 441 | -import "./azcli/azcli.contribution.js"; 442 | -import "./bat/bat.contribution.js"; 443 | -import "./bicep/bicep.contribution.js"; 444 | -import "./cameligo/cameligo.contribution.js"; 445 | -import "./clojure/clojure.contribution.js"; 446 | -import "./coffee/coffee.contribution.js"; 447 | -import "./cpp/cpp.contribution.js"; 448 | -import "./csharp/csharp.contribution.js"; 449 | -import "./csp/csp.contribution.js"; 450 | -import "./css/css.contribution.js"; 451 | -import "./cypher/cypher.contribution.js"; 452 | -import "./dart/dart.contribution.js"; 453 | -import "./dockerfile/dockerfile.contribution.js"; 454 | -import "./ecl/ecl.contribution.js"; 455 | -import "./elixir/elixir.contribution.js"; 456 | -import "./flow9/flow9.contribution.js"; 457 | -import "./fsharp/fsharp.contribution.js"; 458 | -import "./freemarker2/freemarker2.contribution.js"; 459 | -import "./go/go.contribution.js"; 460 | -import "./graphql/graphql.contribution.js"; 461 | -import "./handlebars/handlebars.contribution.js"; 462 | -import "./hcl/hcl.contribution.js"; 463 | -import "./html/html.contribution.js"; 464 | -import "./ini/ini.contribution.js"; 465 | -import "./java/java.contribution.js"; 466 | import "./javascript/javascript.contribution.js"; 467 | -import "./julia/julia.contribution.js"; 468 | -import "./kotlin/kotlin.contribution.js"; 469 | -import "./less/less.contribution.js"; 470 | -import "./lexon/lexon.contribution.js"; 471 | -import "./lua/lua.contribution.js"; 472 | -import "./liquid/liquid.contribution.js"; 473 | -import "./m3/m3.contribution.js"; 474 | -import "./markdown/markdown.contribution.js"; 475 | -import "./mdx/mdx.contribution.js"; 476 | -import "./mips/mips.contribution.js"; 477 | -import "./msdax/msdax.contribution.js"; 478 | -import "./mysql/mysql.contribution.js"; 479 | -import "./objective-c/objective-c.contribution.js"; 480 | -import "./pascal/pascal.contribution.js"; 481 | -import "./pascaligo/pascaligo.contribution.js"; 482 | -import "./perl/perl.contribution.js"; 483 | -import "./pgsql/pgsql.contribution.js"; 484 | -import "./php/php.contribution.js"; 485 | -import "./pla/pla.contribution.js"; 486 | -import "./postiats/postiats.contribution.js"; 487 | -import "./powerquery/powerquery.contribution.js"; 488 | -import "./powershell/powershell.contribution.js"; 489 | -import "./protobuf/protobuf.contribution.js"; 490 | -import "./pug/pug.contribution.js"; 491 | -import "./python/python.contribution.js"; 492 | -import "./qsharp/qsharp.contribution.js"; 493 | -import "./r/r.contribution.js"; 494 | -import "./razor/razor.contribution.js"; 495 | -import "./redis/redis.contribution.js"; 496 | -import "./redshift/redshift.contribution.js"; 497 | -import "./restructuredtext/restructuredtext.contribution.js"; 498 | -import "./ruby/ruby.contribution.js"; 499 | -import "./rust/rust.contribution.js"; 500 | -import "./sb/sb.contribution.js"; 501 | -import "./scala/scala.contribution.js"; 502 | -import "./scheme/scheme.contribution.js"; 503 | -import "./scss/scss.contribution.js"; 504 | -import "./shell/shell.contribution.js"; 505 | -import "./solidity/solidity.contribution.js"; 506 | -import "./sophia/sophia.contribution.js"; 507 | -import "./sparql/sparql.contribution.js"; 508 | -import "./sql/sql.contribution.js"; 509 | -import "./st/st.contribution.js"; 510 | -import "./swift/swift.contribution.js"; 511 | -import "./systemverilog/systemverilog.contribution.js"; 512 | -import "./tcl/tcl.contribution.js"; 513 | -import "./twig/twig.contribution.js"; 514 | import "./typescript/typescript.contribution.js"; 515 | -import "./typespec/typespec.contribution.js"; 516 | -import "./vb/vb.contribution.js"; 517 | -import "./wgsl/wgsl.contribution.js"; 518 | -import "./xml/xml.contribution.js"; 519 | -import "./yaml/yaml.contribution.js"; 520 | diff --git a/node_modules/monaco-editor/esm/vs/editor/contrib/find/browser/findController.js b/node_modules/monaco-editor/esm/vs/editor/contrib/find/browser/findController.js 521 | index d384172..5d36374 100644 522 | --- a/node_modules/monaco-editor/esm/vs/editor/contrib/find/browser/findController.js 523 | +++ b/node_modules/monaco-editor/esm/vs/editor/contrib/find/browser/findController.js 524 | @@ -422,7 +422,7 @@ export const StartFindAction = registerMultiEditorAction(new MultiEditorAction({ 525 | id: FIND_IDS.StartFindAction, 526 | label: nls.localize('startFindAction', "Find"), 527 | alias: 'Find', 528 | - precondition: ContextKeyExpr.or(EditorContextKeys.focus, ContextKeyExpr.has('editorIsOpen')), 529 | + precondition: EditorContextKeys.focus, 530 | kbOpts: { 531 | kbExpr: null, 532 | primary: 2048 /* KeyMod.CtrlCmd */ | 36 /* KeyCode.KeyF */, 533 | @@ -789,7 +789,7 @@ export const StartFindReplaceAction = registerMultiEditorAction(new MultiEditorA 534 | id: FIND_IDS.StartFindReplaceAction, 535 | label: nls.localize('startReplace', "Replace"), 536 | alias: 'Replace', 537 | - precondition: ContextKeyExpr.or(EditorContextKeys.focus, ContextKeyExpr.has('editorIsOpen')), 538 | + precondition: EditorContextKeys.focus, 539 | kbOpts: { 540 | kbExpr: null, 541 | primary: 2048 /* KeyMod.CtrlCmd */ | 38 /* KeyCode.KeyH */, 542 | diff --git a/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeEditor.js b/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeEditor.js 543 | index 71c26d6..607ee60 100644 544 | --- a/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeEditor.js 545 | +++ b/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeEditor.js 546 | @@ -101,7 +101,7 @@ let StandaloneCodeEditor = class StandaloneCodeEditor extends CodeEditorWidget { 547 | // Read descriptor options 548 | const id = _descriptor.id; 549 | const label = _descriptor.label; 550 | - const precondition = ContextKeyExpr.and(ContextKeyExpr.equals('editorId', this.getId()), ContextKeyExpr.deserialize(_descriptor.precondition)); 551 | + const precondition = ContextKeyExpr.deserialize(_descriptor.precondition); 552 | const keybindings = _descriptor.keybindings; 553 | const keybindingsWhen = ContextKeyExpr.and(precondition, ContextKeyExpr.deserialize(_descriptor.keybindingContext)); 554 | const contextMenuGroupId = _descriptor.contextMenuGroupId || null; 555 | diff --git a/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js b/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js 556 | index 3f0a603..36c7d66 100644 557 | --- a/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js 558 | +++ b/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js 559 | @@ -273,39 +273,7 @@ let StandaloneKeybindingService = class StandaloneKeybindingService extends Abst 560 | })); 561 | this._domNodeListeners.push(new DomNodeListeners(domNode, disposables)); 562 | }; 563 | - const removeContainer = (domNode) => { 564 | - for (let i = 0; i < this._domNodeListeners.length; i++) { 565 | - const domNodeListeners = this._domNodeListeners[i]; 566 | - if (domNodeListeners.domNode === domNode) { 567 | - this._domNodeListeners.splice(i, 1); 568 | - domNodeListeners.dispose(); 569 | - } 570 | - } 571 | - }; 572 | - const addCodeEditor = (codeEditor) => { 573 | - if (codeEditor.getOption(61 /* EditorOption.inDiffEditor */)) { 574 | - return; 575 | - } 576 | - addContainer(codeEditor.getContainerDomNode()); 577 | - }; 578 | - const removeCodeEditor = (codeEditor) => { 579 | - if (codeEditor.getOption(61 /* EditorOption.inDiffEditor */)) { 580 | - return; 581 | - } 582 | - removeContainer(codeEditor.getContainerDomNode()); 583 | - }; 584 | - this._register(codeEditorService.onCodeEditorAdd(addCodeEditor)); 585 | - this._register(codeEditorService.onCodeEditorRemove(removeCodeEditor)); 586 | - codeEditorService.listCodeEditors().forEach(addCodeEditor); 587 | - const addDiffEditor = (diffEditor) => { 588 | - addContainer(diffEditor.getContainerDomNode()); 589 | - }; 590 | - const removeDiffEditor = (diffEditor) => { 591 | - removeContainer(diffEditor.getContainerDomNode()); 592 | - }; 593 | - this._register(codeEditorService.onDiffEditorAdd(addDiffEditor)); 594 | - this._register(codeEditorService.onDiffEditorRemove(removeDiffEditor)); 595 | - codeEditorService.listDiffEditors().forEach(addDiffEditor); 596 | + addContainer(mainWindow); 597 | } 598 | addDynamicKeybinding(command, keybinding, handler, when) { 599 | return combinedDisposable(CommandsRegistry.registerCommand(command, handler), this.addDynamicKeybindings([{ 600 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nk2028/tshet-uinh-autoderiver/03b92a344556f19d611d47c5d628d908c5bf70fc/public/favicon.ico -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/tshet-uinh-autoderiver/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/tshet-uinh-autoderiver/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"} 2 | -------------------------------------------------------------------------------- /src/Classes/CustomElement.tsx: -------------------------------------------------------------------------------- 1 | import { createElement, Fragment } from "react"; 2 | 3 | import styled from "@emotion/styled"; 4 | 5 | import { isArray, isTemplateStringsArray } from "../utils"; 6 | 7 | import type { ReactNode } from "../consts"; 8 | import type { Property } from "csstype"; 9 | import type { HTMLAttributes, ReactElement } from "react"; 10 | 11 | const Missing = styled.span` 12 | &:after { 13 | content: "\xa0"; 14 | } 15 | `; 16 | 17 | type TagToProp = { 18 | f: undefined; 19 | b: undefined; 20 | i: undefined; 21 | u: undefined; 22 | s: undefined; 23 | sub: undefined; 24 | sup: undefined; 25 | fg: Property.Color; 26 | bg: Property.BackgroundColor; 27 | size: Property.FontSize; 28 | }; 29 | 30 | type AllTags = keyof TagToProp; 31 | type Tag = { 32 | [Tag in AllTags]: TagToProp[Tag] extends undefined ? readonly [Tag] : readonly [Tag, TagToProp[Tag]]; 33 | }[AllTags]; 34 | type Args = [strings: TemplateStringsArray | NestedCustomNode, ...nodes: NestedCustomNode[]]; 35 | 36 | const TagStyle = { fg: "color", bg: "backgroundColor", size: "fontSize" } as const; 37 | 38 | export type CustomNode = CustomElement | string; 39 | export type NestedCustomNode = CustomNode | readonly NestedCustomNode[]; 40 | 41 | function isTagWithProp(arg: AllTags): arg is keyof typeof TagStyle { 42 | return arg in TagStyle; 43 | } 44 | 45 | export default class CustomElement { 46 | private tag: Tag; 47 | private children: readonly CustomNode[]; 48 | 49 | constructor([tag, prop]: Tag, ...[strings, ...nodes]: Args) { 50 | this.tag = isTagWithProp(tag) 51 | ? [tag, tag === "size" && typeof prop === "number" ? (prop as never) : String(prop)] 52 | : [tag]; 53 | const children: NestedCustomNode[] = []; 54 | if (isTemplateStringsArray(strings)) 55 | strings.forEach((str, index) => { 56 | children.push(str); 57 | if (index < nodes.length) children.push(nodes[index]); 58 | }); 59 | else children.push(strings, ...nodes); 60 | this.children = CustomElement.flattenCustomNodes(children); 61 | } 62 | 63 | private static flattenCustomNodes(nodes: readonly NestedCustomNode[]): CustomNode[] { 64 | return nodes.flatMap(child => 65 | isArray(child) 66 | ? CustomElement.flattenCustomNodes(child) 67 | : ( 68 | child instanceof CustomElement 69 | ? child.children.length 70 | : String(child) !== "" && typeof (child ?? false) !== "boolean" 71 | ) 72 | ? [child] 73 | : [], 74 | ); 75 | } 76 | 77 | toJSON() { 78 | return [...this.tag, ...this.children]; 79 | } 80 | 81 | private static normalize(node: CustomNode): CustomNode { 82 | return node instanceof CustomElement 83 | ? node.children.length 84 | ? node 85 | : "" 86 | : typeof (node ?? false) === "boolean" 87 | ? "" 88 | : String(node); 89 | } 90 | 91 | static stringify(node: CustomNode | readonly CustomNode[]) { 92 | node = isArray(node) ? node.map(CustomElement.normalize) : CustomElement.normalize(node); 93 | return JSON.stringify(node); 94 | } 95 | 96 | static isEqual(left: CustomNode, right: CustomNode): boolean; 97 | static isEqual(left: readonly CustomNode[], right: readonly CustomNode[]): boolean; 98 | static isEqual(left: CustomNode | readonly CustomNode[], right: CustomNode | readonly CustomNode[]) { 99 | return CustomElement.stringify(left) === CustomElement.stringify(right); 100 | } 101 | 102 | render(): ReactElement { 103 | const [tag, prop] = this.tag; 104 | return createElement( 105 | isTagWithProp(tag) ? "span" : tag === "f" ? Fragment : tag, 106 | isTagWithProp(tag) ? ({ style: { [TagStyle[tag]]: prop } } as HTMLAttributes) : undefined, 107 | ...CustomElement.renderInner(this.children), 108 | ); 109 | } 110 | 111 | private static renderInner(children: readonly CustomNode[]) { 112 | return children.map(child => 113 | child instanceof CustomElement 114 | ? child.children.length 115 | ? child.render() 116 | : "" 117 | : typeof (child ?? false) === "boolean" 118 | ? "" 119 | : String(child), 120 | ); 121 | } 122 | 123 | static render(children: readonly CustomNode[], fallback: ReactNode = ) { 124 | return CustomElement.renderInner(children).map(child => (child === "" ? fallback : child)); 125 | } 126 | } 127 | 128 | const TAGS = Symbol("TAGS"); 129 | 130 | type AllFormatters = { 131 | [Tag in AllTags]: TagToProp[Tag] extends undefined ? Formatter : (prop: TagToProp[Tag]) => Formatter; 132 | }; 133 | export interface Formatter extends AllFormatters { 134 | (...args: Args): CustomElement; 135 | } 136 | interface FormatterWithTags extends Formatter { 137 | [TAGS]: Tag[]; 138 | } 139 | 140 | function FormatterFactory(tags: Tag[]) { 141 | const instance = ((...args) => { 142 | let i = instance[TAGS].length - 1; 143 | let element = new CustomElement(instance[TAGS][i--] || ["f"], ...args); 144 | while (~i) element = new CustomElement(instance[TAGS][i--], element); 145 | return element; 146 | }) as FormatterWithTags; 147 | instance[TAGS] = tags; 148 | Object.setPrototypeOf(instance, FormatterFactory.prototype); 149 | return instance as Formatter; 150 | } 151 | 152 | Object.setPrototypeOf(FormatterFactory.prototype, Function.prototype); 153 | 154 | for (const tag of ["f", "b", "i", "u", "s", "sup", "sub"] as const) 155 | Object.defineProperty(FormatterFactory.prototype, tag, { 156 | get() { 157 | return FormatterFactory([...this[TAGS], [tag]]); 158 | }, 159 | }); 160 | 161 | for (const tag of ["fg", "bg", "size"] as const) 162 | Object.defineProperty(FormatterFactory.prototype, tag, { 163 | get() { 164 | return (prop: TagToProp[T]) => FormatterFactory([...this[TAGS], [tag, prop]]); 165 | }, 166 | }); 167 | 168 | export const Formatter = FormatterFactory([]); 169 | -------------------------------------------------------------------------------- /src/Classes/ParameterSet.tsx: -------------------------------------------------------------------------------- 1 | import { 推導方案, 推導設定 } from "tshet-uinh-deriver-tools"; 2 | 3 | import styled from "@emotion/styled"; 4 | 5 | import TooltipLabel from "../Components/TooltipLabel"; 6 | import { rawDeriverFrom } from "../evaluate"; 7 | 8 | import type { GroupLabel, Newline, 設定項 } from "tshet-uinh-deriver-tools"; 9 | 10 | function isNewline(item: 設定項): item is Newline { 11 | return item.type === "newline"; 12 | } 13 | 14 | function isGroupLabel(item: 設定項): item is GroupLabel { 15 | return item.type === "groupLabel"; 16 | } 17 | 18 | const Title = styled.b` 19 | &:before { 20 | content: "〔"; 21 | color: #888; 22 | margin: 0 0.25rem 0 -0.5rem; 23 | } 24 | &:after { 25 | content: "〕"; 26 | color: #888; 27 | margin: 0 0.25rem; 28 | } 29 | `; 30 | 31 | const Description = styled.div` 32 | margin: -0.5rem 0 -0.2rem; 33 | font-size: 0.875rem; 34 | color: #555; 35 | p { 36 | margin: 0.3rem 0; 37 | line-height: 1.6; 38 | } 39 | `; 40 | 41 | const Colon = styled.span` 42 | &:after { 43 | content: ":"; 44 | color: #888; 45 | margin: 0 0.5rem 0 0.375rem; 46 | vertical-align: 0.125rem; 47 | } 48 | `; 49 | 50 | export default class ParameterSet { 51 | private _設定: 推導設定; 52 | 53 | constructor(parameters: 推導設定 | readonly 設定項[] = []) { 54 | this._設定 = parameters instanceof 推導設定 ? parameters : new 推導設定(parameters); 55 | } 56 | 57 | set(key: string, value: unknown): ParameterSet { 58 | return new ParameterSet(this._設定.with({ [key]: value })); 59 | } 60 | 61 | pack(): Readonly> { 62 | return this._設定.選項; 63 | } 64 | 65 | combine(old: ParameterSet | Readonly>): ParameterSet { 66 | if (old instanceof ParameterSet) { 67 | old = old.pack(); 68 | } 69 | const resetKeys = new Set(this._設定.列表.flatMap(item => ("key" in item && item["reset"] ? [item.key] : []))); 70 | const actual = Object.fromEntries(Object.entries(old).filter(([k]) => !resetKeys.has(k))); 71 | return new ParameterSet(this._設定.with(actual)); 72 | } 73 | 74 | get size() { 75 | return Object.keys(this._設定.選項).length; 76 | } 77 | 78 | get errors(): readonly string[] { 79 | return this._設定.解析錯誤; 80 | } 81 | 82 | refresh(input: string) { 83 | if (!this.size) return ParameterSet.from(input); 84 | let rawDeriver; 85 | try { 86 | rawDeriver = rawDeriverFrom(input); 87 | } catch { 88 | return this; 89 | } 90 | return new ParameterSet(new 推導方案(rawDeriver).方案設定(this.pack())).combine(this); 91 | } 92 | 93 | static from(input: string, 選項?: Readonly>) { 94 | let rawDeriver; 95 | try { 96 | rawDeriver = rawDeriverFrom(input); 97 | } catch { 98 | return new ParameterSet(); 99 | } 100 | return new ParameterSet(new 推導方案(rawDeriver).方案設定(選項)); 101 | } 102 | 103 | render(onChange: (change: ParameterSet) => void) { 104 | return this._設定.列表.map(item => { 105 | if (isNewline(item)) { 106 | return ( 107 | <> 108 |
109 | {"\n"} 110 | 111 | ); 112 | } else if (isGroupLabel(item)) { 113 | const { text, description } = item; 114 | return ( 115 | <> 116 |
117 | {"\n"} 118 | {text} 119 | {typeof description === "string" && description ? ( 120 | 121 | {description.split(/[\n-\r\x85\u2028\u2029]+/u).map((line, i) => ( 122 |

{line}

123 | ))} 124 |
125 | ) : null} 126 | 127 | ); 128 | } else { 129 | if (item["hidden"]) { 130 | return null; 131 | } 132 | const { key, text, description, value, options } = item; 133 | const label = text ?? key; 134 | if (options) { 135 | return ( 136 | 137 | {label} 138 | 139 | 152 | 153 | ); 154 | } else { 155 | switch (typeof value) { 156 | case "boolean": 157 | return ( 158 | 159 | onChange(this.set(key, event.target.checked))} 163 | /> 164 | {label} 165 | 166 | ); 167 | case "number": 168 | return ( 169 | 170 | {label} 171 | 172 | onChange(this.set(key, +event.target.value))} 177 | autoComplete="off" 178 | /> 179 | 180 | ); 181 | case "string": 182 | return ( 183 | 184 | {label} 185 | 186 | onChange(this.set(key, event.target.value))} 190 | autoComplete="off" 191 | autoCorrect="off" 192 | autoCapitalize="off" 193 | spellCheck="false" 194 | /> 195 | 196 | ); 197 | default: 198 | return null; 199 | } 200 | } 201 | } 202 | }); 203 | } 204 | 205 | // NOTE For saving the state. Only the packed object is needed. 206 | toJSON() { 207 | return this.pack(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Classes/SwalReact.ts: -------------------------------------------------------------------------------- 1 | import Swal from "sweetalert2"; 2 | import withReactContent from "sweetalert2-react-content"; 3 | 4 | export default withReactContent(Swal).mixin({ 5 | showClass: { popup: "" }, 6 | hideClass: { popup: "" }, 7 | }); 8 | -------------------------------------------------------------------------------- /src/Components/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | import "purecss/build/pure.css"; 4 | // NOTE sweetalert2's ESM export does not setup styles properly, manually importing 5 | import "sweetalert2/dist/sweetalert2.css"; 6 | 7 | import { injectGlobal, css as stylesheet } from "@emotion/css"; 8 | import styled from "@emotion/styled"; 9 | import { faCirclePlay, faExternalLink, faInfo, faQuestion } from "@fortawesome/free-solid-svg-icons"; 10 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 11 | 12 | import Main from "./Main"; 13 | import Swal from "../Classes/SwalReact"; 14 | import { codeFontFamily, noop } from "../consts"; 15 | 16 | injectGlobal` 17 | html, 18 | body { 19 | line-height: 1.6; 20 | font-size: 16px; 21 | font-family: "Source Han Serif C", "Source Han Serif K", "Noto Serif CJK KR", "Source Han Serif SC", 22 | "Noto Serif CJK SC", "Source Han Serif", "Noto Serif CJK JP", "Source Han Serif TC", "Noto Serif CJK TC", 23 | "Noto Serif KR", "Noto Serif SC", "Noto Serif TC", "Jomolhari", "HanaMin", "CharisSILW", serif; 24 | font-language-override: "KOR"; 25 | overflow: hidden; 26 | touch-action: none; 27 | } 28 | body.dragging { 29 | user-select: none; 30 | } 31 | :lang(och-Latn-fonipa) { 32 | font-family: "CharisSILW", serif; 33 | } 34 | br:first-child { 35 | display: none; 36 | } 37 | dialog { 38 | position: fixed; 39 | inset: 0; 40 | margin: 0; 41 | padding: 0; 42 | background: none; 43 | border: none; 44 | width: 100%; 45 | height: 100%; 46 | max-width: none; 47 | max-height: none; 48 | opacity: 0; 49 | transition: 50 | opacity 200ms ease-out, 51 | transform 200ms ease-out, 52 | overlay 200ms ease-out allow-discrete, 53 | display 200ms ease-out allow-discrete; 54 | @starting-style { 55 | opacity: 0; 56 | } 57 | &[open] { 58 | display: grid; 59 | grid-template: 1fr / 1fr; 60 | opacity: 1; 61 | } 62 | &::backdrop { 63 | opacity: 0; 64 | transition: 65 | opacity 200ms ease-out, 66 | overlay 200ms ease-out allow-discrete, 67 | display 200ms ease-out allow-discrete; 68 | @starting-style { 69 | opacity: 0; 70 | } 71 | } 72 | &[open]::backdrop { 73 | opacity: 1; 74 | } 75 | } 76 | button { 77 | box-sizing: inherit; 78 | appearance: none; 79 | outline: none; 80 | margin: 0; 81 | padding: 0; 82 | border: none; 83 | background: none; 84 | color: inherit; 85 | font: inherit; 86 | line-height: inherit; 87 | } 88 | button::-moz-focus-inner { 89 | border: none; 90 | padding: 0; 91 | } 92 | hr { 93 | border: none; 94 | border-top: 1px solid #d4d6d8; 95 | } 96 | body .pure-button { 97 | padding-top: 0.2em; 98 | padding-bottom: 0.2em; 99 | margin-right: 0.3em; 100 | vertical-align: baseline; 101 | &.pure-button-danger { 102 | background-color: #dc3741; 103 | color: white; 104 | } 105 | } 106 | .pure-form p { 107 | line-height: 2.5; 108 | } 109 | body .pure-form { 110 | select { 111 | padding: 0 0.8rem 0 0.4rem; 112 | height: 2rem; 113 | vertical-align: baseline; 114 | margin-right: 0.125rem; 115 | } 116 | input[type="radio"], 117 | input[type="checkbox"] { 118 | margin-right: 0.3rem; 119 | vertical-align: -0.0625rem; 120 | } 121 | input[type="text"], 122 | input[type="number"] { 123 | width: 6.25rem; 124 | height: 2rem; 125 | vertical-align: baseline; 126 | margin-right: 0.125rem; 127 | padding: 0 0.6rem; 128 | } 129 | input[type="button"] { 130 | margin-right: 1.125rem; 131 | } 132 | label { 133 | display: inline; 134 | margin-right: 1.125rem; 135 | white-space: nowrap; 136 | } 137 | } 138 | .swal2-close { 139 | font-family: unset; 140 | } 141 | @media (max-width: 640px) { 142 | .swal2-container { 143 | padding: 2rem 0 0; 144 | .swal2-popup { 145 | width: 100%; 146 | border-radius: 0.5rem 0.5rem 0 0; 147 | } 148 | } 149 | } 150 | `; 151 | 152 | const aboutModal = stylesheet` 153 | &.swal2-container.swal2-backdrop-show { 154 | background-color: rgba(0, 0, 0, 0.7); 155 | backdrop-filter: blur(18px); 156 | } 157 | .swal2-popup { 158 | width: min(36vw + 360px, 960px, 100%); 159 | padding: 0; 160 | } 161 | .swal2-html-container { 162 | text-align: left; 163 | margin: 0; 164 | padding: 1.5rem 3rem 2rem; 165 | line-height: 1.6; 166 | h2, 167 | b { 168 | color: black; 169 | } 170 | p, li { 171 | color: #5c5c5c; 172 | font-size: 1rem; 173 | } 174 | a:link { 175 | position: relative; 176 | color: #315177; 177 | text-decoration: none; 178 | transition: color 150ms; 179 | &:after { 180 | content: ""; 181 | position: absolute; 182 | left: 0; 183 | right: 0; 184 | bottom: 1px; 185 | border-bottom: 1px dashed currentColor; 186 | } 187 | &:hover, 188 | &:focus { 189 | color: black; 190 | } 191 | } 192 | a:visited { 193 | color: #5c4c86; 194 | &:hover, 195 | &:focus { 196 | color: black; 197 | } 198 | } 199 | kbd, code { 200 | font-family: ${codeFontFamily}; 201 | } 202 | kbd:not(kbd kbd) { 203 | display: inline-flex; 204 | gap: 2.5px; 205 | font-size: 0.8125em; 206 | padding: 0 0.375em; 207 | background-color: #f4f6f8; 208 | border: 1px solid #d8e2e4; 209 | border-bottom-width: 2px; 210 | border-radius: 0.5em; 211 | color: #333; 212 | vertical-align: middle; 213 | } 214 | code:not(code code) { 215 | font-size: 0.875em; 216 | padding: 0.1875em 0.25em; 217 | background-color: #f4f4f4; 218 | border-radius: 0.375em; 219 | color: #f0506e; 220 | vertical-align: middle; 221 | } 222 | } 223 | .swal2-close { 224 | font-size: 3.5rem; 225 | padding: 0.5rem 0.5rem 0 0; 226 | &:focus { 227 | box-shadow: none; 228 | } 229 | } 230 | @media (max-width: 640px) { 231 | grid-template: unset; 232 | .swal2-popup { 233 | overflow-y: auto; 234 | width: 100%; 235 | height: 100%; 236 | } 237 | .swal2-html-container { 238 | padding: 0.25rem 2rem 0.75rem; 239 | } 240 | .swal2-close { 241 | font-size: 3rem; 242 | } 243 | } 244 | `; 245 | 246 | function showInfoBox(content: JSX.Element) { 247 | return Swal.fire({ 248 | showClass: { popup: "" }, 249 | hideClass: { popup: "" }, 250 | customClass: { container: aboutModal }, 251 | showCloseButton: true, 252 | showConfirmButton: false, 253 | html: content, 254 | }); 255 | } 256 | 257 | function showAbout() { 258 | return showInfoBox( 259 | <> 260 |

關於

261 |

262 | 切韻音系自動推導器(下稱「本頁面」)由{" "} 263 | 264 | nk2028 265 | {" "} 266 | 開發。我們開發有關語言學的項目,尤其是有關歷史漢語語音學,異體字和日語語言學的項目。 267 |

268 |

269 | 歡迎加入 QQ 音韻學答疑羣(羣號 526333751)和{" "} 270 | 271 | Telegram nk2028 社羣(@nk2028_discuss) 272 | 273 | 。 274 |

275 |

276 | 277 | 本頁面原始碼 278 | 279 | 公開於 GitHub。 280 |

281 |

282 | 推導器預置的 283 | 284 | 樣例推導方案程式碼 285 | 286 | 及 287 | 288 | 過時推導方案 289 | 290 | 亦可於 GitHub 瀏覽。 291 |

292 |

私隱權政策

293 |

294 | 本頁面是一項開放原始碼的網絡服務。作為本頁面的開發者,我們對您的私隱非常重視。本頁面的開發者不會透過本頁面收集您的任何資料。 295 |

296 |

下面將具體介紹本頁面能在何種程度上保障您的私隱權。

297 | 您鍵入的內容 298 |

299 | 本頁面的開發者不會收集您在本頁面中鍵入的任何內容。任何與您鍵入的內容相關的運算全部在您的系統中完成。本頁面不會將包括待標註的文本、標註結果在內的任何資料傳送至任何伺服器。 300 |

301 | 您的其他資料 302 |

303 | 本頁面使用的內容託管於以下站點:GitHub Pages、jsDelivr、Google 304 | Fonts。在您訪問本頁面時,您的瀏覽器將與這些站點交互。本頁面的開發者並不能讀取您訪問這些站點時產生的資料,亦無法控制這些站點如何使用您訪問時產生的資料。 305 |

306 | , 307 | ); 308 | } 309 | 310 | function showHelp() { 311 | return showInfoBox( 312 | <> 313 |

使用說明

314 |

快速鍵

315 |

快速鍵僅在編輯器處於焦點狀態時有效。

316 |
    317 |
  • 318 | 319 | Alt+N 320 | {" "} 321 | 或{" "} 322 | 323 | Option+N 324 | {" "} 325 | ( 326 | 327 | 328 | N 329 | 330 | ):新增方案 331 |
  • 332 |
  • 333 | 334 | Alt+S 335 | {" "} 336 | 或{" "} 337 | 338 | Option+S 339 | {" "} 340 | ( 341 | 342 | 343 | S 344 | 345 | ):刪除方案 346 |
  • 347 |
  • 348 | 349 | Ctrl+O 350 | {" "} 351 | 或{" "} 352 | 353 | Cmd+O 354 | {" "} 355 | ( 356 | 357 | 358 | O 359 | 360 | ):從本機開啟方案 361 |
  • 362 |
  • 363 | 364 | Ctrl+S 365 | {" "} 366 | 或{" "} 367 | 368 | Cmd+S 369 | {" "} 370 | ( 371 | 372 | 373 | S 374 | 375 | ):儲存方案至本機 376 |
  • 377 | 392 |
  • 393 | 394 | Alt+R 395 | {" "} 396 | 或{" "} 397 | 398 | Option+R 399 | {" "} 400 | ( 401 | 402 | 403 | R 404 | 405 | ) 或{" "} 406 | 407 | Shift+Enter 408 | {" "} 409 | ( 410 | 411 | 412 | 413 | 414 | ):執行推導並顯示推導結果 415 |
  • 416 |
  • 417 | Esc ():關閉「新增方案」或「推導結果」面板 418 |
  • 419 |
420 |

此外,推導方案檔案亦可透過拖曳載入。

421 |

指定個別字音

422 |

423 | 推導自訂文章時,若某字有多個音,且推導結果不同,則在推導結果介面上,該字會被著色。指標移至其上(或觸控螢幕上點按)會出現選單,可以點選想要的字音。 424 |

425 |

426 | 若「同步音韻地位選擇至輸入框」已勾選,則選擇的字音會被記住於文章中(詳見下段所述格式),下次推導會預設選擇同一字音。 427 |

428 |

429 | 此外,若希望自訂某字需按某音推導,可在其後緊接一對半形圓括號「() 430 | 」,當中寫下想要的音韻地位描述(格式可參見推導結果中的音韻地位顯示,或參見{" "} 431 | 432 | TshetUinh.js 文檔 433 | 434 | ) 435 |

436 |

編寫推導方案

437 |

438 | 推導方案代碼會作為函數執行,用 return 回傳結果。函數有兩個執行模式:「推導」與「方案設定」。 439 |

440 |

在「推導」模式下,會對推導的每個字/音韻地位執行一次函數,需回傳推導結果,可用的引數有:

441 |
    442 |
  • 443 | 音韻地位: TshetUinh.音韻地位:待推導之音韻地位,詳見{" "} 444 | 445 | TshetUinh.js 文檔 446 | 447 |
  • 448 |
  • 449 | 字頭: string | null:當前被推導的字 450 |
  • 451 |
  • 452 | 選項: Record<String, unknown>:物件,包含用戶指定的各項方案參數(詳見下述「方案設定模式」) 453 |
  • 454 |
455 |

在「方案設定」模式下,會在建立方案時、改變代碼後或用戶調整/重置參數選項後執行,需回傳方案支援的可調整參數。

456 |

457 | 該模式下僅 選項 引數會被傳入,音韻地位字頭 均為{" "} 458 | undefined,可以透過 if (!音韻地位) 判斷處於哪一模式。選項{" "} 459 | 引數格式與「推導」模式基本一致:剛建立方案時或重置選項後為空物件({"{}"} 460 | ),其餘情形則為包含當前選項各參數的物件。 461 |

462 |

463 | 「方案設定」模式需回傳方案各設定項的列表(Array),各項格式請參見{" "} 464 | 468 | tshet-uinh-deriver-tools 文檔 469 | 470 | 。如果方案不需可變參數,可回傳空列表([])。 471 |

472 | , 473 | ); 474 | } 475 | 476 | const Container = styled.div` 477 | position: absolute; 478 | inset: 0; 479 | overflow: hidden; 480 | background-color: #fbfbfb; 481 | `; 482 | const Content = styled.div` 483 | position: absolute; 484 | inset: 0; 485 | display: flex; 486 | flex-direction: column; 487 | `; 488 | const Heading = styled.h1` 489 | font-size: 1.75rem; 490 | margin: 0; 491 | line-height: 1; 492 | padding: 0 0.125rem 0.5rem 0.625rem; 493 | border-bottom: 0.2rem solid #d0d2d4; 494 | > * { 495 | margin: 0.625rem 0.75rem 0 0; 496 | } 497 | `; 498 | const Title = styled.span` 499 | display: inline-block; 500 | `; 501 | const Version = styled.span` 502 | display: inline-block; 503 | color: #888; 504 | font-size: 1rem; 505 | `; 506 | const Buttons = styled.span` 507 | display: inline-block; 508 | `; 509 | const ShowButton = styled.button` 510 | display: inline-flex; 511 | align-items: center; 512 | border-radius: 9999px; 513 | width: 1.5rem; 514 | height: 1.5rem; 515 | font-size: 1.25rem; 516 | color: #666; 517 | border: 0.125rem solid #666; 518 | margin-left: 0.5rem; 519 | cursor: pointer; 520 | transition: 521 | color 150ms, 522 | border-color 150ms; 523 | &:hover, 524 | &:focus { 525 | color: #0078e7; 526 | border-color: #0078e7; 527 | } 528 | `; 529 | const ApplyButton = styled.button` 530 | color: #0078e7; 531 | cursor: pointer; 532 | transition: color 150ms; 533 | &:hover, 534 | &:focus { 535 | color: #339cff; 536 | } 537 | `; 538 | const LinkToLegacy = styled.span` 539 | font-size: 0.875rem; 540 | float: right; 541 | line-height: 1.75rem; 542 | a { 543 | display: flex; 544 | align-items: center; 545 | gap: 0.375rem; 546 | text-decoration: none; 547 | color: hsl(210, 16%, 40%); 548 | transition: color 150ms; 549 | &:hover, 550 | &:focus { 551 | color: hsl(210, 8%, 50%); 552 | } 553 | svg { 554 | font-size: 0.75rem; 555 | } 556 | } 557 | `; 558 | const FontPreload = styled.span` 559 | position: absolute; 560 | top: -9999px; 561 | left: -9999px; 562 | width: 0; 563 | height: 0; 564 | overflow: hidden; 565 | `; 566 | 567 | export default function App() { 568 | const evaluateHandlerRef = useRef(noop); 569 | return ( 570 | 571 | 572 |
573 | 596 |
597 |
598 | 599 | 結果 600 | 601 | ); 602 | } 603 | -------------------------------------------------------------------------------- /src/Components/CreateSchemaDialog.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | import { css } from "@emotion/react"; 5 | import styled from "@emotion/styled"; 6 | import { faFile, faFileCode } from "@fortawesome/free-regular-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | 9 | import ExplorerFolder from "./ExplorerFolder"; 10 | import Spinner from "./Spinner"; 11 | import { invalidCharsRegex, newFileTemplate, tshetUinhExamplesURLPrefix } from "../consts"; 12 | import samples from "../samples"; 13 | import { fetchFile, normalizeFileName, stopPropagation } from "../utils"; 14 | 15 | import type { Folder, Sample, SchemaState } from "../consts"; 16 | import type { ChangeEventHandler, FormEvent, RefObject } from "react"; 17 | 18 | const Container = styled.dialog` 19 | transform: scale(0.9); 20 | @starting-style { 21 | transform: scale(0.9); 22 | } 23 | &[open] { 24 | transform: scale(1); 25 | } 26 | &::backdrop { 27 | background-color: rgba(0, 0, 0, 0.4); 28 | } 29 | `; 30 | const Popup = styled.div` 31 | background-color: #f9fafb; 32 | display: grid; 33 | grid-template: auto 1fr auto auto / 1fr auto; 34 | // width: min(36vw + 360px, 960px, 100%); // with preview 35 | width: min(22vw + 360px, 960px, 100%); // without preview 36 | // gap: 1rem; // with preview 37 | row-gap: 1rem; // without preview 38 | height: calc(100% - 3rem); 39 | box-sizing: border-box; 40 | margin: auto; 41 | padding: 1rem 1.625rem; 42 | border-radius: 0.5rem; 43 | overflow: hidden; 44 | // @media (max-width: 720px) { // with preview 45 | @media (max-width: 640px) { 46 | width: 100%; 47 | margin-bottom: 0; 48 | border-radius: 1rem 1rem 0 0; 49 | } 50 | `; 51 | const Title = styled.h2` 52 | grid-area: 1 / 1 / 2 / 3; 53 | text-align: left; 54 | color: #111; 55 | margin: 0; 56 | `; 57 | const Explorer = styled.div` 58 | grid-area: 2 / 1 / 3 / 2; 59 | overflow-y: scroll; 60 | user-select: none; 61 | line-height: 1.625; 62 | background-color: white; 63 | color: #111; 64 | border: 1px solid #aaa; 65 | padding: 0.5rem; 66 | outline: none; 67 | ul { 68 | padding: 0; 69 | margin: 0; 70 | margin-left: 2rem; 71 | list-style-type: none; 72 | } 73 | > ul { 74 | margin-left: 0; 75 | } 76 | `; 77 | const SchemaItem = styled.button` 78 | display: flex; 79 | align-items: center; 80 | width: 100%; 81 | text-align: left; 82 | * { 83 | transition: 84 | color 100ms, 85 | background-color 100ms; 86 | } 87 | &:hover *, 88 | &:focus * { 89 | color: #0078e7; 90 | } 91 | `; 92 | const SchemaName = styled.div<{ selected: boolean }>` 93 | flex: 1; 94 | color: #333; 95 | margin-left: 0.125rem; 96 | padding: 0 0.125rem; 97 | ${({ selected }) => 98 | selected && 99 | css` 100 | background-color: #0078e7; 101 | color: white !important; 102 | `} 103 | `; 104 | const Preview = styled.div` 105 | grid-area: 2 / 2 / 3 / 3; 106 | color: #333; 107 | `; 108 | const PreviewFrame = styled.div` 109 | border: 1px solid #aaa; 110 | font-size: 3rem; 111 | height: 8rem; 112 | display: flex; 113 | align-items: center; 114 | justify-content: center; 115 | line-height: 1; 116 | `; 117 | const Description = styled.div` 118 | color: #555; 119 | margin: 1rem 0.25rem; 120 | align-items: center; 121 | `; 122 | const Metadata = styled.div` 123 | th { 124 | text-align: right; 125 | padding: 0 1rem 0 2rem; 126 | } 127 | `; 128 | const Action = styled.form` 129 | grid-area: 3 / 1 / 4 / 3; 130 | display: flex; 131 | align-items: center; 132 | // Ensure specificity 133 | button[type] { 134 | margin: 0 0 0 0.5rem; 135 | padding: 0 1.1rem; 136 | height: 100%; 137 | transition: 138 | opacity 200ms, 139 | background-image 200ms; 140 | } 141 | &:invalid button[value="confirm"] { 142 | cursor: not-allowed; 143 | opacity: 0.4; 144 | pointer-events: none; 145 | } 146 | `; 147 | const Rename = styled.div` 148 | display: contents; 149 | .pure-form & label { 150 | display: contents; 151 | svg { 152 | color: #222; 153 | margin: 0 0.375rem 0 0.125rem; 154 | } 155 | div { 156 | margin-right: 0.5em; 157 | } 158 | input[type="text"] { 159 | display: block; 160 | width: 100%; 161 | height: 2.25rem; 162 | margin: 0; 163 | flex: 1; 164 | &:invalid { 165 | color: red; 166 | border-color: red; 167 | } 168 | } 169 | } 170 | `; 171 | const Validation = styled.div` 172 | grid-area: 4 / 1 / 5 / 3; 173 | font-size: 0.75rem; 174 | font-weight: bold; 175 | color: red; 176 | margin: -0.375rem 0rem -0.25rem; 177 | line-height: 1; 178 | `; 179 | const Loading = styled.div` 180 | grid-area: 1 / 1 / -1 / -1; 181 | display: flex; 182 | align-items: center; 183 | justify-content: center; 184 | background-color: rgba(249, 250, 251, 0.6); 185 | margin: -2rem; 186 | `; 187 | 188 | interface CreateSchemaDialogProps { 189 | getDefaultFileName(sample: string): string; 190 | schemaLoaded(schema: Omit): void; 191 | hasSchemaName(name: string): boolean; 192 | } 193 | 194 | const CreateSchemaDialog = forwardRef(function CreateSchemaDialog( 195 | { getDefaultFileName, schemaLoaded, hasSchemaName }, 196 | ref, 197 | ) { 198 | const [createSchemaName, setCreateSchemaName] = useState(getDefaultFileName("")); 199 | const [createSchemaSample, setCreateSchemaSample] = useState(""); 200 | const [loading, setLoading] = useState(false); 201 | 202 | const resetDialog = useCallback(() => { 203 | setCreateSchemaName(getDefaultFileName("")); 204 | setCreateSchemaSample(""); 205 | setLoading(false); 206 | }, [getDefaultFileName]); 207 | useEffect(resetDialog, [resetDialog]); 208 | 209 | const validation = useMemo(() => { 210 | const name = normalizeFileName(createSchemaName); 211 | if (!name) return "方案名稱為空"; 212 | if (invalidCharsRegex.test(name)) return "方案名稱含有特殊字元"; 213 | if (hasSchemaName(name)) return "方案名稱與現有方案重複"; 214 | return ""; 215 | }, [createSchemaName, hasSchemaName]); 216 | 217 | const inputRef = useRef(null); 218 | useEffect(() => { 219 | inputRef.current?.setCustomValidity(validation); 220 | }, [validation]); 221 | 222 | function recursiveFolder(folder: Folder) { 223 | return Object.entries(folder).map(([name, sample]) => { 224 | return typeof sample === "string" ? ( 225 |
  • 226 | { 228 | const trimmedName = name.replace(/^直接標註|^推導|《|》|(.*)|擬音$|轉寫$/g, "").trim(); 229 | setCreateSchemaName(getDefaultFileName(trimmedName)); 230 | setCreateSchemaSample(sample); 231 | }}> 232 | 233 | {name} 234 | 235 |
  • 236 | ) : ( 237 | 238 | {recursiveFolder(sample)} 239 | 240 | ); 241 | }); 242 | } 243 | 244 | const closeDialog = useCallback(() => { 245 | (ref as RefObject).current?.close(); 246 | }, [ref]); 247 | 248 | const addSchema = useCallback( 249 | async (event: FormEvent) => { 250 | event.preventDefault(); 251 | setLoading(true); 252 | try { 253 | schemaLoaded({ 254 | name: normalizeFileName(createSchemaName), 255 | input: createSchemaSample 256 | ? await fetchFile(tshetUinhExamplesURLPrefix + createSchemaSample + ".js") 257 | : newFileTemplate, 258 | }); 259 | } catch { 260 | setLoading(false); 261 | } finally { 262 | closeDialog(); 263 | } 264 | }, 265 | [createSchemaName, createSchemaSample, schemaLoaded, closeDialog], 266 | ); 267 | 268 | const inputChange: ChangeEventHandler = useCallback( 269 | event => setCreateSchemaName(event.target.value), 270 | [], 271 | ); 272 | 273 | return createPortal( 274 | 275 | 276 | 新增方案 277 | 278 |
      279 |
    • 280 | { 282 | setCreateSchemaName(getDefaultFileName("")); 283 | setCreateSchemaSample(""); 284 | }}> 285 | 286 | 新增空白方案…… 287 | 288 |
    • 289 | {recursiveFolder(samples)} 290 |
    291 |
    292 | {/* TODO preview disabled for now */} 293 | 338 | 339 | 340 | 354 | 355 | 358 | 361 | 362 | {validation || "\xa0"} 363 | {loading && ( 364 | 365 | 366 | 367 | )} 368 |
    369 |
    , 370 | document.body, 371 | ); 372 | }); 373 | 374 | export default CreateSchemaDialog; 375 | -------------------------------------------------------------------------------- /src/Components/ExplorerFolder.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { faAngleDown, faAngleRight } from "@fortawesome/free-solid-svg-icons"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | 5 | import type { ReactNode } from "../consts"; 6 | 7 | const FolderItem = styled.summary` 8 | display: flex; 9 | align-items: center; 10 | outline: none; 11 | transition: color 100ms; 12 | list-style: none; 13 | &::-moz-focus-inner { 14 | border: none; 15 | padding: 0; 16 | } 17 | &::marker, 18 | &::-webkit-details-marker { 19 | content: ""; 20 | display: none; 21 | } 22 | &:hover, 23 | &:focus { 24 | color: #0078e7; 25 | } 26 | `; 27 | const ExpandedIcon = styled(FontAwesomeIcon)` 28 | display: none; 29 | details[open] & { 30 | display: block; 31 | } 32 | `; 33 | const CollapsedIcon = styled(FontAwesomeIcon)` 34 | display: block; 35 | details[open] & { 36 | display: none; 37 | } 38 | `; 39 | 40 | export default function ExplorerFolder({ name, children }: { name: string; children: ReactNode }) { 41 | return ( 42 |
  • 43 |
    44 | 45 | 46 | 47 | {name} 48 | 49 |
      {children}
    50 |
    51 |
  • 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/Components/Main.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | import styled from "@emotion/styled"; 5 | import { faCopy } from "@fortawesome/free-regular-svg-icons"; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | 8 | import SchemaEditor from "./SchemaEditor"; 9 | import Spinner from "./Spinner"; 10 | import { listenTooltip } from "./TooltipChar"; 11 | import Swal from "../Classes/SwalReact"; 12 | import { allOptions, defaultArticle } from "../consts"; 13 | import evaluate from "../evaluate"; 14 | import { listenArticle } from "../options"; 15 | import initialState, { stateStorageLocation } from "../state"; 16 | import TooltipLabel from "./TooltipLabel"; 17 | import { stopPropagation } from "../utils"; 18 | 19 | import type { MainState, Option, ReactNode } from "../consts"; 20 | import type { MutableRefObject } from "react"; 21 | 22 | const dummyOutput = document.createElement("output"); 23 | 24 | const ArticleInput = styled.textarea` 25 | line-height: 1.6; 26 | resize: none; 27 | width: 100%; 28 | flex: 1; 29 | overflow: hidden; 30 | `; 31 | const OutputContainer = styled.dialog` 32 | transform: translateY(10%); 33 | @starting-style { 34 | transform: translateY(10%); 35 | } 36 | &[open] { 37 | transform: translateY(0); 38 | } 39 | &::backdrop { 40 | background-color: rgba(0, 0, 0, 0.2); 41 | } 42 | `; 43 | const OutputPopup = styled.div` 44 | height: fit-content; 45 | box-sizing: border-box; 46 | margin-top: auto; 47 | background-color: white; 48 | border-top: 0.25rem solid #ccc; 49 | position: relative; 50 | overflow: auto; 51 | padding: 1rem; 52 | `; 53 | const OutputContent = styled.output` 54 | display: block; 55 | font-size: 105%; 56 | margin-top: 0.75rem; 57 | overflow-wrap: break-word; 58 | white-space: pre-wrap; 59 | white-space: break-spaces; 60 | h3, 61 | p { 62 | margin: 0; 63 | line-height: 1.2; 64 | } 65 | :not(rt):lang(och-Latn-fonipa) { 66 | white-space: initial; 67 | } 68 | ruby { 69 | margin: 0 3px; 70 | display: inline-flex; 71 | flex-direction: column-reverse; 72 | align-items: center; 73 | vertical-align: bottom; 74 | } 75 | rt { 76 | font-size: 82.5%; 77 | line-height: 1.1; 78 | text-align: center; 79 | } 80 | table { 81 | margin-top: -0.5rem; 82 | margin-left: 0.25rem; 83 | border-spacing: 0; 84 | thead { 85 | position: sticky; 86 | top: -2px; 87 | background-color: white; 88 | height: 2.5rem; 89 | vertical-align: bottom; 90 | } 91 | th, 92 | td { 93 | border-left: 0.5px solid #aaa; 94 | padding: 0 0.5rem; 95 | &:first-child { 96 | border-left: none; 97 | padding-left: 0.25rem; 98 | } 99 | } 100 | th { 101 | text-align: left; 102 | border-bottom: 0.5px solid #aaa; 103 | padding-right: 1.25rem; 104 | } 105 | tbody > tr:first-child > td { 106 | padding-top: 0.25rem; 107 | } 108 | } 109 | `; 110 | const Title = styled.h1` 111 | display: flex; 112 | align-items: center; 113 | margin: 0 0.25rem; 114 | font-size: 1.75rem; 115 | `; 116 | const CopyButton = styled.button` 117 | margin-left: 1rem; 118 | transition: color 0.2s; 119 | color: #888; 120 | cursor: pointer; 121 | font-size: 0.8em; 122 | font-weight: 400; 123 | display: flex; 124 | align-items: center; 125 | gap: 0.25em; 126 | &:hover, 127 | &:focus { 128 | color: #0078e7; 129 | } 130 | & > div { 131 | font-size: 0.75em; 132 | border-bottom: none; 133 | } 134 | `; 135 | const CloseButton = styled.button` 136 | position: absolute; 137 | top: 0; 138 | right: 0; 139 | display: flex; 140 | font-size: 3.5rem; 141 | margin: 0; 142 | &:focus { 143 | box-shadow: none; 144 | } 145 | `; 146 | const Loading = styled.div` 147 | display: flex; 148 | align-items: center; 149 | justify-content: center; 150 | margin-bottom: 2rem; 151 | `; 152 | 153 | let evaluationResult: ReactNode = []; 154 | 155 | export default function Main({ evaluateHandlerRef }: { evaluateHandlerRef: MutableRefObject<() => void> }) { 156 | const [state, setState] = useState(initialState); 157 | const { article, option, convertVariant, syncCharPosition } = state; 158 | useEffect(() => { 159 | localStorage.setItem(stateStorageLocation, JSON.stringify(state)); 160 | }, [state]); 161 | 162 | function useHandle(key: T, handler: (event: E) => MainState[T]): (event: E) => void { 163 | return useCallback(event => setState(state => ({ ...state, [key]: handler(event) })), [handler, key]); 164 | } 165 | 166 | const [syncedArticle, setSyncedArticle] = useState([]); 167 | 168 | useEffect(() => { 169 | if (syncCharPosition && syncedArticle.length) setState(state => ({ ...state, article: syncedArticle.join("") })); 170 | }, [syncCharPosition, syncedArticle]); 171 | 172 | const ref = useRef(dummyOutput); 173 | const [operation, increaseOperation] = useReducer((operation: number) => operation + 1, 0); 174 | 175 | const dialogRef = useRef(null); 176 | const closeDialog = useCallback(() => { 177 | dialogRef.current?.close(); 178 | }, []); 179 | evaluateHandlerRef.current = useCallback(async () => { 180 | evaluationResult = []; 181 | dialogRef.current?.showModal(); 182 | setLoading(true); 183 | try { 184 | evaluationResult = await evaluate(state); 185 | increaseOperation(); 186 | } catch { 187 | closeDialog(); 188 | } finally { 189 | setLoading(false); 190 | } 191 | }, [state, closeDialog]); 192 | 193 | const [loading, setLoading] = useState(false); 194 | 195 | const [copyTooltipText, setCopyTooltipText] = useState("全部複製"); 196 | const copyEvaluationResult = useCallback(async () => { 197 | const content = ref.current.textContent?.trim(); 198 | if (content) { 199 | try { 200 | await navigator.clipboard.writeText(content); 201 | setCopyTooltipText("已複製"); 202 | } catch { 203 | setCopyTooltipText("複製失敗"); 204 | } 205 | } 206 | }, []); 207 | const onHideTooltip = useCallback(() => setCopyTooltipText("全部複製"), []); 208 | 209 | // XXX Please Rewrite 210 | useEffect(() => { 211 | listenArticle(setSyncedArticle); 212 | listenTooltip((id, ch, 描述) => { 213 | setSyncedArticle(syncedArticle => { 214 | syncedArticle = [...syncedArticle]; 215 | syncedArticle[id] = `${ch}(${描述})`; 216 | return syncedArticle; 217 | }); 218 | }); 219 | }, []); 220 | 221 | const [articleInput, setArticleInput] = useState(null); 222 | useLayoutEffect(() => { 223 | if (!articleInput) return; 224 | const textArea = articleInput; 225 | const container = textArea.parentElement!; 226 | function resizeTextArea() { 227 | const scrollTop = container.scrollTop; 228 | // First measure without a scrollbar 229 | textArea.style.minHeight = ""; 230 | textArea.style.flex = "unset"; 231 | const computedStyle = getComputedStyle(textArea); 232 | const borderHeight = parseFloat(computedStyle.borderTopWidth) + parseFloat(computedStyle.borderBottomWidth); 233 | textArea.style.minHeight = `max(9em, ${textArea.scrollHeight + borderHeight}px)`; 234 | if (textArea.scrollHeight > textArea.getBoundingClientRect().height) { 235 | // Remeasure if the input doesn’t actually fit due to the addition of scrollbar 236 | textArea.style.minHeight = ""; 237 | container.style.overflowY = "scroll"; 238 | textArea.style.minHeight = `max(9em, ${textArea.scrollHeight + borderHeight}px)`; 239 | container.style.overflowY = ""; 240 | } 241 | textArea.style.flex = ""; 242 | container.scrollTop = scrollTop; 243 | } 244 | resizeTextArea(); 245 | const resizeObserver = new ResizeObserver(resizeTextArea); 246 | resizeObserver.observe(container); 247 | return () => { 248 | resizeObserver.disconnect(); 249 | }; 250 | }, [article, articleInput]); 251 | 252 | const resetArticle = useCallback(async () => { 253 | if ( 254 | !article || 255 | (article !== defaultArticle && 256 | ( 257 | await Swal.fire({ 258 | title: "要恢復成預設文本嗎?", 259 | text: "此動作無法復原。", 260 | icon: "warning", 261 | showConfirmButton: false, 262 | focusConfirm: false, 263 | showDenyButton: true, 264 | showCancelButton: true, 265 | focusCancel: true, 266 | denyButtonText: "確定", 267 | cancelButtonText: "取消", 268 | }) 269 | ).isDenied) 270 | ) 271 | setState({ ...state, article: defaultArticle }); 272 | }, [article, state]); 273 | 274 | return ( 275 | <> 276 | 281 |
    282 | 291 | 297 | 305 | 313 | 321 |
    322 | event.target.value)} 332 | value={article} 333 | /> 334 | 335 | } 336 | evaluateHandlerRef={evaluateHandlerRef} 337 | /> 338 | {createPortal( 339 | 340 | 341 | 342 | <span>推導結果</span> 343 | {!loading && ( 344 | <> 345 | <TooltipLabel description={copyTooltipText} onHideTooltip={onHideTooltip}> 346 | <CopyButton onClick={copyEvaluationResult}> 347 | <FontAwesomeIcon icon={faCopy} size="sm" /> 348 | <div>全部複製</div> 349 | </CopyButton> 350 | </TooltipLabel> 351 | <form method="dialog"> 352 | <CloseButton type="submit" className="swal2-close" title="關閉"> 353 | × 354 | </CloseButton> 355 | </form> 356 | </> 357 | )} 358 | 359 | 360 | {evaluationResult} 361 | 362 | {loading && ( 363 | 364 | 365 | 366 | )} 367 | 368 | , 369 | document.body, 370 | )} 371 | 372 | ); 373 | } 374 | -------------------------------------------------------------------------------- /src/Components/Ruby.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | import type { ReactNode } from "../consts"; 4 | 5 | export default function Ruby({ rb, rt }: { rb: ReactNode; rt: ReactNode }) { 6 | return ( 7 | 8 | {rb} 9 | ( 10 | 11 | {Array.isArray(rt) 12 | ? rt.map((item, index) => ( 13 | 14 | {!!index && ( 15 | <> 16 | 17 |
    18 | 19 | )} 20 | {item} 21 |
    22 | )) 23 | : rt} 24 |
    25 | ) 26 |
    27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/Components/SchemaEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | import { css } from "@emotion/react"; 5 | import styled from "@emotion/styled"; 6 | import { faFileCode } from "@fortawesome/free-regular-svg-icons"; 7 | import { faPlus, faRotateLeft, faXmark } from "@fortawesome/free-solid-svg-icons"; 8 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 9 | import Editor, { useMonaco } from "@monaco-editor/react"; 10 | 11 | import CreateSchemaDialog from "./CreateSchemaDialog"; 12 | import Spinner from "./Spinner"; 13 | import actions from "../actions"; 14 | import Swal from "../Classes/SwalReact"; 15 | import { codeFontFamily, invalidCharsRegex, newFileTemplate, noop, tshetUinhExamplesURLPrefix } from "../consts"; 16 | import "../editor/setup"; 17 | import { 18 | displaySchemaLoadingErrors, 19 | fetchFile, 20 | memoize, 21 | normalizeFileName, 22 | notifyError, 23 | settleAndGroupPromise, 24 | showLoadingModal, 25 | } from "../utils"; 26 | 27 | import type { UseMainState, ReactNode } from "../consts"; 28 | import type { MouseEvent, MutableRefObject } from "react"; 29 | 30 | const TabBar = styled.div` 31 | display: flex; 32 | align-items: flex-end; 33 | user-select: none; 34 | color: #333; 35 | background-color: #eaecee; 36 | white-space: nowrap; 37 | min-height: 2.25rem; 38 | padding: 0 0.375rem; 39 | overflow: hidden; 40 | `; 41 | const Tab = styled.div<{ checked: boolean }>` 42 | display: flex; 43 | align-items: center; 44 | position: relative; 45 | box-sizing: border-box; 46 | height: 100%; 47 | padding-left: 0.5rem; 48 | ${({ checked }) => 49 | checked && 50 | css` 51 | z-index: 8; 52 | border-radius: 0.5rem 0.5rem 0 0; 53 | background-color: white; 54 | &:before, 55 | &:after { 56 | content: ""; 57 | position: absolute; 58 | bottom: 0; 59 | background-color: white; 60 | width: 0.5rem; 61 | height: 0.5rem; 62 | } 63 | &:before { 64 | left: -0.5rem; 65 | } 66 | &:after { 67 | right: -0.5rem; 68 | } 69 | `} 70 | > svg { 71 | z-index: 2; 72 | color: #666; 73 | } 74 | `; 75 | const Name = styled.div<{ checked: boolean }>` 76 | margin: 0 0.4rem; 77 | ${({ checked }) => 78 | checked && 79 | css` 80 | &:before, 81 | &:after { 82 | content: ""; 83 | position: absolute; 84 | bottom: 0; 85 | background-color: #eaecee; 86 | width: 0.5rem; 87 | height: 0.5rem; 88 | z-index: 1; 89 | } 90 | &:before { 91 | border-radius: 0 0 0.5rem 0; 92 | left: -0.5rem; 93 | } 94 | &:after { 95 | border-radius: 0 0 0 0.5rem; 96 | right: -0.5rem; 97 | } 98 | `} 99 | `; 100 | const DeleteButton = styled.button` 101 | display: inline-flex; 102 | align-items: center; 103 | justify-content: center; 104 | width: 1.0625rem; 105 | height: 1.0625rem; 106 | border-radius: 9999px; 107 | color: #666; 108 | transition: background-color 150ms; 109 | &:hover, 110 | &:focus { 111 | background-color: #ccc; 112 | } 113 | `; 114 | const Separator = styled.div<{ visible: boolean }>` 115 | width: 0.5px; 116 | height: 1.375rem; 117 | margin-left: 0.5rem; 118 | ${({ visible }) => 119 | visible && 120 | css` 121 | background-color: #888; 122 | `} 123 | `; 124 | const CreateSchemaButton = styled.button` 125 | align-self: center; 126 | margin-left: 0.5rem; 127 | display: inline-flex; 128 | align-items: center; 129 | justify-content: center; 130 | border-radius: 9999px; 131 | min-height: 1.75rem; 132 | padding: 0 0.375rem; 133 | gap: 0.125rem; 134 | font-size: 1rem; 135 | color: #555; 136 | transition: background-color 150ms; 137 | &:hover, 138 | &:focus { 139 | background-color: #ccc; 140 | } 141 | & > .fa-fw { 142 | width: 1em; 143 | } 144 | `; 145 | const EditorArea = styled.div` 146 | position: relative; 147 | `; 148 | const ResetButton = styled.button` 149 | display: inline-flex; 150 | margin-left: 1rem; 151 | transition: color 0.2s; 152 | color: #555; 153 | cursor: pointer; 154 | align-items: center; 155 | gap: 0.125rem; 156 | &:hover, 157 | &:focus { 158 | color: #0078e7; 159 | } 160 | &.rotate svg { 161 | animation: rotate 0.3s; 162 | } 163 | @keyframes rotate { 164 | 0% { 165 | transform: rotate(360deg); 166 | } 167 | 100% { 168 | transform: rotate(0deg); 169 | } 170 | } 171 | & div { 172 | font-size: initial; 173 | font-weight: initial; 174 | } 175 | `; 176 | const Parameters = styled.p` 177 | margin: -0.75rem 0 0; 178 | `; 179 | const NoParameters = styled.p` 180 | margin: -1.25rem 0 -0.5rem; 181 | font-size: 0.875rem; 182 | color: #888; 183 | `; 184 | const ParameterErrorHint = styled.p` 185 | margin: 0; 186 | font-size: 0.875rem; 187 | color: red; 188 | `; 189 | const Divider = styled.div<{ isDragging: boolean }>` 190 | background-color: #c4c6c8; 191 | height: 0.2rem; 192 | position: relative; 193 | cursor: ns-resize; 194 | &::after { 195 | content: ""; 196 | position: absolute; 197 | top: -0.1rem; 198 | bottom: -0.1rem; 199 | left: 0; 200 | right: 0; 201 | background-color: ${({ isDragging }) => (isDragging ? "#0078e7" : "transparent")}; 202 | transition: background-color 150ms; 203 | } 204 | &:hover::after, 205 | &:focus::after { 206 | background-color: #0078e7; 207 | } 208 | `; 209 | const DividerShadow = styled.div` 210 | position: absolute; 211 | left: 0; 212 | bottom: 0; 213 | right: 0; 214 | height: 6px; 215 | box-shadow: #ddd 0 -6px 6px -6px inset; 216 | `; 217 | const Options = styled.form` 218 | flex: 1; 219 | display: flex; 220 | flex-direction: column; 221 | gap: 1.17rem; 222 | padding: 1.17rem 1rem; 223 | overflow-y: auto; 224 | `; 225 | const OptionsTitle = styled.h3` 226 | margin: 0; 227 | `; 228 | const OptionsSeparator = styled.hr` 229 | margin: -0.17rem -1rem 0; 230 | `; 231 | const DropContainer = styled.div<{ isDragging: boolean }>` 232 | position: fixed; 233 | inset: 0; 234 | display: ${({ isDragging }) => (isDragging ? "grid" : "none")}; 235 | background-color: rgba(127, 127, 127, 0.7); 236 | padding: 3rem; 237 | z-index: 2147483647; 238 | `; 239 | const DropArea = styled.div` 240 | border: 0.5rem dashed #ccc; 241 | border-radius: 2.5rem; 242 | font-size: 4rem; 243 | display: grid; 244 | place-items: center; 245 | color: white; 246 | background-color: rgba(191, 191, 191, 0.7); 247 | `; 248 | 249 | interface SchemaEditorProps extends UseMainState { 250 | commonOptions: ReactNode; 251 | evaluateHandlerRef: MutableRefObject<() => void>; 252 | } 253 | 254 | export default function SchemaEditor({ state, setState, commonOptions, evaluateHandlerRef }: SchemaEditorProps) { 255 | const { schemas, activeSchemaName } = state; 256 | const activeSchema = useMemo( 257 | () => schemas.find(({ name }) => name === activeSchemaName), 258 | [schemas, activeSchemaName], 259 | ); 260 | 261 | const monaco = useMonaco(); 262 | useEffect(() => { 263 | if (!monaco) return; 264 | // Clean up deleted schemata 265 | const schemaUris = new Set(schemas.map(({ name }) => monaco.Uri.parse(name).toString())); 266 | for (const model of monaco.editor.getModels()) { 267 | if (!schemaUris.has(model.uri.toString())) { 268 | model.dispose(); 269 | } 270 | } 271 | }, [monaco, schemas]); 272 | 273 | const getDefaultFileNameWithSchemaNames = useCallback( 274 | (schemaNames: string[]) => 275 | memoize((name: string) => { 276 | name ||= "無標題"; 277 | const indices = schemaNames 278 | .map(oldName => { 279 | if (oldName === name) return 0; 280 | if (!oldName.startsWith(name + "-")) return -1; 281 | const start = name.length + 1; 282 | for (let i = start; i < oldName.length; i++) 283 | if (oldName[i] < +(i === start) + "" || oldName[i] > "9") return -1; 284 | return +oldName.slice(start); 285 | }) 286 | .sort((a, b) => a - b); 287 | indices[-1] = -1; 288 | let i = 0; 289 | while (indices[i] - indices[i - 1] <= 1) i++; 290 | return name + (~indices[i - 1] || ""); 291 | }), 292 | [], 293 | ); 294 | const getDefaultFileName = useMemo( 295 | () => getDefaultFileNameWithSchemaNames(schemas.map(({ name }) => name)), 296 | [getDefaultFileNameWithSchemaNames, schemas], 297 | ); 298 | 299 | const dialogRef = useRef(null); 300 | 301 | const createSchema = useRef(noop); 302 | createSchema.current = useCallback(() => dialogRef.current?.showModal(), []); 303 | 304 | const addFilesToSchema = useCallback( 305 | async (files: Iterable) => { 306 | const { fulfilled: fileNamesAndContents, rejected: errors } = await settleAndGroupPromise( 307 | Array.from(files, async file => [file.name, await file.text()] as const), 308 | ); 309 | setState(newState => { 310 | const currSchemaNames = schemas.map(({ name }) => name); 311 | for (const [name, content] of fileNamesAndContents) { 312 | // POSIX allows all characters other than `\0` and `/` in file names, 313 | // this is necessary to ensure that the file name is valid on all platforms. 314 | const formattedName = getDefaultFileNameWithSchemaNames(currSchemaNames)( 315 | normalizeFileName(name).replace(invalidCharsRegex, "_"), 316 | ); 317 | currSchemaNames.push(formattedName); 318 | newState = actions.addSchema({ name: formattedName, input: content })(newState); 319 | } 320 | return newState; 321 | }); 322 | return errors; 323 | }, 324 | [schemas, setState, getDefaultFileNameWithSchemaNames], 325 | ); 326 | 327 | useEffect(() => { 328 | async function loadSchemas() { 329 | const query = new URLSearchParams(location.search); 330 | history.replaceState(null, document.title, location.pathname); // Remove query 331 | const hrefs = query.getAll("script"); 332 | if (!hrefs.length && schemas.length) return; 333 | const abortController = new AbortController(); 334 | const { signal } = abortController; 335 | showLoadingModal(abortController); 336 | if (!hrefs.length) { 337 | try { 338 | setState( 339 | actions.addSchema({ 340 | name: "切韻拼音", 341 | input: await fetchFile(tshetUinhExamplesURLPrefix + "tupa.js", signal), 342 | }), 343 | ); 344 | } catch { 345 | setState( 346 | actions.addSchema({ 347 | name: "無標題", 348 | input: newFileTemplate, 349 | }), 350 | ); 351 | } 352 | Swal.close(); 353 | return; 354 | } 355 | const names = query.getAll("name"); 356 | let i = 0; 357 | const { fulfilled: files, rejected: errors } = await settleAndGroupPromise( 358 | hrefs.map(async href => { 359 | // Adds a protocol if the input seems to lack one 360 | // This also prevents `example.com:` from being treated as a protocol if the input is `example.com:8080` 361 | const url = new URL(/^[a-z]+:/i.test(href) ? href : `https://${href}`); 362 | const name = 363 | i < names.length 364 | ? names[i++] // Use user-specified name 365 | : url.protocol === "data:" 366 | ? "" // Let `getDefaultFileName` name it 367 | : /([^/]*)\/*$/.exec(url.pathname)![1]; // Use the last segment of the path as name 368 | if (url.hostname === "github.com") { 369 | url.searchParams.append("raw", "true"); // Fetch raw file content for GitHub files 370 | } else if (url.hostname === "gist.github.com" && !url.pathname.endsWith("/raw")) { 371 | url.pathname += "/raw"; // Fetch raw file content for GitHub gists 372 | } 373 | const response = await fetch(url, { 374 | headers: { 375 | Accept: "text/javascript, text/plain", // githubusercontent.com always responses with `Content-Type: text/plain` 376 | }, 377 | cache: "no-cache", 378 | signal, 379 | }); 380 | if (!response.ok) throw new Error(await response.text()); 381 | const blob = await response.blob(); 382 | return new File([blob], name); 383 | }), 384 | ); 385 | // The file names may be incorrect in strict mode, but are fine in production build 386 | errors.push(...(await addFilesToSchema(files))); 387 | // Add `tupa.js` if all fetches failed and no schemas present 388 | if (errors.length === hrefs.length && !schemas.length) await loadSchemas(); 389 | Swal.close(); 390 | signal.aborted || displaySchemaLoadingErrors(errors, hrefs.length); 391 | } 392 | loadSchemas(); 393 | }, [schemas, setState, addFilesToSchema]); 394 | 395 | const deleteSchema = useCallback( 396 | async (name: string) => { 397 | if ( 398 | ( 399 | await Swal.fire({ 400 | title: "要刪除此方案嗎?", 401 | text: "此動作無法復原。", 402 | icon: "warning", 403 | showConfirmButton: false, 404 | focusConfirm: false, 405 | showDenyButton: true, 406 | showCancelButton: true, 407 | focusCancel: true, 408 | denyButtonText: "確定", 409 | cancelButtonText: "取消", 410 | }) 411 | ).isDenied 412 | ) 413 | setState(actions.deleteSchema(name)); 414 | }, 415 | [setState], 416 | ); 417 | 418 | const deleteActiveSchema = useRef(noop); 419 | deleteActiveSchema.current = useCallback(() => { 420 | deleteSchema(activeSchemaName); 421 | }, [deleteSchema, activeSchemaName]); 422 | 423 | const openFileFromDisk = useRef(noop); 424 | openFileFromDisk.current = useCallback(() => { 425 | if ("showOpenFilePicker" in window) { 426 | (async () => { 427 | try { 428 | const handles = await showOpenFilePicker({ 429 | id: "autoderiver-open-file", 430 | types: [ 431 | { 432 | description: "JavaScript file", 433 | accept: { "text/javascript": [".js"] }, 434 | }, 435 | ], 436 | multiple: true, 437 | }); 438 | const { fulfilled: files, rejected: errors } = await settleAndGroupPromise( 439 | handles.map(handle => handle.getFile()), 440 | ); 441 | errors.push(...(await addFilesToSchema(files))); 442 | displaySchemaLoadingErrors(errors, handles.length); 443 | } catch (error) { 444 | if ((error as Error | null)?.name !== "AbortError") { 445 | notifyError("開啟檔案時發生錯誤", error); 446 | } 447 | } 448 | })(); 449 | } else { 450 | const input = document.createElement("input"); 451 | input.hidden = true; 452 | input.type = "file"; 453 | input.multiple = true; 454 | document.body.appendChild(input); 455 | input.addEventListener("change", async () => { 456 | const { files } = input; 457 | document.body.removeChild(input); 458 | files && displaySchemaLoadingErrors(await addFilesToSchema(files), files.length); 459 | }); 460 | input.showPicker(); 461 | } 462 | }, [addFilesToSchema]); 463 | 464 | const saveFileToDisk = useRef(noop); 465 | saveFileToDisk.current = useCallback(() => { 466 | if (!activeSchema?.input) return; 467 | const file = new Blob([activeSchema.input], { type: "text/javascript" }); 468 | if ("showSaveFilePicker" in window) { 469 | (async () => { 470 | try { 471 | const handle = await showSaveFilePicker({ 472 | id: "autoderiver-save-file", 473 | types: [ 474 | { 475 | description: "JavaScript file", 476 | accept: { "text/javascript": [".js"] }, 477 | }, 478 | ], 479 | suggestedName: activeSchema.name, 480 | }); 481 | const writable = await handle.createWritable(); 482 | await writable.write(file); 483 | await writable.close(); 484 | } catch (error) { 485 | if ((error as Error | null)?.name !== "AbortError") { 486 | notifyError("儲存檔案時發生錯誤", error); 487 | } 488 | } 489 | })(); 490 | } else { 491 | const url = URL.createObjectURL(file); 492 | const anchor = document.createElement("a"); 493 | anchor.href = url; 494 | anchor.download = activeSchema.name; 495 | anchor.hidden = true; 496 | document.body.appendChild(anchor); 497 | anchor.click(); 498 | document.body.removeChild(anchor); 499 | URL.revokeObjectURL(url); 500 | } 501 | }, [activeSchema]); 502 | 503 | const resetParameters = useCallback( 504 | (event: MouseEvent) => { 505 | event.preventDefault(); 506 | setState(actions.resetSchemaParameters(activeSchemaName)); 507 | const element = event.currentTarget; 508 | element.classList.remove("rotate"); 509 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- Trigger DOM reflow 510 | element.offsetWidth; 511 | element.classList.add("rotate"); 512 | }, 513 | [activeSchemaName, setState], 514 | ); 515 | 516 | const tabBarRef = useRef(null); 517 | function drag(name: string, { clientX: startX }: { clientX: number }, isMouse?: boolean) { 518 | document.body.classList.add("dragging"); 519 | if (activeSchemaName !== name) setState(state => ({ ...state, activeSchemaName: name })); 520 | const { length } = schemas; 521 | if (length <= 1 || tabBarRef.current?.childElementCount !== length + 1) return; 522 | 523 | const index = schemas.findIndex(schema => schema.name === name); 524 | const children = [].slice.call(tabBarRef.current.children, 0, -1) as HTMLElement[]; 525 | const widths = children.map(element => element.getBoundingClientRect().width); 526 | const currentWidth = widths[index] + "px"; 527 | const threshold: number[] = []; 528 | threshold[index] = 0; 529 | 530 | for (let sum = 0, i = index - 1; i >= 0; i--) { 531 | threshold[i] = sum + widths[i] / 2; 532 | sum += widths[i]; 533 | } 534 | for (let sum = 0, i = index + 1; i < length; i++) { 535 | threshold[i] = sum + widths[i] / 2; 536 | sum += widths[i]; 537 | } 538 | 539 | let clientX = startX; 540 | 541 | function move(event: { clientX: number } | TouchEvent) { 542 | clientX = "clientX" in event ? event.clientX : (event.touches?.[0]?.clientX ?? clientX); 543 | let value = clientX - startX; 544 | children[index].style.left = value + "px"; 545 | if (value < 0) { 546 | value = -value; 547 | for (let i = 0; i < index; i++) children[i].style.left = value >= threshold[i] ? currentWidth : ""; 548 | for (let i = length - 1; i > index; i--) children[i].style.left = ""; 549 | } else { 550 | for (let i = 0; i < index; i++) children[i].style.left = ""; 551 | for (let i = length - 1; i > index; i--) 552 | children[i].style.left = value >= threshold[i] ? "-" + currentWidth : ""; 553 | } 554 | } 555 | 556 | function end(event: { clientX: number } | TouchEvent) { 557 | clientX = "clientX" in event ? event.clientX : (event.touches?.[0]?.clientX ?? clientX); 558 | let value = clientX - startX; 559 | children.forEach(element => (element.style.left = "")); 560 | let i: number; 561 | if (value < 0) { 562 | value = -value; 563 | for (i = 0; i < index; i++) if (value >= threshold[i]) break; 564 | } else { 565 | for (i = length - 1; i > index; i--) if (value >= threshold[i]) break; 566 | } 567 | if (i !== index) setState(actions.moveSchema(name, i)); 568 | 569 | document.body.classList.remove("dragging"); 570 | if (isMouse) { 571 | document.removeEventListener("mousemove", move); 572 | document.removeEventListener("mouseup", end); 573 | } else { 574 | document.removeEventListener("touchmove", move); 575 | document.removeEventListener("touchend", end); 576 | document.removeEventListener("touchcancel", end); 577 | } 578 | } 579 | 580 | if (isMouse) { 581 | document.addEventListener("mousemove", move); 582 | document.addEventListener("mouseup", end); 583 | } else { 584 | document.addEventListener("touchmove", move); 585 | document.addEventListener("touchend", end); 586 | document.addEventListener("touchcancel", end); 587 | } 588 | } 589 | 590 | function validateFileName(name: string) { 591 | const hasSchemaName = (name: string) => schemas.find(schema => schema.name === name); 592 | if (!name) return "方案名稱為空"; 593 | if (invalidCharsRegex.test(name)) return "方案名稱含有特殊字元"; 594 | if (hasSchemaName(name)) return "方案名稱與現有方案重複"; 595 | return ""; 596 | } 597 | 598 | async function mouseUp(name: string, { button }: { button: number }) { 599 | if (button === 1) deleteSchema(name); 600 | else if (button === 2) { 601 | const promise = Swal.fire({ 602 | title: "重新命名方案", 603 | input: "text", 604 | inputPlaceholder: "輸入方案名稱……", 605 | inputValue: name, 606 | inputAttributes: { 607 | autocomplete: "off", 608 | autocorrect: "off", 609 | autocapitalize: "off", 610 | spellcheck: "false", 611 | }, 612 | showCancelButton: true, 613 | confirmButtonText: "確定", 614 | cancelButtonText: "取消", 615 | }); 616 | const confirmButton = Swal.getConfirmButton() as HTMLButtonElement; 617 | confirmButton.disabled = true; 618 | confirmButton.style.pointerEvents = "none"; 619 | const input = Swal.getInput() as HTMLInputElement; 620 | input.addEventListener("input", () => { 621 | const newName = normalizeFileName(input.value); 622 | const validation = validateFileName(newName); 623 | if (validation) { 624 | if (newName !== name) { 625 | const { selectionStart, selectionEnd, selectionDirection } = input; 626 | Swal.showValidationMessage(validation); 627 | input.setSelectionRange(selectionStart, selectionEnd, selectionDirection || undefined); 628 | } 629 | confirmButton.disabled = true; 630 | confirmButton.style.pointerEvents = "none"; 631 | } else { 632 | Swal.resetValidationMessage(); 633 | confirmButton.disabled = false; 634 | confirmButton.style.pointerEvents = ""; 635 | } 636 | }); 637 | const { isConfirmed, value } = await promise; 638 | if (isConfirmed) { 639 | const newName = normalizeFileName(value); 640 | if (!validateFileName(newName)) setState(actions.renameSchema(name, newName)); 641 | } 642 | } 643 | } 644 | 645 | const dropContainerRef = useRef(null); 646 | const [isDragging, setIsDragging] = useState(false); 647 | useEffect(() => { 648 | function onDragStart(event: DragEvent) { 649 | if (!event.dataTransfer?.types.includes("Files")) return; 650 | event.preventDefault(); 651 | event.stopPropagation(); 652 | setIsDragging(true); 653 | } 654 | 655 | function onDragEnd(event: DragEvent) { 656 | if (!event.dataTransfer?.types.includes("Files")) return; 657 | event.preventDefault(); 658 | event.stopPropagation(); 659 | if (event.target === dropContainerRef.current) setIsDragging(false); 660 | } 661 | 662 | async function onDrop(event: DragEvent) { 663 | if (!event.dataTransfer?.types.includes("Files")) return; 664 | event.preventDefault(); 665 | event.stopPropagation(); 666 | setIsDragging(false); 667 | const { files } = event.dataTransfer; 668 | displaySchemaLoadingErrors(await addFilesToSchema(files), files.length); 669 | } 670 | 671 | window.addEventListener("dragenter", onDragStart); 672 | window.addEventListener("dragover", onDragStart); 673 | window.addEventListener("dragend", onDragEnd); 674 | window.addEventListener("dragleave", onDragEnd); 675 | window.addEventListener("drop", onDrop); 676 | return () => { 677 | window.removeEventListener("dragenter", onDragStart); 678 | window.removeEventListener("dragover", onDragStart); 679 | window.removeEventListener("dragend", onDragEnd); 680 | window.removeEventListener("dragleave", onDragEnd); 681 | window.removeEventListener("drop", onDrop); 682 | }; 683 | }, [addFilesToSchema]); 684 | 685 | const [isDividerDragging, setIsDividerDragging] = useState(false); 686 | function dividerDrag({ target, clientY }: { target: EventTarget; clientY: number }, isMouse?: boolean) { 687 | document.body.classList.add("dragging"); 688 | document.body.style.cursor = "ns-resize"; 689 | setIsDividerDragging(true); 690 | const dividerElement = target as HTMLDivElement; 691 | const container = dividerElement.parentElement!; 692 | const editorElement = container.children[2]; 693 | 694 | const offsetY = clientY - dividerElement.getBoundingClientRect().top; 695 | 696 | function move(event: { clientY: number } | TouchEvent) { 697 | clientY = "clientY" in event ? event.clientY : (event.touches?.[0]?.clientY ?? clientY); 698 | const editorTop = editorElement.getBoundingClientRect().top; 699 | const numerator = clientY - offsetY - editorTop; 700 | const denominator = 701 | container.getBoundingClientRect().height - dividerElement.getBoundingClientRect().height - editorTop; 702 | setState(state => ({ ...state, optionPanelHeight: Math.min(Math.max(1 - numerator / denominator, 0.1), 0.9) })); 703 | } 704 | 705 | function end() { 706 | document.body.classList.remove("dragging"); 707 | document.body.style.cursor = ""; 708 | setIsDividerDragging(false); 709 | if (isMouse) { 710 | document.removeEventListener("mousemove", move); 711 | document.removeEventListener("mouseup", end); 712 | } else { 713 | document.removeEventListener("touchmove", move); 714 | document.removeEventListener("touchend", end); 715 | document.removeEventListener("touchcancel", end); 716 | } 717 | } 718 | 719 | if (isMouse) { 720 | document.addEventListener("mousemove", move); 721 | document.addEventListener("mouseup", end); 722 | } else { 723 | document.addEventListener("touchmove", move); 724 | document.addEventListener("touchend", end); 725 | document.addEventListener("touchcancel", end); 726 | } 727 | } 728 | 729 | const [editorArea, setEditorArea] = useState(null); 730 | const [optionPanel, setOptionPanel] = useState(null); 731 | useLayoutEffect(() => { 732 | if (!editorArea || !optionPanel) return; 733 | function setOptionPanelHeight() { 734 | editorArea!.style.height = 735 | (1 - state.optionPanelHeight) * 736 | (editorArea!.getBoundingClientRect().height + optionPanel!.getBoundingClientRect().height) + 737 | "px"; 738 | } 739 | setOptionPanelHeight(); 740 | addEventListener("resize", setOptionPanelHeight); 741 | return () => { 742 | removeEventListener("resize", setOptionPanelHeight); 743 | }; 744 | }, [editorArea, optionPanel, state.optionPanelHeight]); 745 | 746 | return ( 747 | <> 748 | 749 | {schemas.map(({ name }, index) => ( 750 | !event.button && drag(name, event, true)} 754 | onMouseUp={event => mouseUp(name, event)} 755 | onTouchStart={event => drag(name, event.touches[0])} 756 | onContextMenu={event => event.preventDefault()}> 757 | 758 | {name} 759 | deleteSchema(name)}> 760 | 761 | 762 | 763 | 764 | ))} 765 | 766 | 767 |
    加載更多注音方案
    768 |
    769 |
    770 | 771 | } 776 | options={{ 777 | fontFamily: codeFontFamily, 778 | scrollbar: { 779 | horizontalScrollbarSize: 10, 780 | verticalScrollbarSize: 10, 781 | }, 782 | unicodeHighlight: { 783 | nonBasicASCII: false, 784 | invisibleCharacters: false, 785 | ambiguousCharacters: false, 786 | }, 787 | }} 788 | onMount={useCallback( 789 | (editor, monaco) => { 790 | editor.addAction({ 791 | id: "create-file", 792 | label: "新增方案……", 793 | // Ctrl/Cmd + N cannot be overridden in browsers 794 | keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyN], 795 | run() { 796 | createSchema.current(); 797 | }, 798 | }); 799 | editor.addAction({ 800 | id: "delete-file", 801 | label: "刪除方案……", 802 | // Ctrl/Cmd + W cannot be overridden in browsers 803 | keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyW], 804 | run() { 805 | deleteActiveSchema.current(); 806 | }, 807 | }); 808 | editor.addAction({ 809 | id: "open-file-from-disk", 810 | label: "從本機開啟方案……", 811 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyO], 812 | run() { 813 | openFileFromDisk.current(); 814 | }, 815 | }); 816 | editor.addAction({ 817 | id: "save-file-to-disk", 818 | label: "儲存方案至本機……", 819 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], 820 | run() { 821 | saveFileToDisk.current(); 822 | }, 823 | }); 824 | 825 | function evaluate() { 826 | evaluateHandlerRef.current(); 827 | } 828 | // Using `addCommand` instead of `addAction` 829 | // as this does not need to be in the command palette 830 | editor.addCommand(monaco.KeyMod.Alt | monaco.KeyCode.KeyR, evaluate); 831 | editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, evaluate); 832 | }, 833 | [saveFileToDisk, evaluateHandlerRef], 834 | )} 835 | onChange={useCallback( 836 | input => { 837 | if (typeof input !== "undefined" && activeSchema) 838 | setState(actions.setSchemaInput(activeSchemaName, input)); 839 | }, 840 | [activeSchema, activeSchemaName, setState], 841 | )} 842 | /> 843 | 844 | 845 | dividerDrag(event, true)} 848 | onTouchStart={event => dividerDrag(event.touches[0])} 849 | /> 850 | 851 | 852 | 選項 853 | {activeSchema?.parameters.size || activeSchema?.parameters.errors.length ? ( 854 | 855 | 856 |
    將所有選項恢復成預設值
    857 |
    858 | ) : null} 859 |
    860 | {activeSchema?.parameters.size ? ( 861 | 862 | {activeSchema.parameters.render(parameters => 863 | setState(actions.setSchemaParameters(activeSchemaName, parameters)), 864 | )} 865 | 866 | ) : ( 867 | 此推導方案無需選項。 868 | )} 869 | {activeSchema?.parameters.errors.length ? ( 870 | 871 | 部分設定項目無法解析{" "} 872 | 878 | 879 | ) : null} 880 | 881 | {commonOptions} 882 |
    883 | setState(actions.addSchema(schema)), [setState])} 886 | getDefaultFileName={getDefaultFileName} 887 | hasSchemaName={useCallback(name => !!schemas.find(schema => schema.name === name), [schemas])} 888 | /> 889 | {createPortal( 890 | 891 | 892 |
    將推導方案檔案拖曳至此
    893 |
    894 |
    , 895 | document.body, 896 | )} 897 | 898 | ); 899 | } 900 | -------------------------------------------------------------------------------- /src/Components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | const Svg = styled.svg` 4 | animation: rotate 1s linear infinite; 5 | @keyframes rotate { 6 | from { 7 | transform: rotate(0deg); 8 | } 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | } 13 | `; 14 | const Circle = styled.circle` 15 | animation: dash 0.75s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite alternate; 16 | transform-origin: center; 17 | @keyframes dash { 18 | from { 19 | stroke-dashoffset: 38; 20 | transform: rotate(0rad); 21 | } 22 | to { 23 | stroke-dashoffset: 17; 24 | transform: rotate(-1rad); 25 | } 26 | } 27 | `; 28 | 29 | export default function Spinner() { 30 | return ( 31 | 32 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/Components/Table.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | import type { ReactNode } from "../consts"; 4 | 5 | export default function Table({ head, body }: { head: ReactNode[]; body: ReactNode[][] }) { 6 | return ( 7 | 8 | 9 | 10 | {head.map((item, index) => ( 11 | 12 | 13 | 14 | 15 | ))} 16 | 17 | 18 | 19 | {body.map((row, i) => ( 20 | 21 | {row.map((item, index) => ( 22 | 23 | 24 | 25 | 26 | ))} 27 | 28 | ))} 29 | 30 |
    {item}
    {item}
    31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/Components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, useCallback, useEffect, useRef } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import { css as stylesheet } from "@emotion/css"; 5 | 6 | import { stopPropagation } from "../utils"; 7 | 8 | import type { ReactElement, SyntheticEvent } from "react"; 9 | 10 | function getPageWidth() { 11 | return Math.max( 12 | document.body.scrollWidth, 13 | document.documentElement.scrollWidth, 14 | document.body.offsetWidth, 15 | document.documentElement.offsetWidth, 16 | document.documentElement.clientWidth, 17 | ); 18 | } 19 | 20 | const tooltipStyle = stylesheet` 21 | position: absolute; 22 | background-color: rgba(255, 255, 255, 0.95); 23 | border-radius: 4px; 24 | box-shadow: 0 1px 4px 1px rgba(0, 0, 0, 0.37); 25 | color: #333; 26 | min-width: 3rem; 27 | max-width: min(25rem, calc(100vw - 2rem)); 28 | z-index: 800; 29 | &:hover, 30 | &:focus { 31 | visibility: visible !important; 32 | } 33 | `; 34 | const fixedWidthStyle = stylesheet` 35 | width: 25rem; 36 | `; 37 | 38 | const div = document.getElementById("tooltip") ?? document.createElement("div"); 39 | div.addEventListener("click", stopPropagation); 40 | div.id = "tooltip"; 41 | div.style.visibility = "hidden"; 42 | div.className = tooltipStyle; 43 | const root = createRoot(div); 44 | 45 | let tooltipTarget: symbol | null = null; 46 | 47 | // HACK outputContainerScrollTop & outputContainerScrollLeft are to fix the Tooltip's position 48 | // when the OutputContainer is scrolled. This is just a TEMPORARY fix! 49 | function TooltipAnchor({ 50 | relativeToNodeBox, 51 | children, 52 | fixedWidth, 53 | outputContainerWidth = getPageWidth(), 54 | outputContainerScrollTop = window.scrollY, 55 | outputContainerScrollLeft = window.scrollX, 56 | }: { 57 | relativeToNodeBox: DOMRect; 58 | children: ReactElement; 59 | fixedWidth: boolean; 60 | outputContainerWidth?: number; 61 | outputContainerScrollTop?: number; 62 | outputContainerScrollLeft?: number; 63 | }) { 64 | useEffect(() => { 65 | div.className = tooltipStyle + (fixedWidth ? " " + fixedWidthStyle : ""); 66 | 67 | const oneRemSize = parseFloat(getComputedStyle(document.documentElement).fontSize); 68 | const margin = oneRemSize / 6; 69 | 70 | const divInnerBox = div.getBoundingClientRect(); 71 | 72 | let targetTop = relativeToNodeBox.top - divInnerBox.height - margin; 73 | targetTop = targetTop < oneRemSize ? relativeToNodeBox.bottom + margin : targetTop; 74 | targetTop += outputContainerScrollTop; 75 | 76 | let targetLeft = (relativeToNodeBox.left + relativeToNodeBox.right - divInnerBox.width) / 2; 77 | targetLeft = Math.min(outputContainerWidth - oneRemSize - divInnerBox.width, Math.max(oneRemSize, targetLeft)); 78 | targetLeft += outputContainerScrollLeft; 79 | 80 | div.style.top = targetTop + "px"; 81 | div.style.left = targetLeft + "px"; 82 | div.style.visibility = "visible"; 83 | }); 84 | 85 | return children; 86 | } 87 | 88 | export default function Tooltip({ 89 | element, 90 | children, 91 | fixedWidth = true, 92 | onHideTooltip, 93 | }: { 94 | element: ReactElement; 95 | children: ReactElement; 96 | fixedWidth?: boolean; 97 | onHideTooltip?: (() => void) | undefined; 98 | }) { 99 | const selfRef = useRef(Symbol("Tooltip")); 100 | const boxRef = useRef(null); 101 | 102 | const renderTooltip = useCallback(() => { 103 | // HACK Make this
    show on top of the OutputContainer (which is a ) 104 | // NOTE Attaching this node to a parent node managed by another React instance may have unexpected consequences. 105 | const outputContainer = document.querySelector("dialog[open]") ?? document.body; 106 | outputContainer.appendChild(div); 107 | // HACK Compensate for the width and scroll position of the OutputContainer 108 | const outputContainerIsDialog = outputContainer instanceof HTMLDialogElement; 109 | const outputContainerWidth = outputContainerIsDialog ? outputContainer.clientWidth : getPageWidth(); 110 | const outputContainerScrollTop = outputContainerIsDialog ? outputContainer.scrollTop : window.scrollY; 111 | const outputContainerScrollLeft = outputContainerIsDialog ? outputContainer.scrollLeft : window.scrollX; 112 | root.render( 113 | 119 | {element} 120 | , 121 | ); 122 | }, [element, fixedWidth]); 123 | const showTooltip = useCallback( 124 | (event: SyntheticEvent) => { 125 | boxRef.current = event.currentTarget.getBoundingClientRect(); 126 | renderTooltip(); 127 | tooltipTarget = selfRef.current; 128 | }, 129 | [renderTooltip], 130 | ); 131 | useEffect(() => { 132 | if (tooltipTarget === selfRef.current && boxRef.current && div.style.visibility === "visible") { 133 | renderTooltip(); 134 | } 135 | }, [renderTooltip]); 136 | 137 | const hideTooltip = useCallback(() => { 138 | div.style.visibility = "hidden"; 139 | onHideTooltip?.(); 140 | }, [onHideTooltip]); 141 | useEffect( 142 | () => () => { 143 | if (tooltipTarget === selfRef.current) { 144 | hideTooltip(); 145 | } 146 | }, 147 | [hideTooltip], 148 | ); 149 | 150 | return cloneElement(children, { 151 | onMouseEnter: showTooltip, 152 | onTouchStart: showTooltip, 153 | onMouseLeave: hideTooltip, 154 | onTouchEnd: hideTooltip, 155 | }); 156 | } 157 | -------------------------------------------------------------------------------- /src/Components/TooltipChar.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from "react"; 2 | 3 | import { 資料 } from "tshet-uinh"; 4 | 5 | import { css } from "@emotion/react"; 6 | import styled from "@emotion/styled"; 7 | 8 | import Ruby from "./Ruby"; 9 | import Tooltip from "./Tooltip"; 10 | import CustomElement from "../Classes/CustomElement"; 11 | import { noop } from "../consts"; 12 | 13 | import type { Entry } from "../consts"; 14 | 15 | const Wrapper = styled.div` 16 | padding-top: 10px; 17 | margin-top: -10px; 18 | padding-bottom: 5px; 19 | margin-bottom: -5px; 20 | overflow-wrap: break-word; 21 | white-space: pre-wrap; 22 | white-space: break-spaces; 23 | `; 24 | const Item = styled.p<{ textColor: string }>` 25 | margin: 2px 10px; 26 | &:last-child { 27 | margin-bottom: 5px; 28 | } 29 | color: ${({ textColor }) => textColor}; 30 | ${({ onClick }) => 31 | onClick && 32 | css` 33 | cursor: pointer; 34 | &:hover, 35 | &:focus { 36 | color: #0078e7; 37 | } 38 | `} 39 | `; 40 | const Missing = styled.span` 41 | &:after { 42 | content: "❬?❭"; 43 | color: #bbb; 44 | } 45 | `; 46 | const Char = styled.span` 47 | font-size: 125%; 48 | `; 49 | const RubyWrapper = styled.span<{ textColor: string }>` 50 | display: inline-block; 51 | padding: 0 3px; 52 | color: ${({ textColor }) => textColor}; 53 | `; 54 | 55 | type TooltipListener = (id: number, ch: string, 描述: string) => void; 56 | let tooltipListener: TooltipListener = noop; 57 | export function listenTooltip(listener: TooltipListener) { 58 | tooltipListener = listener; 59 | } 60 | 61 | export default function TooltipChar({ 62 | id, 63 | ch, 64 | entries, 65 | preselected, 66 | }: { 67 | id: number; 68 | ch: string; 69 | entries: Entry[]; 70 | preselected: number; 71 | }) { 72 | const [selected, setSelected] = useState(preselected); 73 | 74 | const resolved = selected !== -1; 75 | const currIndex = +resolved && selected; 76 | const { 擬音, 結果 } = entries[currIndex]; 77 | const multiple = entries.length > 1; 78 | 79 | function onClick(charIndex: number, 描述: string) { 80 | return multiple 81 | ? () => { 82 | setSelected(charIndex); 83 | tooltipListener(id, ch, 描述); 84 | } 85 | : undefined; 86 | } 87 | 88 | return ( 89 | 92 | {entries.map(({ 擬音, 結果 }, index) => ( 93 | !來源) ? "#c00" : multiple && index === currIndex ? "#00f" : "black"} 96 | onClick={onClick(index, 結果[0].音韻地位.描述)}> 97 | 98 | {CustomElement.render(擬音, ).map((item, index) => ( 99 | 100 | {!!index && / } 101 | {item} 102 | 103 | ))} 104 | 105 | {結果.map((條目, i) => { 106 | const { 字頭, 釋義 = "", 來源 = null, 音韻地位 } = 條目; 107 | const { 描述 } = 音韻地位; 108 | const 各反切 = { 109 | 反: new Set(), 110 | 切: new Set(), 111 | }; 112 | if (條目.反切) { 113 | 各反切[來源?.文獻 === "王三" ? "反" : "切"].add(條目.反切); 114 | } else { 115 | for (const { 反切, 來源 } of 資料.query音韻地位(音韻地位)) { 116 | if (!反切) continue; 117 | 各反切[來源?.文獻 === "王三" ? "反" : "切"].add(反切); 118 | } 119 | } 120 | 各反切.反 = new Set(Array.from(各反切.反).filter(反切 => !各反切.切.has(反切))); 121 | const 反切text = 122 | (["反", "切"] as const) 123 | .flatMap(x => (各反切[x].size ? [[...各反切[x]].join("/") + x] : [])) 124 | .join(" ") + " "; 125 | const 出處text = 來源 && ["廣韻", "王三"].includes(來源.文獻) ? `[${來源.文獻} ${來源.韻目}韻]` : ""; 126 | return ( 127 | 128 | {i ?
    : " "} 129 | 130 | {字頭} {描述} {反切text + 釋義 + 出處text} 131 | 132 |
    133 | ); 134 | })} 135 |
    136 | ))} 137 | 138 | }> 139 | !來源) ? "#c00" : multiple ? (resolved ? "#708" : "#00f") : "black"}> 141 | 142 | 143 |
    144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/Components/TooltipLabel.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | import Tooltip from "./Tooltip"; 4 | 5 | import type { ReactNode } from "../consts"; 6 | 7 | const Wrapper = styled.div` 8 | p { 9 | margin: 6px 12px; 10 | line-height: 1.6; 11 | white-space: pre-line; 12 | } 13 | `; 14 | 15 | const Option = styled.label` 16 | cursor: help; 17 | span:first-of-type { 18 | border-bottom: 1px dotted #aaa; 19 | } 20 | `; 21 | 22 | export default function TooltipLabel({ 23 | description, 24 | children, 25 | onHideTooltip, 26 | }: { 27 | description?: string | undefined; 28 | children: ReactNode; 29 | onHideTooltip?: (() => void) | undefined; 30 | }) { 31 | return typeof description === "string" && description ? ( 32 | 36 | {description.split(/[\n-\r\x85\u2028\u2029]+/).map((line, i) => ( 37 |

    {line}

    38 | ))} 39 | 40 | } 41 | onHideTooltip={onHideTooltip}> 42 | 43 |
    44 | ) : ( 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/Yitizi.d.ts: -------------------------------------------------------------------------------- 1 | declare module "yitizi" { 2 | export const yitiziData: { [c: string]: string }; 3 | export function get(c: string): string[]; 4 | } 5 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import ParameterSet from "./Classes/ParameterSet"; 2 | 3 | import type { MainState, SchemaState } from "./consts"; 4 | 5 | export default { 6 | addSchema: (schema: Omit) => (state: MainState) => { 7 | if (state.schemas.some(({ name }) => name === schema.name)) { 8 | return state; 9 | } 10 | return { 11 | ...state, 12 | schemas: [...state.schemas, { ...schema, parameters: ParameterSet.from(schema.input) }], 13 | activeSchemaName: schema.name, 14 | }; 15 | }, 16 | 17 | deleteSchema: (name: string) => (state: MainState) => { 18 | const schemas = [...state.schemas]; 19 | const index = schemas.findIndex(schema => schema.name === name); 20 | schemas.splice(index, 1); 21 | return { 22 | ...state, 23 | schemas, 24 | activeSchemaName: schemas.length ? schemas[index - +(index >= schemas.length)].name : "", 25 | }; 26 | }, 27 | 28 | moveSchema: (name: string, targetIndex: number) => (state: MainState) => { 29 | const { schemas } = state; 30 | const index = schemas.findIndex(schema => schema.name === name); 31 | return { 32 | ...state, 33 | schemas: 34 | targetIndex < index 35 | ? [ 36 | ...schemas.slice(0, targetIndex), 37 | schemas[index], 38 | ...schemas.slice(targetIndex, index), 39 | ...schemas.slice(index + 1), 40 | ] 41 | : [ 42 | ...schemas.slice(0, index), 43 | ...schemas.slice(index + 1, targetIndex + 1), 44 | schemas[index], 45 | ...schemas.slice(targetIndex + 1), 46 | ], 47 | }; 48 | }, 49 | 50 | renameSchema: (name: string, newName: string) => (state: MainState) => { 51 | const schemas = [...state.schemas]; 52 | const index = schemas.findIndex(schema => schema.name === name); 53 | schemas[index] = { ...schemas[index], name: newName }; 54 | return { ...state, schemas, activeSchemaName: newName }; 55 | }, 56 | 57 | setSchemaInput: (name: string, input: string) => (state: MainState) => { 58 | const schemas = [...state.schemas]; 59 | const index = schemas.findIndex(schema => schema.name === name); 60 | const newState = { ...schemas[index], input }; 61 | newState.parameters = newState.parameters?.refresh(input) || ParameterSet.from(input); 62 | schemas[index] = newState; 63 | return { ...state, schemas }; 64 | }, 65 | 66 | setSchemaParameters: (name: string, parameters: ParameterSet) => (state: MainState) => { 67 | const schemas = [...state.schemas]; 68 | const index = schemas.findIndex(schema => schema.name === name); 69 | const newState = { ...schemas[index] }; 70 | newState.parameters = parameters.refresh(newState.input); 71 | schemas[index] = newState; 72 | return { ...state, schemas }; 73 | }, 74 | 75 | resetSchemaParameters: (name: string) => (state: MainState) => { 76 | const schemas = [...state.schemas]; 77 | const index = schemas.findIndex(schema => schema.name === name); 78 | const newState = { ...schemas[index] }; 79 | newState.parameters = ParameterSet.from(newState.input); 80 | schemas[index] = newState; 81 | return { ...state, schemas }; 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | import type { CustomNode } from "./Classes/CustomElement"; 2 | import type ParameterSet from "./Classes/ParameterSet"; 3 | import type samples from "./samples"; 4 | import type { Dispatch, DispatchWithoutAction, ReactChild, ReactPortal, SetStateAction } from "react"; 5 | import type { 資料 } from "tshet-uinh"; 6 | 7 | export const tshetUinhExamplesURLPrefix = "https://cdn.jsdelivr.net/gh/nk2028/tshet-uinh-examples@main/"; 8 | // export const tshetUinhExamplesURLPrefix = "https://raw.githubusercontent.com/nk2028/tshet-uinh-examples/main/"; 9 | export const tshetUinhTextLabelURLPrefix = "https://cdn.jsdelivr.net/gh/nk2028/tshet-uinh-text-label@main/"; 10 | 11 | export const newFileTemplate = /* js */ ` 12 | /* 在此輸入描述…… 13 | * 14 | * @author your_name 15 | */ 16 | 17 | /** @type { 音韻地位['屬於'] } */ 18 | const is = (...x) => 音韻地位.屬於(...x); 19 | /** @type { 音韻地位['判斷'] } */ 20 | const when = (...x) => 音韻地位.判斷(...x); 21 | 22 | if (!音韻地位) return [ 23 | // 在此輸入方案選項…… 24 | ]; 25 | 26 | `.trimStart(); 27 | 28 | export const defaultArticle = 29 | "風(幫三C東平)煙俱淨,天山共(羣三C鍾去)色。從(從三鍾平)流飄(滂三A宵平)蕩(定開一唐上),任(日開三侵平)意東西。" + 30 | "自富陽至桐廬一百許里,奇(羣開三B支平)山異水,天下(匣開二麻上)獨絕。\n" + 31 | "水皆縹碧,千丈見(見開四先去)底。游魚細石,直視(常開三脂上)無礙。急湍(透合一寒平)甚(常開三侵上)箭,猛浪(來開一唐去)若(日開三陽入)奔(幫一魂平)。\n" + 32 | "夾岸高山,皆生(生開三庚平)寒樹(常合三虞去),負勢競上(常開三陽上),互相(心開三陽平)軒邈,爭高直指,千百成峯。" + 33 | "泉水激(見開四青入)石,泠泠作(精開一唐入)響;好(曉開一豪上)鳥相(心開三陽平)鳴,嚶嚶成韻(云合三B真去)。" + 34 | "蟬則千轉(知合三仙去)不(幫三C尤上)窮,猨則百叫無絕。" + 35 | "鳶飛戾(來開四齊去)天者,望(明三C陽去)峯息心;經(見開四青平)綸(來合三真平)世務者,窺谷(見一東入)忘(明三C陽平)反(幫三C元上)。" + 36 | "橫(匣合二庚平)柯上(常開三陽上)蔽,在(從開一咍上)晝猶(以三尤平)昏;疏(生開三魚平)條交映(影開三B庚去),有時見(見開四先去)日。"; 37 | 38 | export const codeFontFamily = ` 39 | "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", 40 | "Source Han Serif C", "Source Han Serif K", "Noto Serif CJK KR", "Source Han Serif SC", 41 | "Noto Serif CJK SC", "Source Han Serif", "Noto Serif CJK JP", "Source Han Serif TC", "Noto Serif CJK TC", 42 | "Noto Serif KR", "Noto Serif SC", "Noto Serif TC", "Jomolhari", "HanaMin", "CharisSILW", monospace, monospace`; 43 | 44 | export const options = { 45 | convertArticle: "從輸入框中讀取文章,並注音", 46 | convertPresetArticle: "為預置文章注音", 47 | exportAllPositions: "導出所有音韻地位", 48 | compareSchemas: "比較多個方案,並導出結果相異的音韻地位", 49 | exportAllSyllables: "導出所有音節", 50 | exportAllSyllablesWithCount: "導出所有音節,並計數", 51 | }; 52 | export type Option = keyof typeof options; 53 | export const allOptions = Object.entries(options) as [Option, string][]; 54 | 55 | /** Characters invalid in file names on Windows */ 56 | // eslint-disable-next-line no-control-regex 57 | export const invalidCharsRegex = /[\0-\x1f"*/:<>?\\|\x7f-\x9f]/g; 58 | 59 | export function noop() { 60 | // no operation 61 | } 62 | 63 | export type MainState = Readonly<{ 64 | schemas: SchemaState[]; 65 | article: string; 66 | option: Option; 67 | convertVariant: boolean; 68 | syncCharPosition: boolean; 69 | activeSchemaName: string; 70 | optionPanelHeight: number; 71 | }>; 72 | 73 | export type SchemaState = Readonly<{ 74 | name: string; 75 | input: string; 76 | parameters: ParameterSet; 77 | }>; 78 | 79 | export type Entry = Readonly<{ 80 | 結果: Query[]; 81 | 擬音: CustomNode[]; 82 | }>; 83 | 84 | export type Query = Readonly & Partial<資料.檢索結果>>; 85 | 86 | type Values = T extends Record ? Values : T; 87 | export type Sample = Values; 88 | export type Folder = { [name: string]: Folder | Sample }; 89 | 90 | type UseGet = { [P in K]: T }; 91 | type UseSet = { [P in `set${Capitalize}`]: Dispatch> }; 92 | type Use = UseGet & UseSet; 93 | 94 | export type UseMainState = Use<"state", MainState>; 95 | export type UseLoading = Use<"loading", boolean>; 96 | export type UseOperation = UseGet<"operation", number> & { increaseOperation: DispatchWithoutAction }; 97 | export type UseSetSyncedArticle = UseSet<"syncedArticle", string[]>; 98 | 99 | type ReactFragment = Iterable; // No {} !!! 100 | export type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined; 101 | -------------------------------------------------------------------------------- /src/editor/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "monaco-editor/esm/vs/language/typescript/tsMode" { 2 | export class SuggestAdapter { 3 | provideCompletionItems( 4 | model: import("monaco-editor").editor.ITextModel, 5 | position: import("monaco-editor").Position, 6 | _context: import("monaco-editor").languages.CompletionContext, 7 | token: import("monaco-editor").CancellationToken, 8 | ): Promise; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/editor/initialize.ts: -------------------------------------------------------------------------------- 1 | import { languages } from "monaco-editor"; 2 | import tsWorkerUrl from "monaco-editor/esm/vs/language/typescript/ts.worker.js?worker&url"; 3 | import { SuggestAdapter } from "monaco-editor/esm/vs/language/typescript/tsMode"; 4 | 5 | // NOTE Workaround for cross-origin loading. 6 | // See: https://github.com/vitejs/vite/issues/13680#issuecomment-1819274694 7 | // Normally we could just import the worker like `import TSWorker from "...?worker";` and then `new TSWorker()`, 8 | // but it doesn't work if the assets are deployed on a different site. 9 | const tsWorkerWrapperScript = `import ${JSON.stringify(new URL(tsWorkerUrl, import.meta.url))}`; 10 | const tsWorkerWrapperBlob = new Blob([tsWorkerWrapperScript], { type: "text/javascript" }); 11 | 12 | self.MonacoEnvironment = { 13 | getWorker() { 14 | return new Worker(URL.createObjectURL(tsWorkerWrapperBlob), { type: "module" }); 15 | }, 16 | }; 17 | 18 | // https://github.com/microsoft/monaco-editor/issues/1077 19 | const removeItems: Record = { 20 | globalThis: languages.CompletionItemKind.Module, 21 | eval: languages.CompletionItemKind.Function, 22 | decodeURI: languages.CompletionItemKind.Function, 23 | decodeURIComponent: languages.CompletionItemKind.Function, 24 | encodeURI: languages.CompletionItemKind.Function, 25 | encodeURIComponent: languages.CompletionItemKind.Function, 26 | escape: languages.CompletionItemKind.Function, 27 | unescape: languages.CompletionItemKind.Function, 28 | debugger: languages.CompletionItemKind.Keyword, 29 | }; 30 | 31 | const sortOrderChanges: Record = { 32 | null: [languages.CompletionItemKind.Keyword, "*6"], 33 | undefined: [languages.CompletionItemKind.Variable, "*6"], 34 | NaN: [languages.CompletionItemKind.Variable, "*6"], 35 | parseInt: [languages.CompletionItemKind.Function, "*5"], 36 | parseFloat: [languages.CompletionItemKind.Function, "*5"], 37 | isFinite: [languages.CompletionItemKind.Function, "*5"], 38 | isNaN: [languages.CompletionItemKind.Function, "*5"], 39 | }; 40 | 41 | const sortOrder: Partial> = { 42 | [languages.CompletionItemKind.Property]: "*1", 43 | [languages.CompletionItemKind.Function]: "*2", 44 | [languages.CompletionItemKind.Field]: "*2", 45 | [languages.CompletionItemKind.File]: "*3", 46 | [languages.CompletionItemKind.Keyword]: "*4", 47 | [languages.CompletionItemKind.Variable]: "*7", 48 | [languages.CompletionItemKind.Module]: "*7", 49 | }; 50 | 51 | const originalProvideCompletionItems = SuggestAdapter.prototype.provideCompletionItems; 52 | 53 | SuggestAdapter.prototype.provideCompletionItems = async function (...args) { 54 | let result: languages.CompletionList | undefined; 55 | try { 56 | result = await originalProvideCompletionItems.apply(this, args); 57 | } catch { 58 | // ignored 59 | } 60 | if (!result) result = { suggestions: [] }; 61 | result.suggestions = result.suggestions.flatMap(item => { 62 | let { label } = item; 63 | label = typeof label === "string" ? label : label.label; 64 | const sortText = 65 | label in sortOrderChanges && sortOrderChanges[label][0] === item.kind 66 | ? sortOrderChanges[label][1] 67 | : sortOrder[item.kind]; 68 | if (sortText) item.sortText = sortText; 69 | return removeItems[label] === item.kind ? [] : [item]; 70 | }); 71 | // console.table(result.suggestions.map(({ label, kind, sortText }) => ({ label, kind, sortText }))); 72 | return result; 73 | }; 74 | -------------------------------------------------------------------------------- /src/editor/libs.ts: -------------------------------------------------------------------------------- 1 | import tshetUinhDts from "tshet-uinh/index.d.ts?raw"; 2 | 3 | import globalDts from "./types.d.ts?raw"; 4 | 5 | export default { 6 | "tshet-uinh.d.ts": tshetUinhDts, 7 | "global.d.ts": globalDts, 8 | }; 9 | -------------------------------------------------------------------------------- /src/editor/setup.ts: -------------------------------------------------------------------------------- 1 | import * as monaco from "monaco-editor"; 2 | import TshetUinh from "tshet-uinh"; 3 | 4 | import { loader } from "@monaco-editor/react"; 5 | 6 | import "./initialize"; 7 | import libs from "./libs"; 8 | 9 | declare global { 10 | interface Window { 11 | monaco: typeof monaco; 12 | TshetUinh: typeof TshetUinh; 13 | } 14 | } 15 | 16 | self.monaco = monaco; 17 | self.TshetUinh = TshetUinh; 18 | 19 | loader.config({ monaco }); 20 | 21 | const defaults = monaco.languages.typescript.javascriptDefaults; 22 | defaults.setCompilerOptions({ 23 | target: monaco.languages.typescript.ScriptTarget.ESNext, 24 | allowJs: true, 25 | allowNonTsExtensions: true, 26 | baseUrl: "./", 27 | lib: ["esnext"], 28 | }); 29 | defaults.setExtraLibs(Object.entries(libs).map(([filePath, content]) => ({ filePath, content }))); 30 | 31 | document.fonts.ready.then(() => monaco.editor.remeasureFonts()); 32 | -------------------------------------------------------------------------------- /src/editor/types.d.ts: -------------------------------------------------------------------------------- 1 | import TshetUinh from "tshet-uinh"; 2 | 3 | import type { SchemaFromRequire } from "../evaluate"; 4 | 5 | type TshetUinh = typeof TshetUinh; 6 | type 音韻地位 = TshetUinh.音韻地位; 7 | 8 | declare global { 9 | const TshetUinh: TshetUinh; 10 | const 音韻地位: 音韻地位; 11 | const 字頭: string | null; 12 | const 選項: Record; 13 | const require: (sample: string) => SchemaFromRequire; 14 | } 15 | -------------------------------------------------------------------------------- /src/evaluate.ts: -------------------------------------------------------------------------------- 1 | import { 推導方案 } from "tshet-uinh-deriver-tools"; 2 | 3 | import { Formatter } from "./Classes/CustomElement"; 4 | import { tshetUinhTextLabelURLPrefix } from "./consts"; 5 | import { evaluateOption, getArticle, setArticle } from "./options"; 6 | import { fetchFile, isArray, normalizeFileName, notifyError } from "./utils"; 7 | 8 | import type { CustomNode, NestedCustomNode } from "./Classes/CustomElement"; 9 | import type { MainState, ReactNode } from "./consts"; 10 | import type { 音韻地位 } from "tshet-uinh"; 11 | import type { 原始推導函數, 推導函數 } from "tshet-uinh-deriver-tools"; 12 | 13 | type Require = (音韻地位: 音韻地位, 字頭?: string | null) => RequireFunction; 14 | type RequireFunction = (sample: string) => SchemaFromRequire; 15 | 16 | type NestedStringNode = string | readonly NestedStringNode[]; 17 | type DeriveResult = NestedStringNode | ((formatter: Formatter) => NestedCustomNode); 18 | 19 | export function rawDeriverFrom(input: string): 原始推導函數 { 20 | return new Function("選項", "音韻地位", "字頭", "require", input) as 原始推導函數; 21 | } 22 | 23 | function formatResult(result: DeriveResult): CustomNode { 24 | const node = typeof result === "function" ? result(Formatter) : result; 25 | return isArray(node) ? Formatter.f(node) : node; 26 | } 27 | 28 | export default async function evaluate(state: MainState): Promise { 29 | const { schemas, option } = state; 30 | 31 | if (option === "convertPresetArticle" && !getArticle()) 32 | setArticle(await fetchFile(tshetUinhTextLabelURLPrefix + "index.txt")); 33 | else if (option === "compareSchemas" && schemas.length < 2) throw notifyError("此選項需要兩個或以上方案"); 34 | else await new Promise(resolve => setTimeout(resolve)); 35 | 36 | try { 37 | const derivers: [derive: 推導函數, require: Require][] = schemas.map( 38 | ({ name, input, parameters }) => { 39 | const schema = new 推導方案(rawDeriverFrom(input)); 40 | const options = parameters.pack(); 41 | return [schema(options), require(name)]; 42 | }, 43 | ); 44 | return evaluateOption[option](state, (地位, 字頭) => 45 | derivers.map(([derive, require]) => formatResult(derive(地位, 字頭, require(地位, 字頭)))), 46 | ); 47 | } catch (err) { 48 | throw notifyError("程式碼錯誤", err); 49 | } 50 | 51 | function require(current: string, references: string[] = []): Require { 52 | const newReferences = references.concat(current); 53 | if (references.includes(current)) throw notifyError("Circular reference detected: " + newReferences.join(" -> ")); 54 | return (音韻地位, 字頭) => sample => { 55 | const schema = schemas.find(({ name }) => name === normalizeFileName(sample)); 56 | if (!schema) throw notifyError("Schema not found"); 57 | return new SchemaFromRequire(rawDeriverFrom(schema.input), require(sample, newReferences), 音韻地位, 字頭); 58 | }; 59 | } 60 | } 61 | 62 | export class SchemaFromRequire { 63 | private _schema: 推導方案; 64 | constructor( 65 | rawDeriver: 原始推導函數, 66 | private _require: Require, 67 | private _音韻地位: 音韻地位, 68 | private _字頭: string | null = null, 69 | ) { 70 | this._schema = new 推導方案(rawDeriver); 71 | } 72 | 73 | derive(音韻地位: 音韻地位, 字頭: string | null = null, 選項?: Readonly>) { 74 | return this._schema(選項)(音韻地位, 字頭, this._require(音韻地位, 字頭)); 75 | } 76 | 77 | deriveThis(選項?: Readonly>) { 78 | return this.derive(this._音韻地位, this._字頭, 選項); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import App from "./Components/App"; 5 | 6 | const root = createRoot(document.getElementById("root")!); 7 | root.render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | import { 資料, 音韻地位 } from "tshet-uinh"; 4 | import Yitizi from "yitizi"; 5 | 6 | import styled from "@emotion/styled"; 7 | 8 | import CustomElement from "./Classes/CustomElement"; 9 | import Ruby from "./Components/Ruby"; 10 | import Table from "./Components/Table"; 11 | import TooltipChar from "./Components/TooltipChar"; 12 | import { noop } from "./consts"; 13 | 14 | import type { CustomNode } from "./Classes/CustomElement"; 15 | import type { Entry, MainState, Option, SchemaState, ReactNode } from "./consts"; 16 | 17 | const Title = styled.h3` 18 | padding: 0 0 1rem 0.25rem; 19 | `; 20 | 21 | const 所有地位 = Array.from(資料.iter音韻地位()); 22 | type Deriver = (音韻地位: 音韻地位, 字頭?: string | null) => CustomNode[]; 23 | type Handler = (state: MainState, callDeriver: Deriver) => ReactNode; 24 | 25 | function title(schemas: SchemaState[]) { 26 | return schemas.map(({ name }) => name); 27 | } 28 | 29 | function serialize(callDeriver: Deriver): [string, CustomNode[]][] { 30 | return 所有地位.map(音韻地位 => callDeriver(音韻地位)).map(擬音陣列 => [CustomElement.stringify(擬音陣列), 擬音陣列]); 31 | } 32 | 33 | function iterate(callDeriver: Deriver) { 34 | return 所有地位.map(音韻地位 => { 35 | const 各條目 = 資料.query音韻地位(音韻地位); 36 | const 代表字 = 各條目.find(({ 來源 }) => 來源?.文獻 === "廣韻")?.字頭 ?? 各條目[0]?.字頭; 37 | return { 38 | 描述: 音韻地位.描述, 39 | 擬音陣列: callDeriver(音韻地位), 40 | 代表字, 41 | }; 42 | }); 43 | } 44 | 45 | function finalize(result: ReturnType) { 46 | return result.map(({ 描述, 擬音陣列, 代表字 }) => [描述, ...wrap(擬音陣列), 代表字 || ""]); 47 | } 48 | 49 | function wrap(擬音陣列: CustomNode[]) { 50 | return CustomElement.render(擬音陣列).map((擬音, index) => ( 51 | 52 | {擬音} 53 | 54 | )); 55 | } 56 | 57 | let presetArticle = ""; 58 | 59 | export function getArticle() { 60 | return presetArticle; 61 | } 62 | 63 | export function setArticle(article: string) { 64 | presetArticle = article; 65 | } 66 | 67 | type ArticleListener = (syncedArticle: string[]) => void; 68 | let articleListener: ArticleListener = noop; 69 | export function listenArticle(listener: ArticleListener) { 70 | articleListener = listener; 71 | } 72 | 73 | export const evaluateOption: Record = { 74 | convertArticle({ article, convertVariant }, callDeriver) { 75 | const syncedArticle: string[] = []; 76 | const result: ReactNode[] = []; 77 | const chs = Array.from(article); 78 | 79 | for (let i = 0; i < chs.length; i++) { 80 | let pushed = false; 81 | const ch = chs[i]; 82 | const 所有異體字 = [ch, null].concat(Yitizi.get(ch)); 83 | const entries: Entry[] = []; 84 | let preselected = -1; 85 | 86 | for (const 字頭 of 所有異體字) { 87 | if (!字頭) { 88 | if (convertVariant) continue; 89 | if (!entries.length) continue; 90 | break; 91 | } 92 | for (const 條目 of 資料.query字頭(字頭)) { 93 | const { 音韻地位 } = 條目; 94 | const 擬音 = callDeriver(音韻地位, 字頭); 95 | let entry = entries.find(key => CustomElement.isEqual(key.擬音, 擬音)); 96 | if (!entry) entries.push((entry = { 擬音, 結果: [] })); 97 | entry.結果.push(條目); 98 | } 99 | } 100 | 101 | if (chs[i + 1] === "(") { 102 | let j = i; 103 | while (chs[++j] !== ")" && j < chs.length); 104 | 105 | if (j < chs.length) { 106 | const 描述 = chs.slice(i + 2, j).join(""); 107 | const 地位 = (() => { 108 | try { 109 | return 音韻地位.from描述(描述, true); 110 | } catch { 111 | return undefined; 112 | } 113 | })(); 114 | if (地位) { 115 | preselected = entries.findIndex(({ 結果 }) => 結果.some(({ 音韻地位 }) => 音韻地位.等於(地位))); 116 | if (preselected === -1) { 117 | const 擬音 = callDeriver(地位, ch); 118 | preselected = entries.findIndex(key => CustomElement.isEqual(key.擬音, 擬音)); 119 | if (preselected === -1) preselected = entries.push({ 擬音, 結果: [] }) - 1; 120 | entries[preselected].結果.push({ 字頭: ch, 釋義: "", 音韻地位: 地位 }); 121 | } 122 | syncedArticle.push(chs.slice(i, j + 1).join("")); 123 | i = j; 124 | pushed = true; 125 | } 126 | } 127 | } 128 | if (!pushed) syncedArticle.push(chs[i]); 129 | const id = syncedArticle.length - 1; 130 | result.push(entries.length ? : ch); 131 | } 132 | articleListener(syncedArticle); 133 | return result; 134 | }, 135 | 136 | convertPresetArticle(_, callDeriver) { 137 | return presetArticle.split("\n\n").map((passage, index) => ( 138 | 139 | {passage.split("\n").map((line, index) => { 140 | const output: ReactNode[] = []; 141 | const chs = Array.from(line); 142 | 143 | for (let i = 0; i < chs.length; i++) { 144 | if (chs[i + 1] === "(") { 145 | const j = i; 146 | while (chs[++i] !== ")" && i < chs.length); 147 | 148 | const 字頭 = chs[j]; 149 | const 描述 = chs.slice(j + 2, i).join(""); 150 | const 地位 = 音韻地位.from描述(描述); 151 | const 擬音 = callDeriver(地位, 字頭); 152 | 153 | output.push(); 154 | } else output.push(chs[i]); 155 | } 156 | 157 | const Tag = index ? "p" : "h3"; 158 | return ( 159 | 160 | {output} 161 | 162 | 163 | ); 164 | })} 165 | 166 | 167 | )); 168 | }, 169 | 170 | exportAllPositions({ schemas }, callDeriver) { 171 | return ; 172 | }, 173 | 174 | exportAllSyllables({ schemas }, callDeriver) { 175 | return
    ; 176 | }, 177 | 178 | exportAllSyllablesWithCount({ schemas }, callDeriver) { 179 | type Data = [serialized: string, 擬音陣列: CustomNode[], count: number]; 180 | const result: Data[] = []; 181 | serialize(callDeriver) 182 | .sort(([a], [b]) => +(a > b) || -(a < b)) 183 | .reduce((previous, [serialized, 擬音陣列]) => { 184 | if (previous && previous[0] === serialized) { 185 | previous[2]++; 186 | return previous; 187 | } 188 | const temp: Data = [serialized, 擬音陣列, 1]; 189 | result.push(temp); 190 | return temp; 191 | }, null); 192 | return ( 193 |
    b[2] - a[2]).map(([, 擬音陣列, count]) => [...wrap(擬音陣列), count + ""])} 196 | /> 197 | ); 198 | }, 199 | 200 | compareSchemas({ schemas }, callDeriver) { 201 | const result = iterate(callDeriver).filter(({ 擬音陣列 }) => 202 | 擬音陣列.some(擬音 => !CustomElement.isEqual(擬音, 擬音陣列[0])), 203 | ); 204 | return result.length ? ( 205 | <> 206 | 207 | 找到 {result.length} 個相異項目。 208 | <span hidden>{"\n\n"}</span> 209 | 210 |
    211 | 212 | ) : ( 213 |

    方案推導結果相同。

    214 | ); 215 | }, 216 | }; 217 | -------------------------------------------------------------------------------- /src/samples.ts: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | export default { 3 | "直接標註音韻地位(及相關屬性)": "position", 4 | "切韻音系拼音或轉寫": { 5 | "切韻拼音": "tupa", 6 | "白一平轉寫": "baxter", 7 | }, 8 | "切韻音系擬音": { 9 | "高本漢擬音": "karlgren", 10 | "王力擬音": "wangli", 11 | "潘悟雲擬音": "panwuyun", 12 | "unt 擬音": "unt", 13 | "msoeg 擬音": "msoeg_v8", 14 | }, 15 | "推導後世音系": { 16 | "推導盛唐(平水韻)擬音": "high_tang", 17 | "推導中唐(韻圖)擬音": "mid_tang", 18 | "推導北宋(聲音唱和圖)擬音": "n_song", 19 | "推導《蒙古字韻》": "mongol", 20 | "推導《中原音韻》擬音": "zhongyuan", 21 | }, 22 | "現代方言推導音": { 23 | "推導普通話": "putonghua", 24 | "推導廣州話": "gwongzau", 25 | "推導上海話": "zaonhe", 26 | }, 27 | "人造音系": { 28 | "綾香思考音系": "ayaka_v8", 29 | "不通話": "yec_en_hua", 30 | }, 31 | } as const; 32 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import ParameterSet from "./Classes/ParameterSet"; 2 | import { defaultArticle } from "./consts"; 3 | 4 | import type { MainState } from "./consts"; 5 | 6 | export const stateStorageLocation = "autoderiver/0.2/state"; 7 | 8 | function defaultState(): MainState { 9 | return { 10 | schemas: [], 11 | article: defaultArticle, 12 | option: "convertArticle", 13 | convertVariant: false, 14 | syncCharPosition: true, 15 | activeSchemaName: "", 16 | optionPanelHeight: 0.5, 17 | }; 18 | } 19 | 20 | export default function initialState(): MainState { 21 | const state = localStorage.getItem(stateStorageLocation); 22 | if (state) { 23 | const result: MainState = JSON.parse(state); 24 | return { 25 | ...defaultState(), 26 | ...result, 27 | schemas: result.schemas.map(schema => ({ 28 | ...schema, 29 | parameters: ParameterSet.from(schema.input, schema.parameters as unknown as Record).combine( 30 | schema.parameters, 31 | ), 32 | })), 33 | }; 34 | } 35 | return defaultState(); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { css as stylesheet } from "@emotion/css"; 2 | import styled from "@emotion/styled"; 3 | 4 | import Swal from "./Classes/SwalReact"; 5 | import Spinner from "./Components/Spinner"; 6 | 7 | import type { SyntheticEvent } from "react"; 8 | import type { SweetAlertOptions } from "sweetalert2"; 9 | 10 | export function isArray(arg: unknown): arg is readonly unknown[] { 11 | return Array.isArray(arg); 12 | } 13 | 14 | export function isTemplateStringsArray(arg: unknown): arg is TemplateStringsArray { 15 | return isArray(arg) && "raw" in arg && isArray(arg.raw); 16 | } 17 | 18 | const LoadModal = styled.div` 19 | margin-top: 3rem; 20 | color: #bbb; 21 | `; 22 | 23 | export function showLoadingModal(abortController: AbortController) { 24 | Swal.fire({ 25 | html: ( 26 | 27 | 28 |

    正在載入方案……

    29 |
    30 | ), 31 | allowOutsideClick: false, 32 | allowEscapeKey: false, 33 | showConfirmButton: false, 34 | showCancelButton: true, 35 | cancelButtonText: "取消", 36 | }).then(result => result.dismiss === Swal.DismissReason.cancel && abortController.abort()); 37 | } 38 | 39 | const errorModal = stylesheet` 40 | width: 60vw; 41 | display: block !important; 42 | p { 43 | margin: 0; 44 | } 45 | pre { 46 | text-align: left; 47 | overflow: auto; 48 | max-height: calc(max(100vh - 24em, 7em)); 49 | } 50 | `; 51 | 52 | export function notifyError(msg: string, err?: unknown) { 53 | let technical: string | null = null; 54 | if (typeof err === "string") { 55 | technical = err; 56 | } else if (err instanceof Error) { 57 | technical = err.message; 58 | let curErr: Error = err; 59 | while (curErr.cause instanceof Error) { 60 | curErr = curErr.cause; 61 | technical += "\n" + curErr.message; 62 | } 63 | if (curErr.stack) { 64 | technical += "\n\n" + curErr.stack; 65 | } 66 | } 67 | const config: SweetAlertOptions = { 68 | icon: "error", 69 | title: "錯誤", 70 | text: msg, 71 | confirmButtonText: "確定", 72 | }; 73 | if (technical !== null) { 74 | config.customClass = errorModal; 75 | config.html = ( 76 | <> 77 |

    {msg}

    78 |
    {technical}
    79 | 80 | ); 81 | } else { 82 | config.text = msg; 83 | } 84 | Swal.fire(config); 85 | return new Error(msg, err instanceof Error ? { cause: err } : {}); 86 | } 87 | 88 | export async function fetchFile(href: string | URL, signal: AbortSignal | null = null) { 89 | try { 90 | const response = await fetch(href, { cache: "no-cache", signal }); 91 | const text = await response.text(); 92 | if (!response.ok) throw new Error(text); 93 | return text; 94 | } catch (err) { 95 | throw signal?.aborted ? err : notifyError("載入檔案失敗", err); 96 | } 97 | } 98 | 99 | export function normalizeFileName(name: string) { 100 | return name.replace(/\.js$/, "").trim(); 101 | } 102 | 103 | export function memoize(fn: (arg: T) => R) { 104 | const results: Record = {}; 105 | return (arg: T) => { 106 | if (arg in results) return results[arg]; 107 | return (results[arg] = fn(arg)); 108 | }; 109 | } 110 | 111 | export async function settleAndGroupPromise(values: Iterable>) { 112 | const settledResults = await Promise.allSettled(values); 113 | const returnResults: { fulfilled: T[]; rejected: unknown[] } = { fulfilled: [], rejected: [] }; 114 | for (const result of settledResults) { 115 | if (result.status === "fulfilled") { 116 | returnResults.fulfilled.push(result.value); 117 | } else { 118 | returnResults.rejected.push(result.reason); 119 | } 120 | } 121 | return returnResults; 122 | } 123 | 124 | export function displaySchemaLoadingErrors(errors: unknown[], nSchemas: number) { 125 | if (errors.length > 1) { 126 | notifyError(`${errors.length} 個方案無法載入`, new AggregateError(errors)); 127 | } else if (errors.length === 1) { 128 | notifyError(nSchemas === 1 ? "無法載入方案" : "1 個方案無法載入", errors[0]); 129 | } 130 | } 131 | 132 | export function stopPropagation(event: Event | SyntheticEvent) { 133 | event.stopPropagation(); 134 | } 135 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const __APP_VERSION__: string; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "moduleDetection": "force", 8 | 9 | "strict": true, 10 | "allowUnusedLabels": false, 11 | "allowUnreachableCode": false, 12 | "noImplicitOverride": true, 13 | "noImplicitReturns": true, 14 | "noPropertyAccessFromIndexSignature": true, 15 | "exactOptionalPropertyTypes": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "isolatedModules": true, 19 | "verbatimModuleSyntax": true, 20 | 21 | "esModuleInterop": true, 22 | "resolveJsonModule": true, 23 | "useDefineForClassFields": true, 24 | "skipLibCheck": true, 25 | "incremental": true, 26 | "noEmit": true, 27 | "jsx": "react-jsx" 28 | }, 29 | "include": ["./src"] 30 | } 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | 3 | import type { UserConfig } from "vite"; 4 | 5 | const base = "/tshet-uinh-autoderiver/"; 6 | 7 | export function makeConfig(assetLocation = base) { 8 | if (!assetLocation.endsWith("/")) { 9 | assetLocation += "/"; 10 | } 11 | return { 12 | plugins: [ 13 | react({ 14 | babel: { 15 | plugins: ["@emotion"], 16 | }, 17 | }), 18 | ], 19 | base, 20 | define: { 21 | // For showing the version number on the page 22 | __APP_VERSION__: JSON.stringify(process.env.npm_package_version), 23 | }, 24 | build: { 25 | target: "esnext", 26 | chunkSizeWarningLimit: 8192, 27 | outDir: "build", 28 | }, 29 | experimental: { 30 | renderBuiltUrl(filename, type) { 31 | //console.log("#[", filename, type, "]#"); 32 | if (type.hostId === "index.html") { 33 | // Rewrite asset URL in index.html 34 | return assetLocation + filename; 35 | } else { 36 | // Use relative paths in other assets 37 | return { relative: true }; 38 | } 39 | }, 40 | }, 41 | } satisfies UserConfig; 42 | } 43 | 44 | export default makeConfig(); 45 | -------------------------------------------------------------------------------- /vite.cos.config.ts: -------------------------------------------------------------------------------- 1 | import { makeConfig } from "./vite.config"; 2 | 3 | // Same as base config but prefix each asset path in index.html with this location 4 | export default makeConfig("https://autoderiver-1305783649.cos.accelerate.myqcloud.com/"); 5 | --------------------------------------------------------------------------------