├── .clang-format ├── bun.lockb ├── .dockerignore ├── .gitmodules ├── .gitignore ├── src ├── index.tsx ├── CaretFollower.tsx ├── ThemeSwitcher.tsx ├── utils.ts ├── Inputs.tsx ├── Toolbar.tsx ├── DictionaryPanel.tsx ├── hooks.ts ├── rime.ts ├── types.ts ├── App.tsx ├── Candidate.tsx ├── CandidateInfo.ts ├── consts.ts ├── worker.ts ├── Preferences.tsx ├── index.css └── CandidatePanel.tsx ├── scripts ├── build_schema.ts ├── utils.ts ├── prepare_boost.ts ├── build_wasm.ts ├── build_lib.ts └── build_native.ts ├── patches ├── librime.patch ├── leveldb.patch ├── marisa.patch ├── opencc.patch └── glog.patch ├── vite.config.ts ├── Dockerfile ├── tsconfig.json ├── .github └── workflows │ ├── release.yml │ └── build.yml ├── LICENSE ├── package.json ├── dprint.jsonc ├── index.html ├── README.md ├── .eslintrc.json ├── tailwind.config.ts └── wasm └── api.cpp /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Chromium 2 | SortIncludes: false 3 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TypeDuck-HK/TypeDuck-Web/HEAD/bun.lockb -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | .rollup.cache 5 | .vercel 6 | tsconfig.tsbuildinfo 7 | 8 | node_modules 9 | build 10 | dist 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "librime"] 2 | path = librime 3 | url = https://github.com/TypeDuck-HK/librime.git 4 | [submodule "schema"] 5 | path = schema 6 | url = https://github.com/TypeDuck-HK/schema.git 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | !.vscode/extensions.json 4 | .idea 5 | .rollup.cache 6 | .vercel 7 | tsconfig.tsbuildinfo 8 | 9 | node_modules 10 | boost-*.tar.xz 11 | boost 12 | build 13 | public 14 | dist 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import "react-toastify/dist/ReactToastify.css"; 3 | 4 | import App from "./App"; 5 | import "./index.css"; 6 | 7 | createRoot(document.getElementById("root")!).render(); 8 | -------------------------------------------------------------------------------- /scripts/build_schema.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | import { cwd } from "process"; 3 | 4 | const root = cwd(); 5 | $.cwd("schema"); 6 | await $`rm -rf build default.custom.yaml`; 7 | await $`${root}/build/librime_native/bin/rime_api_console --build`; 8 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | import { cwd } from "process"; 3 | 4 | const root = cwd(); 5 | export async function patch(patchFile: string, path?: string) { 6 | if (path) $.cwd(path); 7 | if (!await $`git status --porcelain -uno --ignore-submodules`.text()) { 8 | await $`git apply ${root}/patches/${patchFile}`; 9 | } 10 | if (path) $.cwd(); 11 | } 12 | -------------------------------------------------------------------------------- /patches/librime.patch: -------------------------------------------------------------------------------- 1 | diff --git a/include/darts.h b/include/darts.h 2 | index dd73cf1b..69a7328a 100644 3 | --- a/include/darts.h 4 | +++ b/include/darts.h 5 | @@ -16,7 +16,7 @@ 6 | #define DARTS_LINE_TO_STR(line) DARTS_INT_TO_STR(line) 7 | #define DARTS_LINE_STR DARTS_LINE_TO_STR(__LINE__) 8 | #define DARTS_THROW(msg) throw Darts::Details::Exception( \ 9 | - __FILE__ ":" DARTS_LINE_STR ": exception: " msg) 10 | + __FILE_NAME__ ":" DARTS_LINE_STR ": exception: " msg) 11 | 12 | namespace Darts { 13 | 14 | -------------------------------------------------------------------------------- /patches/leveldb.patch: -------------------------------------------------------------------------------- 1 | diff --git a/util/env_posix.cc b/util/env_posix.cc 2 | index d84cd1e..773c8cd 100644 3 | --- a/util/env_posix.cc 4 | +++ b/util/env_posix.cc 5 | @@ -781,6 +781,7 @@ PosixEnv::PosixEnv() 6 | void PosixEnv::Schedule( 7 | void (*background_work_function)(void* background_work_arg), 8 | void* background_work_arg) { 9 | + return background_work_function(background_work_arg); 10 | background_work_mutex_.Lock(); 11 | 12 | // Start the background thread, if we haven't done so already. 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import autoprefixer from "autoprefixer"; 3 | import postCSSNesting from "postcss-nesting"; 4 | import tailwindcss from "tailwindcss"; 5 | import tailwindcssNesting from "tailwindcss/nesting"; 6 | 7 | import type { UserConfig } from "vite"; 8 | 9 | export default { 10 | base: "/web/", 11 | plugins: [react()], 12 | css: { 13 | postcss: { 14 | plugins: [ 15 | tailwindcssNesting(postCSSNesting({ edition: "2024-02" })), 16 | tailwindcss(), 17 | autoprefixer(), 18 | ], 19 | }, 20 | }, 21 | build: { 22 | target: "es2017", 23 | }, 24 | } satisfies UserConfig; 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM emscripten/emsdk as emsdk 2 | FROM node:bookworm as builder 3 | 4 | ARG ENABLE_LOGGING=ON 5 | ENV ENABLE_LOGGING ${ENABLE_LOGGING} 6 | 7 | RUN apt update 8 | RUN apt upgrade -y 9 | RUN apt install -y \ 10 | cmake \ 11 | ninja-build \ 12 | libboost-dev \ 13 | libboost-regex-dev \ 14 | libyaml-cpp-dev \ 15 | libleveldb-dev \ 16 | libmarisa-dev \ 17 | libopencc-dev 18 | 19 | COPY --from=emsdk /emsdk /emsdk 20 | ENV PATH ${PATH}:/emsdk/upstream/emscripten 21 | 22 | COPY / /TypeDuck-Web 23 | WORKDIR /TypeDuck-Web 24 | 25 | RUN npm i -g bun 26 | RUN bun i 27 | RUN bun run boost 28 | RUN bun run native 29 | RUN bun run schema 30 | RUN bun run lib 31 | RUN bun run wasm 32 | RUN bun run build 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["ESNext", "DOM", "DOM.Iterable", "WebWorker"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "moduleDetection": "force", 8 | 9 | "strict": true, 10 | "allowUnusedLabels": false, 11 | "allowUnreachableCode": false, 12 | "noImplicitOverride": true, 13 | "noImplicitReturns": true, 14 | "noPropertyAccessFromIndexSignature": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "isolatedModules": true, 17 | "verbatimModuleSyntax": true, 18 | 19 | "esModuleInterop": true, 20 | "resolveJsonModule": true, 21 | "useDefineForClassFields": true, 22 | "skipLibCheck": true, 23 | "incremental": true, 24 | "noEmit": true, 25 | "jsx": "react-jsx" 26 | }, 27 | "include": ["src", "scripts", "*.config.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /patches/marisa.patch: -------------------------------------------------------------------------------- 1 | diff --git a/include/marisa/exception.h b/include/marisa/exception.h 2 | index 508c6b8..7f17c0a 100644 3 | --- a/include/marisa/exception.h 4 | +++ b/include/marisa/exception.h 5 | @@ -62,8 +62,8 @@ class Exception : public std::exception { 6 | // code and an error message. The message format is as follows: 7 | // "__FILE__:__LINE__: error_code: error_message" 8 | #define MARISA_THROW(error_code, error_message) \ 9 | - (throw marisa::Exception(__FILE__, __LINE__, error_code, \ 10 | - __FILE__ ":" MARISA_LINE_STR ": " #error_code ": " error_message)) 11 | + (throw marisa::Exception(__FILE_NAME__, __LINE__, error_code, \ 12 | + __FILE_NAME__ ":" MARISA_LINE_STR ": " #error_code ": " error_message)) 13 | 14 | // MARISA_THROW_IF throws an exception if `condition' is true. 15 | #define MARISA_THROW_IF(condition, error_code) \ 16 | -------------------------------------------------------------------------------- /scripts/prepare_boost.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | import { exists } from "fs/promises"; 3 | import { platform } from "os"; 4 | 5 | const PLATFORM = platform(); 6 | function bash(command: string) { 7 | return PLATFORM === "win32" ? $`bash -c ${command}` : $`${{ raw: command }}`; 8 | } 9 | 10 | const includeDir = "build/sysroot/usr/include"; 11 | const boostVersion = "1.85.0"; 12 | const archiveName = `boost-${boostVersion}-cmake.tar.xz`; 13 | 14 | if (!await exists(archiveName)) { 15 | await Bun.write(archiveName, await fetch(`https://github.com/boostorg/boost/releases/download/boost-${boostVersion}/${archiveName}`)); 16 | await $`rm -rf boost`; 17 | } 18 | if (!await exists("boost")) { 19 | await bash(`tar -xvf ${archiveName}`); 20 | await $`mv boost-${boostVersion} boost`; 21 | } 22 | await $`rm -rf ${includeDir}/boost`; 23 | await $`mkdir -p ${includeDir}/boost`; 24 | await bash(`cp -rf boost/libs/*/include/boost ${includeDir}`); 25 | await bash(`cp -rf boost/libs/numeric/*/include/boost ${includeDir}`); 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | uses: ./.github/workflows/build.yml 9 | release: 10 | needs: build 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - name: Checkout Latest Commit 16 | uses: actions/checkout@v4 17 | with: 18 | filter: blob:none 19 | - name: Tag the Latest Commit 20 | run: | 21 | git tag -f latest 22 | git push -f origin latest 23 | - name: Download Artifact 24 | uses: actions/download-artifact@v4 25 | with: 26 | name: TypeDuck-Web 27 | - name: Release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 30 | run: | 31 | tar -xvf TypeDuck-Web.tar 32 | zip -r TypeDuck-Web.zip TypeDuck-Web 33 | gh release upload latest TypeDuck-Web.zip --clobber 34 | -------------------------------------------------------------------------------- /scripts/build_wasm.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | 3 | const libPath = "build/sysroot/usr/lib"; 4 | const exportedFunctions = [ 5 | "_init", 6 | "_set_option", 7 | "_process_key", 8 | "_select_candidate", 9 | "_delete_candidate", 10 | "_flip_page", 11 | "_customize", 12 | "_deploy", 13 | ].join(); 14 | 15 | const compileArgs = { 16 | raw: `\ 17 | -std=c++17 \ 18 | ${import.meta.env["BUILD_TYPE"] === "Debug" ? "-g" : "-O2 -DBOOST_DISABLE_ASSERTS -DBOOST_DISABLE_CURRENT_LOCATION"} \ 19 | -s ALLOW_MEMORY_GROWTH=1 \ 20 | -s MAXIMUM_MEMORY=4GB \ 21 | -s EXPORTED_FUNCTIONS=${exportedFunctions} \ 22 | -s EXPORTED_RUNTIME_METHODS=["ccall","FS"] \ 23 | --preload-file schema@/usr/share/rime-data \ 24 | -I build/sysroot/usr/include \ 25 | -o public/rime.js \ 26 | `, 27 | }; 28 | 29 | const linkArgs = { 30 | raw: `\ 31 | -fexceptions \ 32 | -l idbfs.js \ 33 | -L ${libPath} \ 34 | -Wl,--whole-archive -l rime -Wl,--no-whole-archive \ 35 | -l yaml-cpp \ 36 | -l leveldb \ 37 | -l marisa \ 38 | -l opencc \ 39 | ${(await Bun.file(`${libPath}/librime.a`).text()).includes("LogMessage") ? "-l glog" : ""} \ 40 | `, 41 | }; 42 | 43 | await $`mkdir -p public`; 44 | await $`em++ -v ${compileArgs} wasm/api.cpp ${linkArgs}`; // --emit-tsd ${root}/src/rime.d.ts 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, TypeDuck Team 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/CaretFollower.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useLayoutEffect, useState } from "react"; 2 | 3 | import getCaretCoordinates from "textarea-caret"; 4 | 5 | import type { ComponentPropsWithRef } from "react"; 6 | 7 | const CaretFollower = forwardRef & { textArea: HTMLTextAreaElement }>(function CaretFollower({ textArea, children, ...rest }, ref) { 8 | const [position, setPosition] = useState({ x: 0, y: 0 }); 9 | useLayoutEffect(() => { 10 | function onSelectionChange() { 11 | if (document.activeElement === textArea) { 12 | const { top, left, height } = getCaretCoordinates(textArea, textArea.selectionStart); 13 | setPosition({ x: textArea.offsetLeft + left, y: textArea.offsetTop + top + height - textArea.scrollTop }); 14 | } 15 | } 16 | textArea.focus(); 17 | onSelectionChange(); 18 | document.addEventListener("selectionchange", onSelectionChange); 19 | window.addEventListener("resize", onSelectionChange); 20 | textArea.addEventListener("selectionchange", onSelectionChange); 21 | textArea.addEventListener("scroll", onSelectionChange); 22 | textArea.addEventListener("resize", onSelectionChange); 23 | return () => { 24 | document.removeEventListener("selectionchange", onSelectionChange); 25 | window.removeEventListener("resize", onSelectionChange); 26 | textArea.removeEventListener("selectionchange", onSelectionChange); 27 | textArea.removeEventListener("scroll", onSelectionChange); 28 | textArea.removeEventListener("resize", onSelectionChange); 29 | }; 30 | }, [textArea]); 31 | return
{children}
; 32 | }); 33 | 34 | export default CaretFollower; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeduck-web", 3 | "version": "0.0.0", 4 | "description": "TypeDuck 網頁版", 5 | "type": "module", 6 | "scripts": { 7 | "boost": "bun scripts/prepare_boost.ts", 8 | "native": "bun scripts/build_native.ts", 9 | "schema": "bun scripts/build_schema.ts", 10 | "lib": "bun scripts/build_lib.ts", 11 | "wasm": "bun scripts/build_wasm.ts", 12 | "start": "vite --host", 13 | "build": "bun run worker --minify && vite build", 14 | "worker": "esbuild src/worker.ts --outdir=public", 15 | "preview": "vite preview --host" 16 | }, 17 | "dependencies": { 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "react-toastify": "^10.0.5", 21 | "react-use": "^17.5.0", 22 | "textarea-caret": "^3.1.0", 23 | "use-local-storage-state": "^19.3.0" 24 | }, 25 | "devDependencies": { 26 | "@types/bun": "^1.1.3", 27 | "@types/emscripten": "^1.39.12", 28 | "@types/react": "^18.3.3", 29 | "@types/react-dom": "^18.3.0", 30 | "@types/textarea-caret": "^3.0.3", 31 | "@typescript-eslint/eslint-plugin": "^7.10.0", 32 | "@typescript-eslint/parser": "^7.10.0", 33 | "@vitejs/plugin-react-swc": "^3.7.0", 34 | "autoprefixer": "^10.4.19", 35 | "daisyui": "^3.9.4", 36 | "esbuild": "^0.21.4", 37 | "eslint": "^8.57.0", 38 | "eslint-plugin-import": "^2.29.1", 39 | "eslint-plugin-jsx-a11y": "^6.8.0", 40 | "eslint-plugin-no-array-concat": "^0.1.2", 41 | "eslint-plugin-react": "^7.34.1", 42 | "eslint-plugin-react-hooks": "^4.6.2", 43 | "eslint-plugin-react-refresh": "^0.4.7", 44 | "postcss": "^8.4.38", 45 | "postcss-nesting": "^12.1.5", 46 | "tailwindcss": "^3.4.3", 47 | "typescript": "^5.4.5", 48 | "vite": "^5.2.11" 49 | }, 50 | "license": "BSD-3-Clause", 51 | "browserslist": "> 0.5% in HK and fully supports wasm and not dead" 52 | } 53 | -------------------------------------------------------------------------------- /dprint.jsonc: -------------------------------------------------------------------------------- 1 | // Adopt from https://github.com/microsoft/TypeScript with minor changes 2 | { 3 | "indentWidth": 4, 4 | "lineWidth": 1000, 5 | "newLineKind": "crlf", 6 | "useTabs": true, 7 | "typescript": { 8 | "semiColons": "prefer", 9 | "quoteStyle": "preferDouble", 10 | "quoteProps": "consistent", 11 | "useBraces": "whenNotSingleLine", 12 | "bracePosition": "sameLineUnlessHanging", 13 | "singleBodyPosition": "sameLine", 14 | "nextControlFlowPosition": "nextLine", 15 | "trailingCommas": "onlyMultiLine", 16 | "operatorPosition": "nextLine", 17 | "preferHanging": false, 18 | 19 | "arrowFunction.useParentheses": "preferNone", 20 | "jsx.bracketPosition": "sameLine", 21 | "jsx.forceNewLinesSurroundingContent": false, 22 | "jsx.multiLineParens": "never", 23 | "functionExpression.spaceAfterFunctionKeyword": true, 24 | "constructorType.spaceAfterNewKeyword": true, 25 | "constructSignature.spaceAfterNewKeyword": true, 26 | 27 | // Let ESLint handle this. 28 | "module.sortImportDeclarations": "maintain", 29 | "module.sortExportDeclarations": "maintain", 30 | "exportDeclaration.sortNamedExports": "maintain", 31 | "importDeclaration.sortNamedImports": "maintain" 32 | }, 33 | "json": { 34 | "trailingCommas": "never" 35 | }, 36 | "markdown": {}, 37 | "prettier": { 38 | "associations": [ 39 | "**/*.{html,htm,xhtml,css,scss,sass,less,graphql,graphqls,gql,yaml,yml}" 40 | ] 41 | }, 42 | "excludes": [ 43 | "node_modules", 44 | "boost", 45 | "librime", 46 | "schema", 47 | "public", 48 | "build", 49 | "dist" 50 | ], 51 | "plugins": [ 52 | "https://plugins.dprint.dev/typescript-0.89.3.wasm", 53 | "https://plugins.dprint.dev/json-0.19.2.wasm", 54 | "https://plugins.dprint.dev/markdown-0.16.4.wasm", 55 | "https://plugins.dprint.dev/prettier-0.39.0.json@896b70f29ef8213c1b0ba81a93cee9c2d4f39ac2194040313cd433906db7bc7c" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TypeDuck 網頁版 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { useMedia } from "react-use"; 4 | 5 | import useLocalStorageState from "use-local-storage-state"; 6 | 7 | export default function ThemeSwitcher() { 8 | const systemTheme = useMedia("(prefers-color-scheme: dark)") ? "dark" : "light"; 9 | const [theme = systemTheme, setTheme] = useLocalStorageState<"light" | "dark" | undefined>("theme", { 10 | serializer: { 11 | stringify: v => String(v), 12 | parse: s => s, 13 | }, 14 | }); 15 | 16 | useEffect(() => { 17 | document.documentElement.dataset["theme"] = theme; 18 | }, [theme]); 19 | 20 | // From https://daisyui.com/components/theme-controller/#theme-controller-using-a-toggle-with-icons-inside 21 | return ; 52 | } 53 | -------------------------------------------------------------------------------- /patches/opencc.patch: -------------------------------------------------------------------------------- 1 | diff --git a/CMakeLists.txt b/CMakeLists.txt 2 | index 1acb75a..3faa727 100644 3 | --- a/CMakeLists.txt 4 | +++ b/CMakeLists.txt 5 | @@ -152,12 +152,22 @@ add_definitions( 6 | -DPACKAGE_NAME="${PACKAGE_NAME}" 7 | ) 8 | 9 | +add_definitions(-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=.) 10 | + 11 | +if (EMSCRIPTEN) 12 | + add_definitions(-I"${CMAKE_CURRENT_SOURCE_DIR}/../../../build/sysroot/usr/include") 13 | +else() 14 | + add_definitions(-I"${CMAKE_CURRENT_SOURCE_DIR}/../../include") 15 | +endif() 16 | + 17 | if ("${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang") 18 | add_definitions( 19 | -std=c++14 20 | -Wall 21 | ) 22 | - set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -pthread") 23 | + if (NOT EMSCRIPTEN) 24 | + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -pthread") 25 | + endif () 26 | if (CMAKE_BUILD_TYPE MATCHES Debug) 27 | add_definitions(-O0 -g3) 28 | endif () 29 | @@ -221,9 +231,6 @@ endif() 30 | ######## Subdirectories 31 | 32 | add_subdirectory(src) 33 | -add_subdirectory(doc) 34 | -add_subdirectory(data) 35 | -add_subdirectory(test) 36 | 37 | ######## Testing 38 | 39 | diff --git a/deps/rapidjson-1.1.0/rapidjson/document.h b/deps/rapidjson-1.1.0/rapidjson/document.h 40 | index e3e20df..b0f1f70 100644 41 | --- a/deps/rapidjson-1.1.0/rapidjson/document.h 42 | +++ b/deps/rapidjson-1.1.0/rapidjson/document.h 43 | @@ -316,8 +316,6 @@ struct GenericStringRef { 44 | 45 | GenericStringRef(const GenericStringRef& rhs) : s(rhs.s), length(rhs.length) {} 46 | 47 | - GenericStringRef& operator=(const GenericStringRef& rhs) { s = rhs.s; length = rhs.length; } 48 | - 49 | //! implicit conversion to plain CharType pointer 50 | operator const Ch *() const { return s; } 51 | 52 | diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt 53 | index 75eda02..759fa85 100644 54 | --- a/src/CMakeLists.txt 55 | +++ b/src/CMakeLists.txt 56 | @@ -202,7 +202,3 @@ endif() 57 | if (ENABLE_BENCHMARK) 58 | add_subdirectory(benchmark) 59 | endif() 60 | - 61 | -# Subdir 62 | - 63 | -add_subdirectory(tools) 64 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Flip, toast } from "react-toastify"; 2 | 3 | import type { TypeOptions } from "react-toastify"; 4 | 5 | export function notify(type: TypeOptions, zh: string, en: string) { 6 | toast.dismiss(); 7 | toast(`${zh}時發生錯誤。如輸入法不能正常運作,請重新載入頁面。\nAn error occurred while ${en}. If the input method does not work properly, please reload the page.`, { 8 | type, 9 | position: "bottom-center", 10 | theme: document.documentElement.dataset["theme"], 11 | transition: Flip, 12 | }); 13 | } 14 | 15 | export class ConsumedString { 16 | private i = 0; 17 | constructor(private string: string) {} 18 | get isNotEmpty() { 19 | return this.i < this.string.length; 20 | } 21 | consume(char: string) { 22 | if (this.isNotEmpty && this.string[this.i] === char) { 23 | this.i++; 24 | return true; 25 | } 26 | return false; 27 | } 28 | consumeUntil(char: string) { 29 | const start = this.i; 30 | while (this.isNotEmpty) { 31 | if (this.string[this.i] === char) return this.string.slice(start, this.i++); 32 | else this.i++; 33 | } 34 | return this.string.slice(start, this.i); 35 | } 36 | toString() { 37 | return this.string.slice(this.i); 38 | } 39 | } 40 | 41 | export function* parseCSV(csv: string) { 42 | let isQuoted = false; 43 | let value = ""; 44 | for (let i = 0; i < csv.length; i++) { 45 | if (isQuoted) { 46 | if (csv[i] === '"') { 47 | if (csv[i + 1] === '"') value += csv[++i]; 48 | else isQuoted = false; 49 | } 50 | else value += csv[i]; 51 | } 52 | else if (!value && csv[i] === '"') isQuoted = true; 53 | else if (csv[i] === ",") { 54 | yield value || undefined; 55 | value = ""; 56 | } 57 | else value += csv[i]; 58 | } 59 | yield value || undefined; 60 | } 61 | 62 | export function isPrintable(key: string) { 63 | return key.length === 1 && key >= " " && key <= "~"; 64 | } 65 | 66 | export function nonEmptyArrayOrUndefined(array: readonly T[] | undefined) { 67 | return array?.length ? array as readonly [T, ...T[]] : undefined; 68 | } 69 | 70 | export function letSome(values: T, callback: (...values: T) => R): R | null { 71 | return values.some(Boolean) ? callback(...values) : null; 72 | } 73 | -------------------------------------------------------------------------------- /src/Inputs.tsx: -------------------------------------------------------------------------------- 1 | import { NO_AUTO_FILL } from "./consts"; 2 | 3 | import type { Dispatch } from "react"; 4 | 5 | interface CheckboxProps { 6 | label: string; 7 | checked: boolean; 8 | setChecked: Dispatch; 9 | } 10 | 11 | interface RadioProps { 12 | name: string; 13 | label: string; 14 | state: T; 15 | setState: Dispatch; 16 | value: T; 17 | } 18 | 19 | export function Toggle({ label, checked, setChecked }: CheckboxProps) { 20 | return ; 29 | } 30 | 31 | export function Radio({ name, label, state, setState, value }: RadioProps) { 32 | return ; 42 | } 43 | 44 | export function Segment({ name, label, state, setState, value }: RadioProps) { 45 | return ; 55 | } 56 | 57 | export function RadioCheckbox({ name, label, state, setState, value, checked, setChecked }: RadioProps & CheckboxProps) { 58 | return
59 | setState(value)} /> 66 | 75 |
; 76 | } 77 | -------------------------------------------------------------------------------- /src/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | import { useRimeOption } from "./hooks"; 4 | 5 | export default function Toolbar({ loading, deployStatus }: { loading: boolean; deployStatus: number }) { 6 | const [debouncedLoading, setDebouncedLoading] = useState(loading); 7 | const timeout = useRef>(); 8 | useEffect(() => { 9 | function clear() { 10 | if (typeof timeout.current !== "undefined") { 11 | clearTimeout(timeout.current); 12 | timeout.current = undefined; 13 | } 14 | } 15 | if (loading) { 16 | timeout.current = setTimeout(() => setDebouncedLoading(true), 200); 17 | return clear; 18 | } 19 | else { 20 | setDebouncedLoading(false); 21 | return clear(); 22 | } 23 | }, [loading]); 24 | 25 | useRimeOption("soft_cursor", true, deployStatus); // Not modifiable by user 26 | const [isASCIIMode, toggleIsASCIIMode] = useRimeOption("ascii_mode", false, deployStatus); 27 | const [isSimplified, toggleIsSimplified] = useRimeOption("simplification", false, deployStatus); 28 | const [isFullwidthLettersAndSymbols, toggleIsFullwidthLettersAndSymbols] = useRimeOption("full_shape", false, deployStatus, "isFullwidthLettersAndSymbols"); 29 | const [isHalfwidthPunctuation, toggleIsHalfwidthPunctuation] = useRimeOption("ascii_punct", false, deployStatus, "isHalfwidthPunctuation"); 30 | return
31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 | 43 |
44 |
45 | {debouncedLoading && 46 |
載入中 Loading… 47 | } 48 |
; 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeDuck Web 2 | 3 | > [TypeDuck](https://typeduck.hk): _Cantonese for everyone at your fingertips_ 4 | 5 | This repository contains the source code for TypeDuck Web. 6 | 7 | Visit [typeduck.hk/web](https://typeduck.hk/web) to give it a try! 8 | 9 | ## Development 10 | 11 | TypeDuck Web is a static single-paged application (SPA) built with [TypeScript](https://www.typescriptlang.org), [React](https://reactjs.org), [Tailwind CSS](https://tailwindcss.com) and [daisyUI](https://daisyui.com). 12 | 13 | The [RIME Input Method Engine](https://rime.im) is the technology that powers TypeDuck Web. It is compiled to [WebAssembly](https://webassembly.org) with [Emscripten](https://emscripten.org) and runs right in your browser without any data being sent to the server. 14 | 15 | ### Prerequisites 16 | 17 | - [Bun](https://bun.sh) 18 | 19 | Execute the command provided on the website to install Bun. Alternatively, you may install it with npm: 20 | 21 | ```sh 22 | npm i -g bun 23 | ``` 24 | 25 | - [CMake](https://cmake.org) 26 | - [Ninja](https://ninja-build.org) 27 | - [LLVM](https://llvm.org) (Windows only) 28 | 29 | You may install the above prerequisites with the following commands: 30 | 31 | ```sh 32 | # Ubuntu 33 | sudo apt install -y cmake ninja-build 34 | # macOS 35 | brew install cmake ninja 36 | # Windows 37 | choco install -y cmake --ia "ADD_CMAKE_TO_PATH=System" 38 | choco install -y ninja llvm 39 | ``` 40 | 41 | On Windows, you may skip the installation above and execute subsequent commands in _Developer PowerShell for Visual Studio_ if you have Visual Studio installed. 42 | 43 | - [Emscripten](https://emscripten.org) 44 | 45 | Follow the [installation guide](https://emscripten.org/docs/getting_started/downloads.html) to install Emscripten. 46 | 47 | ### Compilation 48 | 49 | On Ubuntu, the following additional packages should be pre-installed: 50 | 51 | ```sh 52 | sudo apt install -y \ 53 | libboost-dev \ 54 | libboost-regex-dev \ 55 | libyaml-cpp-dev \ 56 | libleveldb-dev \ 57 | libmarisa-dev \ 58 | libopencc-dev 59 | ``` 60 | 61 | Then, execute the following commands in order: 62 | 63 | ```sh 64 | bun run boost 65 | bun run native 66 | bun run schema 67 | bun run lib 68 | bun run wasm 69 | ``` 70 | 71 | ### Building the Worker Script 72 | 73 | ```sh 74 | bun run worker 75 | ``` 76 | 77 | ### Starting the Development Server 78 | 79 | ```sh 80 | bun start 81 | ``` 82 | 83 | However, the above command is slow to start, at least on Windows. For a faster development experience, you may want to simply build the project. 84 | 85 | ### Building the Project 86 | 87 | ```sh 88 | bun run build 89 | ``` 90 | 91 | ### Previewing the Output 92 | 93 | ```sh 94 | bun run preview 95 | ``` 96 | -------------------------------------------------------------------------------- /src/DictionaryPanel.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | import { LANGUAGE_CODES } from "./consts"; 4 | import { letSome } from "./utils"; 5 | 6 | import type CandidateInfo from "./CandidateInfo"; 7 | import type { InterfacePreferences } from "./types"; 8 | 9 | const DictionaryPanel = forwardRef(function DictionaryPanel({ info, prefs }, ref) { 10 | return info.hasDictionaryEntry(prefs) &&
11 | {info.entries.flatMap((entry, index) => 12 | entry.isDictionaryEntry(prefs) 13 | ? [ 14 |
15 | {letSome( 16 | [entry.honzi, entry.jyutping, entry.pronunciationType], 17 | (honzi, jyutping, pronunciationType) => 18 |
19 | {honzi && {honzi}} 20 | {jyutping && {jyutping}} 21 | {pronunciationType && {pronunciationType}} 22 |
, 23 | )} 24 | {letSome( 25 | [entry.formattedPartsOfSpeech, entry.formattedRegister, entry.formattedLabels, entry.properties.definition[prefs.mainLanguage]], 26 | (partsOfSpeech, register, labels, mainDefinition) => 27 |
28 | {partsOfSpeech?.map((partOfSpeech, i) => {partOfSpeech})} 29 | {register && {register}} 30 | {labels?.map((label, i) => {label})} 31 | {mainDefinition && {mainDefinition}} 32 |
, 33 | )} 34 | {letSome( 35 | [entry.otherData], 36 | otherData => 37 | 38 | {otherData?.map(([key, name, value]) => 39 | 40 | 41 | 44 | 45 | )} 46 |
{name} 42 | {value.split(",").map((line, i) =>
{line}
)} 43 |
, 47 | )} 48 | {letSome( 49 | [entry.otherLanguages(prefs)], 50 | otherDefinitions => 51 | 52 | 53 | {otherDefinitions?.map(([lang, name, value]) => 54 | 55 | 56 | 57 | 58 | )} 59 |
More Languages
{name}{value}
, 60 | )} 61 |
, 62 | ] 63 | : [] 64 | )} 65 |
; 66 | }); 67 | 68 | export default DictionaryPanel; 69 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | import { useSet } from "react-use"; 4 | import useLocalStorageState from "use-local-storage-state"; 5 | 6 | import { DEFAULT_PREFERENCES, Language } from "./consts"; 7 | import Rime, { subscribe } from "./rime"; 8 | import { notify } from "./utils"; 9 | 10 | import type { Preferences, PreferencesWithSetter } from "./types"; 11 | import type { DispatchWithoutAction } from "react"; 12 | 13 | export function useLoading(): [boolean, (asyncTask: () => Promise) => void, () => PromiseWithResolvers] { 14 | const [promises, { add, remove }] = useSet>(); 15 | 16 | const runAsyncTask = useCallback((asyncTask: () => Promise) => { 17 | async function processAsyncTask() { 18 | try { 19 | await asyncTask(); 20 | } 21 | finally { 22 | remove(promise); 23 | } 24 | } 25 | const promise = processAsyncTask(); 26 | add(promise); 27 | }, [add, remove]); 28 | 29 | const startAsyncTask = useCallback(() => { 30 | let resolve!: () => void; 31 | let reject!: () => void; 32 | const promise = new Promise((_resolve, _reject) => { 33 | resolve = _resolve; 34 | reject = _reject; 35 | }); 36 | runAsyncTask(() => promise); 37 | return { promise, resolve, reject }; 38 | }, [runAsyncTask]); 39 | 40 | return [!!promises.size, runAsyncTask, startAsyncTask]; 41 | } 42 | 43 | export function useRimeOption(option: string, defaultValue: boolean, deployStatus: number, localStorageKey?: string): [boolean, DispatchWithoutAction] { 44 | // eslint-disable-next-line react-hooks/rules-of-hooks 45 | const [value, setValue] = localStorageKey ? useLocalStorageState(localStorageKey, { defaultValue }) : useState(defaultValue); 46 | 47 | useEffect(() => { 48 | async function setOption() { 49 | try { 50 | await Rime.setOption(option, value); 51 | } 52 | catch { 53 | notify("error", "套用選項", "applying the option"); 54 | } 55 | } 56 | void setOption(); 57 | }, [option, value, deployStatus]); 58 | 59 | useEffect(() => 60 | subscribe("optionChanged", (rimeOption, rimeValue) => { 61 | if (rimeOption === option) { 62 | setValue(rimeValue); 63 | } 64 | }), [option, setValue]); 65 | 66 | return [value, useCallback(() => setValue(value => !value), [setValue])]; 67 | } 68 | 69 | export function usePreferences() { 70 | return Object.fromEntries( 71 | Object.entries(DEFAULT_PREFERENCES).flatMap(([key, defaultValue]: [string, Preferences[keyof Preferences]]) => { 72 | // eslint-disable-next-line react-hooks/rules-of-hooks 73 | const [optionValue, setOptionValue] = useLocalStorageState( 74 | key, 75 | { 76 | defaultValue, 77 | serializer: key === "displayLanguages" 78 | ? { 79 | stringify: languages => [...languages as Set].join(), 80 | parse: values => new Set(values.split(",").map(value => value.trim() as Language)), 81 | } 82 | : typeof defaultValue === "string" 83 | ? { 84 | stringify: String, 85 | parse: String, 86 | } 87 | : JSON, 88 | }, 89 | ); 90 | return [[key, optionValue], [`set${key[0].toUpperCase()}${key.slice(1)}`, setOptionValue]]; 91 | }), 92 | ) as PreferencesWithSetter; 93 | } 94 | -------------------------------------------------------------------------------- /src/rime.ts: -------------------------------------------------------------------------------- 1 | import type { Actions, ListenerArgsMap, Message } from "./types"; 2 | 3 | type ListenerPayload = { 4 | [K in keyof ListenerArgsMap]: { 5 | type: "listener"; 6 | name: K; 7 | args: ListenerArgsMap[K]; 8 | }; 9 | }[keyof ListenerArgsMap]; 10 | 11 | interface SuccessPayload { 12 | type: "success"; 13 | result: ReturnType; 14 | } 15 | 16 | interface ErrorPayload { 17 | type: "error"; 18 | error: unknown; 19 | } 20 | 21 | type Payload = ListenerPayload | SuccessPayload | ErrorPayload; 22 | 23 | type Listeners = { [K in keyof ListenerArgsMap]: (this: Worker, ...args: ListenerArgsMap[K]) => void }; 24 | 25 | let running: Message | null = null; 26 | const queue: Message[] = []; 27 | 28 | const allListenerTypes: (keyof Listeners)[] = [ 29 | "deployStatusChanged", 30 | "schemaChanged", 31 | "optionChanged", 32 | "initialized", 33 | ]; 34 | 35 | const listeners = {} as { [K in keyof Listeners]: Listeners[K][] }; 36 | for (const type of allListenerTypes) { 37 | listeners[type] = []; 38 | } 39 | 40 | const worker = new Worker("./worker.js"); 41 | worker.addEventListener("message", ({ data }: MessageEvent) => { 42 | if (process.env.NODE_ENV !== "production" || location.search === "?debug") console.log("receive", data); 43 | const { type } = data; 44 | if (type === "listener") { 45 | const { name, args } = data; 46 | for (const listener of listeners[name]) { 47 | // @ts-expect-error Unactionable 48 | listener.apply(worker, args); 49 | } 50 | } 51 | else if (running) { 52 | const { resolve, reject } = running; 53 | const nextMessage = queue.shift(); 54 | if (nextMessage) { 55 | postMessage(nextMessage); 56 | } 57 | else { 58 | running = null; 59 | } 60 | if (type === "success") { 61 | resolve(data.result); 62 | } 63 | else { 64 | reject(data.error); 65 | } 66 | } 67 | }); 68 | 69 | function postMessage(message: Message) { 70 | if (process.env.NODE_ENV !== "production" || location.search === "?debug") console.log("post", message); 71 | const { name, args } = running = message; 72 | worker.postMessage({ name, args }); 73 | } 74 | 75 | const allActions: (keyof Actions)[] = [ 76 | "setOption", 77 | "processKey", 78 | "selectCandidate", 79 | "deleteCandidate", 80 | "flipPage", 81 | "customize", 82 | "deploy", 83 | ]; 84 | 85 | const Rime = {} as Actions; 86 | for (const action of allActions) { 87 | Rime[action] = registerAction(action) as never; 88 | } 89 | export default Rime; 90 | 91 | function registerAction(name: K): Actions[K] { 92 | // @ts-expect-error Unactionable 93 | return (...args: Parameters) => 94 | new Promise((resolve, reject) => { 95 | const message: Message = { name, args, resolve, reject }; 96 | if (running) { 97 | queue.push(message); 98 | } 99 | else { 100 | postMessage(message); 101 | } 102 | }); 103 | } 104 | 105 | export function subscribe(type: K, callback: Listeners[K]) { 106 | listeners[type].push(callback); 107 | return () => { 108 | listeners[type] = listeners[type].filter(listener => listener !== callback) as never; 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type CandidateInfo from "./CandidateInfo"; 2 | import type { Language, ShowRomanization } from "./consts"; 3 | import type { Dispatch, SetStateAction } from "react"; 4 | 5 | export interface RimeAPI { 6 | init(): boolean; 7 | set_option(option: string, value: number): void; 8 | process_key(input: string): string; 9 | select_candidate(index: number): string; 10 | delete_candidate(index: number): string; 11 | flip_page(backward: boolean): string; 12 | customize(page_size: number, options: number): boolean; 13 | deploy(): boolean; 14 | } 15 | 16 | export interface Actions { 17 | setOption(option: string, value: boolean): Promise; 18 | processKey(input: string): Promise; 19 | selectCandidate(index: number): Promise; 20 | deleteCandidate(index: number): Promise; 21 | flipPage(backward: boolean): Promise; 22 | customize(preferences: RimePreferences): Promise; 23 | deploy(): Promise; 24 | } 25 | 26 | interface InputBuffer { 27 | before: string; 28 | active: string; 29 | after: string; 30 | } 31 | 32 | interface RimeComposing { 33 | isComposing: true; 34 | inputBuffer: InputBuffer; 35 | page: number; 36 | isLastPage: boolean; 37 | highlightedIndex: number; 38 | candidates: { 39 | label?: string; 40 | text: string; 41 | comment?: string; 42 | }[]; 43 | } 44 | 45 | interface RimeNotComposing { 46 | isComposing: false; 47 | } 48 | 49 | interface RimePayload { 50 | success: boolean; 51 | committed?: string; 52 | } 53 | 54 | export type RimeResult = (RimeComposing | RimeNotComposing) & RimePayload; 55 | 56 | export type RimeDeployStatus = "start" | "success" | "failure"; 57 | 58 | export interface RimeNotification { 59 | deploy: RimeDeployStatus; 60 | schema: `${string}/${string}`; 61 | option: string; 62 | } 63 | 64 | export interface ListenerArgsMap { 65 | deployStatusChanged: [status: RimeDeployStatus]; 66 | schemaChanged: [id: string, name: string]; 67 | optionChanged: [option: string, value: boolean]; 68 | initialized: [success: boolean]; 69 | } 70 | 71 | interface NamedMessage { 72 | name: K; 73 | args: Parameters; 74 | resolve: (value: ReturnType) => void; 75 | reject: (reason: unknown) => void; 76 | } 77 | 78 | export type Message = NamedMessage; 79 | 80 | export interface InputState { 81 | isPrevDisabled: boolean; 82 | isNextDisabled: boolean; 83 | inputBuffer: InputBuffer; 84 | candidates: CandidateInfo[]; 85 | highlightedIndex: number; 86 | } 87 | 88 | export interface RimePreferences { 89 | pageSize: number; 90 | enableCompletion: boolean; 91 | enableCorrection: boolean; 92 | enableSentence: boolean; 93 | enableLearning: boolean; 94 | isCangjie5: boolean; 95 | } 96 | 97 | export interface InterfacePreferences { 98 | displayLanguages: Set; 99 | mainLanguage: Language; 100 | isHeiTypeface: boolean; 101 | showRomanization: ShowRomanization; 102 | showReverseCode: boolean; 103 | } 104 | 105 | export type Preferences = RimePreferences & InterfacePreferences; 106 | 107 | export type PreferencesWithSetter = Preferences & { [P in keyof Preferences as `set${Capitalize

}`]: Dispatch> }; 108 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | workflow_call: 4 | pull_request: 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest, windows-latest] 12 | steps: 13 | - name: Checkout Latest Commit 14 | uses: actions/checkout@v4 15 | with: 16 | submodules: recursive 17 | - name: Install Ubuntu Dependencies 18 | if: ${{ matrix.os == 'ubuntu-latest' }} 19 | run: | 20 | sudo apt update 21 | sudo apt upgrade -y 22 | sudo apt install -y \ 23 | ninja-build \ 24 | libboost-dev \ 25 | libboost-regex-dev \ 26 | libyaml-cpp-dev \ 27 | libleveldb-dev \ 28 | libmarisa-dev \ 29 | libopencc-dev 30 | echo "CC=/usr/bin/clang" >> $GITHUB_ENV 31 | echo "CXX=/usr/bin/clang++" >> $GITHUB_ENV 32 | - name: Install macOS Dependencies 33 | if: ${{ startsWith(matrix.os, 'macos') }} 34 | run: | 35 | brew install ninja 36 | - name: Install Windows Dependencies 37 | if: ${{ matrix.os == 'windows-latest' }} 38 | run: | 39 | choco upgrade -y llvm 40 | pip install ninja 41 | echo "$env:ProgramFiles\LLVM\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 42 | - name: Setup Bun 43 | uses: oven-sh/setup-bun@v1 44 | with: 45 | bun-version: latest 46 | - name: Setup Emscripten SDK 47 | uses: mymindstorm/setup-emsdk@v14 48 | - name: Install Package Dependencies 49 | run: | 50 | bun i 51 | - name: Prepare Boost 52 | run: | 53 | bun run boost 54 | - name: Build Native 55 | run: | 56 | bun run native 57 | - name: Build Schema 58 | run: | 59 | bun run schema 60 | - name: Build Library 61 | run: | 62 | bun run lib 63 | - name: Build WebAssembly 64 | run: | 65 | bun run wasm 66 | - name: Build App 67 | run: | 68 | bun run build 69 | docker: 70 | runs-on: ubuntu-latest 71 | strategy: 72 | fail-fast: false 73 | steps: 74 | - name: Checkout Latest Commit 75 | uses: actions/checkout@v4 76 | with: 77 | submodules: recursive 78 | - name: Build App 79 | run: | 80 | docker build -t typeduck-web . 81 | docker create --name build typeduck-web 82 | - name: Copy Files 83 | run: | 84 | docker cp build:/TypeDuck-Web/dist ./TypeDuck-Web 85 | - name: Compress Files 86 | run: | 87 | tar -cvf TypeDuck-Web.tar TypeDuck-Web 88 | - name: Upload Artifact 89 | uses: actions/upload-artifact@v4 90 | with: 91 | name: TypeDuck-Web 92 | path: TypeDuck-Web.tar 93 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer, useState } from "react"; 2 | 3 | import { ToastContainer } from "react-toastify"; 4 | 5 | import CandidatePanel from "./CandidatePanel"; 6 | import { NO_AUTO_FILL } from "./consts"; 7 | import { useLoading, usePreferences } from "./hooks"; 8 | import Preferences from "./Preferences"; 9 | import Rime, { subscribe } from "./rime"; 10 | import ThemeSwitcher from "./ThemeSwitcher"; 11 | import Toolbar from "./Toolbar"; 12 | import { notify } from "./utils"; 13 | 14 | export default function App() { 15 | const [textArea, setTextArea] = useState(null); 16 | const [loading, runAsyncTask, startAsyncTask] = useLoading(); 17 | 18 | useEffect(() => { 19 | const { resolve } = startAsyncTask(); 20 | return subscribe("initialized", success => { 21 | if (!success) { 22 | notify("error", "啟動輸入法引擎", "initializing the input method engine"); 23 | } 24 | resolve(); 25 | }); 26 | }, [startAsyncTask]); 27 | 28 | useEffect(() => { 29 | let resolve!: () => void; 30 | let reject!: () => void; 31 | return subscribe("deployStatusChanged", status => { 32 | switch (status) { 33 | case "start": 34 | ({ resolve, reject } = startAsyncTask()); 35 | break; 36 | case "success": 37 | resolve(); 38 | break; 39 | case "failure": 40 | notify("warning", "重新整理", "refreshing"); 41 | reject(); 42 | break; 43 | } 44 | }); 45 | }, [startAsyncTask]); 46 | 47 | const [deployStatus, updateDeployStatus] = useReducer((n: number) => n + 1, 0); 48 | const preferences = usePreferences(); 49 | const { pageSize, enableCompletion, enableCorrection, enableSentence, enableLearning, isCangjie5, isHeiTypeface } = preferences; 50 | useEffect(() => 51 | runAsyncTask(async () => { 52 | let type: "warning" | "error" | undefined; 53 | try { 54 | const success = await Rime.customize({ pageSize, enableCompletion, enableCorrection, enableSentence, enableLearning, isCangjie5 }); 55 | if (!(await Rime.deploy() && success)) { 56 | type = "warning"; 57 | } 58 | } 59 | catch { 60 | type = "error"; 61 | } 62 | if (type) { 63 | notify(type, "套用設定", "applying the settings"); 64 | } 65 | updateDeployStatus(); 66 | }), [pageSize, enableCompletion, enableCorrection, enableSentence, enableLearning, isCangjie5, updateDeployStatus, runAsyncTask]); 67 | 68 | return <> 69 |

70 | 71 | TypeDuck Logo 72 | 73 |

TypeDuck 網頁版

74 | 75 |
76 |
77 | 78 |