├── .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 | -
378 |
379 | Ctrl+`
380 | {" "}
381 | 或{" "}
382 |
383 | Cmd+`
384 | {" "}
385 | (
386 |
387 | ⌘
388 | `
389 |
390 | ):隱藏或顯示推導操作面板
391 |
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 |
294 |
295 |
296 |
297 | 遙
298 |
299 |
300 |
301 |
302 | 襟
303 |
304 |
305 |
306 |
307 | 甫
308 |
309 |
310 |
311 |
312 | 暢
313 |
314 |
315 |
316 |
317 |
318 | eu kim pu tyang, it kyong sen pi.
319 |
320 |
321 |
322 |
323 | 作者 |
324 | nk2028 |
325 |
326 |
327 | 版本 |
328 | 0.0.0 |
329 |
330 |
331 | 日期 |
332 | 2028-01-01 |
333 |
334 |
335 |
336 |
337 |
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 |
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 | 推導結果
343 | {!loading && (
344 | <>
345 |
346 |
347 |
348 | 全部複製
349 |
350 |
351 |
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 |
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 |
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 | {item} |
13 | {index < head.length - 1 ? "\t" : "\n"} |
14 |
15 | ))}
16 |
17 |
18 |
19 | {body.map((row, i) => (
20 |
21 | {row.map((item, index) => (
22 |
23 | {item} |
24 | {index < row.length - 1 ? "\t" : "\n"} |
25 |
26 | ))}
27 |
28 | ))}
29 |
30 |
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