diff --git a/.changeset/mighty-mirrors-eat.md b/.changeset/mighty-mirrors-eat.md new file mode 100644 index 0000000..5dddcbf --- /dev/null +++ b/.changeset/mighty-mirrors-eat.md @@ -0,0 +1,5 @@ +--- +'anki-templates': minor +--- + +feat(all): tool support for ChatGPT, search, translation and customization (工具支持ChatGPT、搜索、翻译和自定义) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a238ee..5056c3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: uses: changesets/action@v1 with: publish: pnpm changeset tag + title: 'chore: release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build/rollup.js b/build/rollup.js index 5fec1a4..2471e2f 100644 --- a/build/rollup.js +++ b/build/rollup.js @@ -15,7 +15,6 @@ import autoprefixer from 'autoprefixer'; import cssnano from 'cssnano'; import fs from 'node:fs/promises'; import path from 'node:path'; -import * as R from 'remeda'; import postcss from 'rollup-plugin-postcss'; import { swc } from 'rollup-plugin-swc3'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -31,7 +30,7 @@ export async function rollupOptions(config) { .readFile( path.resolve( import.meta.dirname, - '../locales/', + '../translations/', `${config.locale}.json`, ), { encoding: 'utf8' }, @@ -42,13 +41,11 @@ export async function rollupOptions(config) { input: 'entry', plugins: [ virtual({ - 'at/options': dataToEsm(templates[config.id]), - 'at/locale': dataToEsm({ + 'at/options': dataToEsm({ + ...templates[config.id], locale: config.locale, }), - 'at/i18n': dataToEsm( - R.mapKeys(i18nMap, (key) => `t${R.capitalize(key)}`), - ), + 'at/i18n': dataToEsm(i18nMap), entry: buildEntry(), }), replace({ @@ -122,7 +119,9 @@ export async function rollupOptions(config) { ].filter(Boolean), }), url(), - visualizer(), + visualizer({ + emitFile: true, + }), html({ fileName: `front.html`, template({ files }) { @@ -164,6 +163,9 @@ ${buildFields()} compress: { drop_console: true, }, + format: { + comments: false, + }, }), false, ), diff --git a/package.json b/package.json index 8d6f40b..b5fe9ac 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "", "scripts": { "build": "node build/cli.js", - "dev": "node build/cli.js --dev", + "dev": "ROLLUP_WATCH=true node build/cli.js --dev", "lint": "pnpm eslint .", "format": "pnpm eslint --fix .", "test": "vitest" @@ -58,7 +58,6 @@ }, "dependencies": { "@formkit/auto-animate": "^0.8.2", - "@headlessui/react": "^2.1.4", "ahooks": "^3.8.1", "clsx": "^2.1.1", "jotai": "^2.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 666cecd..7fd0d95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 - '@headlessui/react': - specifier: ^2.1.4 - version: 2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ahooks: specifier: ^3.8.1 version: 3.8.1(react@18.3.1) @@ -452,37 +449,9 @@ packages: '@fastify/deepmerge@1.3.0': resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} - '@floating-ui/core@1.6.7': - resolution: {integrity: sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==} - - '@floating-ui/dom@1.6.10': - resolution: {integrity: sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==} - - '@floating-ui/react-dom@2.1.1': - resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/react@0.26.23': - resolution: {integrity: sha512-9u3i62fV0CFF3nIegiWiRDwOs7OW/KhSUJDNx2MkQM3LbE5zQOY01sL3nelcVBXvX7Ovvo3A49I8ql+20Wg/Hw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.7': - resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} - '@formkit/auto-animate@0.8.2': resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==} - '@headlessui/react@2.1.4': - resolution: {integrity: sha512-tqxvLFJ2Cx0FWfHKSR0ENM3oa+ATvpGZR5OUswtMNRA7KbkNr4sE+oYM7HQS7zqaIoTu7OVBeQV/JO3pnez9JQ==} - engines: {node: '>=10'} - peerDependencies: - react: ^18 - react-dom: ^18 - '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} @@ -546,37 +515,6 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@react-aria/focus@3.18.2': - resolution: {integrity: sha512-Jc/IY+StjA3uqN73o6txKQ527RFU7gnG5crEl5Xy3V+gbYp2O5L3ezAo/E0Ipi2cyMbG6T5Iit1IDs7hcGu8aw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/interactions@3.22.2': - resolution: {integrity: sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/ssr@3.9.5': - resolution: {integrity: sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==} - engines: {node: '>= 12'} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-aria/utils@3.25.2': - resolution: {integrity: sha512-GdIvG8GBJJZygB4L2QJP1Gabyn2mjFsha73I2wSe+o4DYeGWoJiMZRM06PyTIxLH4S7Sn7eVDtsSBfkc2VY/NA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-stately/utils@3.10.3': - resolution: {integrity: sha512-moClv7MlVSHpbYtQIkm0Cx+on8Pgt1XqtPx6fy9rQFb2DNc9u1G3AUVnqA17buOkH1vLxAtX4MedlxMWyRCYYA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - - '@react-types/shared@3.24.1': - resolution: {integrity: sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - '@rollup/plugin-alias@5.1.0': resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==} engines: {node: '>=14.0.0'} @@ -835,15 +773,6 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' - '@tanstack/react-virtual@3.10.6': - resolution: {integrity: sha512-xaSy6uUxB92O8mngHZ6CvbhGuqxQ5lIZWCBy+FjhrbHmOwc6BnOnKkYm2FsB1/BpKw/+FVctlMbEtI+F6I1aJg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - - '@tanstack/virtual-core@3.10.6': - resolution: {integrity: sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==} - '@trivago/prettier-plugin-sort-imports@4.3.0': resolution: {integrity: sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==} peerDependencies: @@ -3354,9 +3283,6 @@ packages: resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} engines: {node: ^14.18.0 || >=16.0.0} - tabbable@6.2.0: - resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} - tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -4029,42 +3955,8 @@ snapshots: '@fastify/deepmerge@1.3.0': {} - '@floating-ui/core@1.6.7': - dependencies: - '@floating-ui/utils': 0.2.7 - - '@floating-ui/dom@1.6.10': - dependencies: - '@floating-ui/core': 1.6.7 - '@floating-ui/utils': 0.2.7 - - '@floating-ui/react-dom@2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/dom': 1.6.10 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@floating-ui/react@0.26.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.7 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tabbable: 6.2.0 - - '@floating-ui/utils@0.2.7': {} - '@formkit/auto-animate@0.8.2': {} - '@headlessui/react@2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react': 0.26.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/focus': 3.18.2(react@18.3.1) - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@tanstack/react-virtual': 3.10.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.3.0': {} @@ -4139,46 +4031,6 @@ snapshots: '@pkgr/core@0.1.1': {} - '@react-aria/focus@3.18.2(react@18.3.1)': - dependencies: - '@react-aria/interactions': 3.22.2(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.13 - clsx: 2.1.1 - react: 18.3.1 - - '@react-aria/interactions@3.22.2(react@18.3.1)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.25.2(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.13 - react: 18.3.1 - - '@react-aria/ssr@3.9.5(react@18.3.1)': - dependencies: - '@swc/helpers': 0.5.13 - react: 18.3.1 - - '@react-aria/utils@3.25.2(react@18.3.1)': - dependencies: - '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-stately/utils': 3.10.3(react@18.3.1) - '@react-types/shared': 3.24.1(react@18.3.1) - '@swc/helpers': 0.5.13 - clsx: 2.1.1 - react: 18.3.1 - - '@react-stately/utils@3.10.3(react@18.3.1)': - dependencies: - '@swc/helpers': 0.5.13 - react: 18.3.1 - - '@react-types/shared@3.24.1(react@18.3.1)': - dependencies: - react: 18.3.1 - '@rollup/plugin-alias@5.1.0(rollup@4.21.2)': dependencies: slash: 4.0.0 @@ -4352,6 +4204,7 @@ snapshots: '@swc/helpers@0.5.13': dependencies: tslib: 2.7.0 + optional: true '@swc/types@0.1.12': dependencies: @@ -4370,14 +4223,6 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.10 - '@tanstack/react-virtual@3.10.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@tanstack/virtual-core': 3.10.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@tanstack/virtual-core@3.10.6': {} - '@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.3.3)': dependencies: '@babel/generator': 7.17.7 @@ -7035,8 +6880,6 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.7.0 - tabbable@6.2.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.10): dependencies: tailwindcss: 3.4.10 diff --git a/src/components/about.tsx b/src/components/about.tsx index be513c2..18858b1 100644 --- a/src/components/about.tsx +++ b/src/components/about.tsx @@ -1,5 +1,5 @@ import { version } from '../../package.json'; -import { locale } from 'at/locale'; +import { locale } from 'at/options'; import clsx from 'clsx'; export const EnAbout = () => ( diff --git a/src/components/block.tsx b/src/components/block.tsx index e52bf44..24a99e0 100644 --- a/src/components/block.tsx +++ b/src/components/block.tsx @@ -1,19 +1,40 @@ +import { Button } from './button'; +import { ToolsContext } from './tools/context'; +import { selectionMenuAtom } from '@/store/settings'; import clsx from 'clsx'; -import { PropsWithChildren, ReactNode, forwardRef } from 'react'; +import { useAtomValue } from 'jotai/react'; +import { FC, PropsWithChildren, ReactNode } from 'react'; -export const Block = forwardRef< - HTMLDivElement, - PropsWithChildren & { name: ReactNode; className?: string; id?: string } ->(({ children, className, name, id }, ref) => ( -
-
{name}
-
- {children} +export const Block: FC< + PropsWithChildren & { + name: ReactNode; + action?: string; + onAction?: () => void; + className?: string; + id?: string; + enableTools?: boolean; + } +> = ({ children, className, name, id, enableTools, action, onAction }, ref) => { + const prefSelectionMenu = useAtomValue(selectionMenuAtom); + + return ( +
+
+ {name} + {action ? : null} +
+
+ {enableTools && prefSelectionMenu ? ( + {children} + ) : ( + children + )} +
-
-)); + ); +}; diff --git a/src/components/button.tsx b/src/components/button.tsx index 8fa625e..0507d45 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -1,16 +1,24 @@ import clsx from 'clsx'; import { FC, PropsWithChildren } from 'react'; -export const Button: FC< - PropsWithChildren & { onClick?: () => void; className?: string } -> = ({ onClick, children, className }) => ( +interface ButtonProps { + status?: 'normal' | 'danger'; + onClick?: () => void; + className?: string; +} + +export const Button: FC = ({ + status = 'normal', + onClick, + children, + className, +}) => (
+ ); }; diff --git a/src/components/checkbox.tsx b/src/components/checkbox.tsx index c1edfd0..171dad4 100644 --- a/src/components/checkbox.tsx +++ b/src/components/checkbox.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { FC, useId } from 'react'; +import { FC, ReactNode, useId } from 'react'; import { doNothing } from 'remeda'; export const Checkbox: FC<{ @@ -7,7 +7,7 @@ export const Checkbox: FC<{ checked?: boolean; className?: string; title: string; - subtitle?: string; + subtitle?: ReactNode; disabled?: boolean; }> = ({ onChange, className, checked, title, subtitle, disabled }) => { const id = useId(); diff --git a/src/components/field.tsx b/src/components/field.tsx index d1b40a5..16b6164 100644 --- a/src/components/field.tsx +++ b/src/components/field.tsx @@ -1,7 +1,7 @@ -import { FIELD_ID } from '@/utils/const'; +import { FIELD_ID, FIELDS_CONTAINER_ID } from '@/utils/const'; import useCreation from 'ahooks/es/useCreation'; import clsx from 'clsx'; -import { FC, memo, useCallback, useId } from 'react'; +import { FC, memo, useCallback, useEffect, useId } from 'react'; export const AnkiField: FC<{ name: string; @@ -22,14 +22,14 @@ export const AnkiField: FC<{ [fieldNode], ); - // useEffect(() => { - // return () => { - // if (fieldNode) { - // fieldNode.remove(); - // document.getElementById(FIELDS_CONTAINER_ID)?.appendChild(fieldNode); - // } - // }; - // }, [fieldNode]); + useEffect(() => { + return () => { + if (fieldNode) { + fieldNode.remove(); + document.getElementById(FIELDS_CONTAINER_ID)?.appendChild(fieldNode); + } + }; + }, [fieldNode]); const styleId = useId(); @@ -39,6 +39,7 @@ export const AnkiField: FC<{ id={`anki-field-${name}`} className={clsx( 'anki-field', + 'first:!mt-0 last:!mb-0', 'overflow-x-auto', 'prose prose-neutral dark:prose-invert', styleId, diff --git a/src/components/link-button.tsx b/src/components/link-button.tsx deleted file mode 100644 index 151eac2..0000000 --- a/src/components/link-button.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FC } from 'react'; - -export const LinkButton: FC<{ image: string; href: string }> = ({ - image, - href, -}) => { - return ( - - ); -}; diff --git a/src/components/menu-buttons.tsx b/src/components/menu-buttons.tsx deleted file mode 100644 index e8d5d21..0000000 --- a/src/components/menu-buttons.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { LinkButton } from './link-button'; -import imgBaiduFanyi from '@/assets/baidu-fanyi.png'; -import imgBaidu from '@/assets/baidu.png'; -import imgGoogleTranslate from '@/assets/google-translate.svg'; -import imgGoogle from '@/assets/google.svg'; -import { locale } from 'at/locale'; -import { FC } from 'react'; - -const BaiduTranslate: FC<{ sel: string }> = ({ sel }) => ( - -); - -const BaiduSearch: FC<{ sel: string }> = ({ sel }) => ( - -); - -const GoogleTranslate: FC<{ sel: string }> = ({ sel }) => ( - -); - -const GoogleSearch: FC<{ sel: string }> = ({ sel }) => ( - -); - -export const TranslateButton = - locale === 'zh' ? BaiduTranslate : GoogleTranslate; -export const SearchButton = locale === 'zh' ? BaiduSearch : GoogleSearch; diff --git a/src/components/modal.tsx b/src/components/modal.tsx deleted file mode 100644 index d8e01f3..0000000 --- a/src/components/modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Dialog, Transition } from '@headlessui/react'; -import clsx from 'clsx'; -import { FC, Fragment, PropsWithChildren } from 'react'; -import { doNothing } from 'remeda'; - -export const Modal: FC< - PropsWithChildren & { - isOpen?: boolean; - onClose?: () => void; - title?: string; - } -> = ({ isOpen, onClose = doNothing, title, children }) => { - return ( - <> - - - -
- - -
-
- - - - {title} - -
{children}
-
-
-
-
-
-
- - ); -}; diff --git a/src/components/selection-menu.tsx b/src/components/selection-menu.tsx deleted file mode 100644 index 297d8ee..0000000 --- a/src/components/selection-menu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { SearchButton, TranslateButton } from './menu-buttons'; -import { useTextSelection } from '@/hooks/use-text-selection'; -import { FC, PropsWithChildren, RefObject, useEffect, useState } from 'react'; -import { useThrottle } from 'react-use'; - -export const SelectionMenu: FC< - PropsWithChildren & { target: RefObject } -> = ({ target }) => { - const { clientRect, textContent, isCollapsed } = useTextSelection(target); - - const [open, setOpen] = useState(false); - - useEffect(() => { - if (textContent?.trim() === '') { - setOpen(false); - return; - } - setOpen(true); - }, [textContent]); - - const throttledRect = useThrottle(clientRect, 60); - - if (!open || !throttledRect || isCollapsed || !textContent) { - return null; - } - - const { top, left, width, height } = throttledRect; - - return ( -
-
- - -
-
- ); -}; diff --git a/src/components/timer.tsx b/src/components/timer.tsx index d3c18d4..874b7ed 100644 --- a/src/components/timer.tsx +++ b/src/components/timer.tsx @@ -1,22 +1,10 @@ import { Block } from './block'; import { Input } from './input'; -import { hideTimerAtom } from './settings'; -import { atomWithLocalStorage } from '@/utils/storage'; +import { hideTimerAtom } from '@/store/settings'; +import { atomWithScopedStorage } from '@/utils/storage'; import useCountDown from 'ahooks/es/useCountDown'; import useCreation from 'ahooks/es/useCreation'; -import { - tClose, - tDay, - tDefaultTimerTitle, - tHour, - tMinute, - tSecond, - tSetting, - tTargetDate, - tTimer, - tTimerSetting, - tTimerTitle, -} from 'at/i18n'; +import * as t from 'at/i18n'; import { useAtom, useAtomValue } from 'jotai'; import { FC, useState } from 'react'; @@ -30,10 +18,10 @@ export const Timer: FC = () => { const { days, hours, minutes, seconds } = formattedRes; return [ - [tDay, days], - [tHour, hours], - [tMinute, minutes], - [tSecond, seconds], + [t.day, days], + [t.hour, hours], + [t.minute, minutes], + [t.second, seconds], ]; }, [formattedRes]); @@ -64,10 +52,10 @@ interface TimerProps { const defaultTimerProps = { targetDate: '2023-12-31', - title: tDefaultTimerTitle, + title: t.defaultTimerTitle, }; -export const timerAtom = atomWithLocalStorage( +export const timerAtom = atomWithScopedStorage( 'timer', defaultTimerProps, ); @@ -84,22 +72,14 @@ export const TimerBlock = () => { return ( - {showSetting ? tTimerSetting : tTimer} -
setShowSetting((p) => !p)} - > - {showSetting ? tClose : tSetting} -
- - } + name={{showSetting ? t.timerSetting : t.timer}} + action={showSetting ? t.close : t.setting} + onAction={() => setShowSetting((prev) => !prev)} > {showSetting ? ( <> setTimer((prevTimer) => ({ @@ -111,7 +91,7 @@ export const TimerBlock = () => { /> setTimer((prevTimer) => ({ diff --git a/src/components/tools/context.tsx b/src/components/tools/context.tsx new file mode 100644 index 0000000..30c114c --- /dev/null +++ b/src/components/tools/context.tsx @@ -0,0 +1,52 @@ +import { useTextSelection } from '@/hooks/use-text-selection'; +import { toolsAtom } from '@/store/tools'; +import { getUrl } from '@/utils/tool'; +import { useAtomValue } from 'jotai/react'; +import { FC, PropsWithChildren, useMemo, useRef } from 'react'; +import { useThrottle } from 'react-use'; + +export const ToolsContext: FC = ({ children }) => { + const ref = useRef(null); + const { clientRect, textContent } = useTextSelection(ref); + const throttledRect = useThrottle(clientRect, 60); + const text = textContent?.trim(); + const tools = useAtomValue(toolsAtom); + + const toolsPopover = useMemo(() => { + if (!throttledRect || !text) { + return null; + } + const { top, left, height } = throttledRect; + return ( +
+
+ {tools.map((tool) => ( + + {tool.name} + + ))} +
+
+ ); + }, [throttledRect, text, tools]); + + return ( +
+ {children} + {toolsPopover} +
+ ); +}; diff --git a/src/components/tools/edit.tsx b/src/components/tools/edit.tsx new file mode 100644 index 0000000..d6e7928 --- /dev/null +++ b/src/components/tools/edit.tsx @@ -0,0 +1,66 @@ +import { Button } from '../button'; +import { Input } from '../input'; +import { Tool } from '@/store/tools'; +import * as t from 'at/i18n'; +import { FC, useState } from 'react'; + +export const ToolEdit: FC<{ + initialTool: Tool; + onSave: (tool: Tool) => void; + onCancel: () => void; +}> = ({ initialTool, onSave, onCancel }) => { + const [tool, setTool] = useState(initialTool); + + return ( + <> + + setTool((prevTool) => ({ + ...prevTool, + name: value, + })) + } + className="mt-2" + /> + + setTool((prevTool) => ({ + ...prevTool, + url: value, + })) + } + className="mt-2" + /> + + setTool((prevTool) => ({ + ...prevTool, + prefixText: value, + })) + } + className="mt-2" + /> + + setTool((prevTool) => ({ + ...prevTool, + suffixText: value, + })) + } + className="mt-2" + /> +
+ + +
+ + ); +}; diff --git a/src/entries/basic.tsx b/src/entries/basic.tsx index 414e0f7..85bf797 100644 --- a/src/entries/basic.tsx +++ b/src/entries/basic.tsx @@ -2,7 +2,7 @@ import { CardShell } from '../components/card-shell'; import { AnkiField } from '@/components/field'; import { FIELD_ID } from '@/utils/const'; import { isFieldEmpty } from '@/utils/field'; -import { tQuestion } from 'at/i18n'; +import * as t from 'at/i18n'; import clsx from 'clsx'; export default () => { @@ -11,7 +11,7 @@ export default () => { return ( diff --git a/src/entries/mcq.tsx b/src/entries/mcq.tsx index 3e5e2f8..2d9fc7b 100644 --- a/src/entries/mcq.tsx +++ b/src/entries/mcq.tsx @@ -1,14 +1,14 @@ import { CardShell } from '../components/card-shell'; import { AnkiField } from '../components/field'; +import { useBack } from '../hooks/use-back'; +import { useCrossState } from '../hooks/use-cross-state'; +import { useField } from '../hooks/use-field'; import { biggerTextAtom, blurOptionsAtom, hideQuestionTypeAtom, -} from '../components/settings'; -import { useBack } from '../hooks/use-back'; -import { useCrossState } from '../hooks/use-cross-state'; -import { useField } from '../hooks/use-field'; -import { randomOptionsAtom } from '@/components/settings'; + randomOptionsAtom, +} from '@/store/settings'; import '@/styles/mcq.css'; import { flipToBack } from '@/utils/bridge'; import { FIELD_ID } from '@/utils/const'; @@ -17,15 +17,8 @@ import { useAutoAnimate } from '@formkit/auto-animate/preact'; import useCreation from 'ahooks/es/useCreation'; import useMemoizedFn from 'ahooks/es/useMemoizedFn'; import useSelections from 'ahooks/es/useSelections'; -import { - tCorrectAnswer, - tMissedAnswer, - tMultipleAnswer, - tQuestion, - tSingleAnswer, - tWrongAnswer, -} from 'at/i18n'; -import { locale } from 'at/locale'; +import * as t from 'at/i18n'; +import { locale } from 'at/options'; import { fields } from 'at/options'; import clsx from 'clsx'; import { useAtomValue } from 'jotai'; @@ -33,9 +26,9 @@ import { useEffect } from 'react'; import { doNothing, shuffle } from 'remeda'; const ANSWER_TYPE_MAP = { - missedAnswer: tMissedAnswer, - correctAnswer: tCorrectAnswer, - wrongAnswer: tWrongAnswer, + missedAnswer: t.missedAnswer, + correctAnswer: t.correctAnswer, + wrongAnswer: t.wrongAnswer, }; const fieldToAlpha = (field: string) => field.slice(field.length - 1); @@ -124,9 +117,9 @@ export default () => { {isMultipleChoice ? tMultipleAnswer : tSingleAnswer} + <>{isMultipleChoice ? t.multipleAnswer : t.singleAnswer} ) } questionExtra={ diff --git a/src/entries/tf.tsx b/src/entries/tf.tsx index ab16ec4..d4ee981 100644 --- a/src/entries/tf.tsx +++ b/src/entries/tf.tsx @@ -7,7 +7,7 @@ import { extractTfItems } from '@/utils/extract-tf-items'; import { isFieldEmpty } from '@/utils/field'; import useCreation from 'ahooks/es/useCreation'; import useMemoizedFn from 'ahooks/es/useMemoizedFn'; -import { tQuestion, tYourWrongAnswer } from 'at/i18n'; +import * as t from 'at/i18n'; import clsx from 'clsx'; import { CheckCircle, XCircle, Triangle } from 'lucide-react'; import { useCallback } from 'react'; @@ -134,13 +134,13 @@ export default () => { return ( {items} {back ? (
- {tYourWrongAnswer} + {t.yourWrongAnswer} >; + +export const PageContext = createContext<{ + page: Page; + setPage: (page: Page) => void; +}>({ + page: Page.Index, + setPage: doNothing, +}); + +export const usePage = () => { + return useContext(PageContext).page; +}; + +export const useNavigate = () => { + return useContext(PageContext).setPage; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..df4ba18 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,8 @@ +import SettingsPage from './settings'; +import ToolsPage from './tools'; +import { Page, PageMap } from '@/hooks/use-page'; + +export const DEFAULT_PAGES: PageMap = { + [Page.Settings]: SettingsPage, + [Page.Tools]: ToolsPage, +}; diff --git a/src/components/settings.tsx b/src/pages/settings.tsx similarity index 54% rename from src/components/settings.tsx rename to src/pages/settings.tsx index 6f71555..61c2679 100644 --- a/src/components/settings.tsx +++ b/src/pages/settings.tsx @@ -1,76 +1,61 @@ -import { About } from './about'; -import { Checkbox } from './checkbox'; -import { atomWithLocalStorage } from '@/utils/storage'; +import { About } from '@/components/about'; +import { Block } from '@/components/block'; +import { Checkbox } from '@/components/checkbox'; +import { Page, useNavigate } from '@/hooks/use-page'; import { - tBiggerText, - tBlurOptions, - tBlurOptionsDetail, - tHideAbout, - tHideQuestionType, - tHideQuestionTypeDetail, - tHideTimer, - tNoScroll, - tRandomOption, - tRandomOptionDetail, - tSelMenu, - tSelMenuDetail, -} from 'at/i18n'; + biggerTextAtom, + blurOptionsAtom, + hideAboutAtom, + hideQuestionTypeAtom, + hideTimerAtom, + noScorllAtom, + randomOptionsAtom, + selectionMenuAtom, +} from '@/store/settings'; +import * as t from 'at/i18n'; import { id } from 'at/options'; import { useAtom } from 'jotai'; import { FC } from 'react'; -export const randomOptionsAtom = atomWithLocalStorage( - 'randomOptions', - true, -); -export const selectionMenuAtom = atomWithLocalStorage( - 'selectionMenu', - true, -); -export const hideAboutAtom = atomWithLocalStorage('hideAbout', false); -export const biggerTextAtom = atomWithLocalStorage( - 'biggerText', - false, -); -export const hideTimerAtom = atomWithLocalStorage('hideTimer', false); -export const hideQuestionTypeAtom = atomWithLocalStorage( - 'hideQuestionType', - false, -); -export const noScorllAtom = atomWithLocalStorage('noScorll', true); -export const blurOptionsAtom = atomWithLocalStorage( - 'blurOptions', - false, -); - const CommonOptions: FC = () => { const [selectionMenu, setSelectionMenu] = useAtom(selectionMenuAtom); const [hideAbout, setHideAbout] = useAtom(hideAboutAtom); const [biggerText, setBiggerText] = useAtom(biggerTextAtom); const [hideTimer, setHideTimer] = useAtom(hideTimerAtom); const [noScorll, setNoScorll] = useAtom(noScorllAtom); + const navigate = useNavigate(); return ( <> - - + {t.selMenuDetail} + navigate(Page.Tools)} + > + {t.setting} + + + } checked={selectionMenu} onChange={setSelectionMenu} /> + + @@ -91,20 +76,20 @@ if (id === 'mcq') { return ( <> @@ -120,14 +105,19 @@ if (id === 'mcq') { OptionList = () => null; } -export const Settings: FC = () => { +export default () => { + const navigate = useNavigate(); return ( - <> + navigate(Page.Index)} + >

- +
); }; diff --git a/src/pages/tools.tsx b/src/pages/tools.tsx new file mode 100644 index 0000000..a078dc9 --- /dev/null +++ b/src/pages/tools.tsx @@ -0,0 +1,85 @@ +import { Block } from '@/components/block'; +import { Button } from '@/components/button'; +import { ToolEdit } from '@/components/tools/edit'; +import { useNavigate, Page } from '@/hooks/use-page'; +import { Tool, toolsAtom } from '@/store/tools'; +import * as t from 'at/i18n'; +import { useAtom } from 'jotai/react'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +export default () => { + const navigate = useNavigate(); + const [tools, setTools] = useAtom(toolsAtom); + const [edit, setEdit] = useState(null); + + const onSave = (tool: Tool) => { + const { id } = tool; + setTools((prevTools) => { + const idx = prevTools.findIndex((item) => item.id === id); + if (idx < 0) { + prevTools.push(tool); + } else { + prevTools[idx] = { ...tool }; + } + return prevTools.slice(); + }); + setEdit(null); + }; + + const onDelete = (tool: Tool) => { + if (!confirm(t.confirmDelete)) { + return; + } + setTools((prevTools) => { + return prevTools.filter((item) => item.id !== tool.id); + }); + }; + + const onAdd = () => { + setEdit({ + id: Math.random().toString(36).slice(2), + }); + }; + + return ( + <> + navigate(Page.Settings)} + > + {edit ? ( + setEdit(null)} + /> + ) : ( +
+ + {tools.map((tool) => ( +
+
{tool.name}
+
+ + +
+
+ ))} +
+ )} +
+ +
+ + + ); +}; diff --git a/src/store.ts b/src/store/index.ts similarity index 100% rename from src/store.ts rename to src/store/index.ts diff --git a/src/store/settings.ts b/src/store/settings.ts new file mode 100644 index 0000000..2377eb1 --- /dev/null +++ b/src/store/settings.ts @@ -0,0 +1,25 @@ +import { atomWithScopedStorage } from '@/utils/storage'; + +export const randomOptionsAtom = atomWithScopedStorage( + 'randomOptions', + true, +); +export const selectionMenuAtom = atomWithScopedStorage( + 'selectionMenu', + true, +); +export const hideAboutAtom = atomWithScopedStorage('hideAbout', false); +export const biggerTextAtom = atomWithScopedStorage( + 'biggerText', + false, +); +export const hideTimerAtom = atomWithScopedStorage('hideTimer', false); +export const hideQuestionTypeAtom = atomWithScopedStorage( + 'hideQuestionType', + false, +); +export const noScorllAtom = atomWithScopedStorage('noScorll', true); +export const blurOptionsAtom = atomWithScopedStorage( + 'blurOptions', + false, +); diff --git a/src/store/tools.ts b/src/store/tools.ts new file mode 100644 index 0000000..c0305fa --- /dev/null +++ b/src/store/tools.ts @@ -0,0 +1,38 @@ +import { atomWithGlobalStorage } from '@/utils/storage'; +import * as t from 'at/i18n'; +import { locale } from 'at/options'; + +export interface Tool { + id: string; + name?: string; + prefixText?: string; + suffixText?: string; + url?: string; +} + +export const DEFAULT_TOOLS: Tool[] = [ + { + id: 'search', + name: t.search, + url: + locale === 'zh' + ? 'https://www.baidu.com/#wd={q}' + : 'https://www.google.com/search?q={q}', + }, + { + id: 'translate', + name: t.translate, + url: + locale === 'zh' + ? 'https://fanyi.baidu.com/#en/zh/{q}' + : 'https://translate.google.com/?sl=auto&text={q}&op=translate', + }, + { + id: 'chatgpt', + name: 'ChatGPT', + prefixText: t.explainFollowing, + url: 'https://chatgpt.com/?q={q}', + }, +]; + +export const toolsAtom = atomWithGlobalStorage('tools', DEFAULT_TOOLS); diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 7458940..b78590b 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -3,7 +3,16 @@ import { atomWithStorage, createJSONStorage } from 'jotai/utils'; const storage = createJSONStorage(() => localStorage); -export const atomWithLocalStorage = (key: string, initialValue: T) => - atomWithStorage(`at:${id}:${key}`, initialValue, storage, { - getOnInit: true, - }); +export const atomWithScopedStorage = + /*@__NO_SIDE_EFFECTS__*/ + (key: string, initialValue: T) => + atomWithStorage(`at:${id}:${key}`, initialValue, storage, { + getOnInit: true, + }); + +export const atomWithGlobalStorage = + /*@__NO_SIDE_EFFECTS__*/ + (key: string, initialValue: T) => + atomWithStorage(`at:_global:${key}`, initialValue, storage, { + getOnInit: true, + }); diff --git a/src/utils/tool.ts b/src/utils/tool.ts new file mode 100644 index 0000000..c4802e5 --- /dev/null +++ b/src/utils/tool.ts @@ -0,0 +1,15 @@ +import { Tool } from '@/store/tools'; + +export function getUrl(tool: Tool, text: string) { + const { url, prefixText, suffixText } = tool; + let q = ''; + if (prefixText) { + q = prefixText; + } + q += text; + if (suffixText) { + q += suffixText; + } + q = encodeURIComponent(q); + return url?.replace('{q}', q); +} diff --git a/tailwind.config.js b/tailwind.config.js index 00452f1..0a9b43d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -import en from './locales/en.json'; -import zh from './locales/zh.json'; +import en from './translations/en.json'; +import zh from './translations/zh.json'; /** @type {import('tailwindcss').Config} */ module.exports = { diff --git a/locales/en.json b/translations/en.json similarity index 58% rename from locales/en.json rename to translations/en.json index 6dc0a8d..4cc6ab2 100644 --- a/locales/en.json +++ b/translations/en.json @@ -12,7 +12,7 @@ "randomOption": "Random option", "randomOptionDetail": "The options after random shuffling will be displayed, and the original order will be restored after displaying the answer", "selMenu": "Text selection menu", - "selMenuDetail": "After selecting the text, a menu will pop up, and you can click to quickly jump to Google Search or Google Translate to view information related to the selected text", + "selMenuDetail": "After selecting the text, a menu will pop up, and you can click to quickly jump to tools like Google Search or Google Translate to view information related to the selected text", "hideAbout": "Hide “About”", "biggerText": "Larger question text", "day": "Days", @@ -34,5 +34,19 @@ "noScroll": "Don't auto scroll when flipping", "blurOptions": "Blur options", "blurOptionsDetail": "When enabled, options will be blurred and will become clear after clicking on the option area.", - "yourWrongAnswer": "Your wrong answer" + "yourWrongAnswer": "Your wrong answer", + "help": "Help", + "tool": "Tool", + "add": "Add", + "name": "Name", + "url": "URL", + "prefixText": "Prefix Text", + "suffixText": "Suffix Text", + "cancel": "Cancel", + "save": "Save", + "confirmDelete": "Are you sure to delete?", + "translate": "Translate", + "search": "Search", + "explainFollowing": "Explain the following content: ", + "toolHelp": "

Each tool requires a URL to be set. For example, Google Search:

https://www.google.com/search?q={q}

When you select text and click the corresponding tool, the {q} in the link will be replaced with the selected text, and the browser will automatically navigate to the updated link.

Prefix text and suffix text refer to adding text before and after the selected text, which is then used as {q} for replacement.

" } diff --git a/locales/zh.json b/translations/zh.json similarity index 58% rename from locales/zh.json rename to translations/zh.json index 2148a05..0e7177a 100644 --- a/locales/zh.json +++ b/translations/zh.json @@ -12,7 +12,7 @@ "randomOption": "随机选项", "randomOptionDetail": "开启后展示随机打乱的选项,显示答案后恢复至原顺序", "selMenu": "选中菜单", - "selMenuDetail": "选中题目文字后弹出菜单,点击可快速跳转至百度翻译、百度搜索查看选中文字相关的信息", + "selMenuDetail": "选中题目文字后弹出菜单,点击可快速跳转至百度翻译、百度搜索等工具查看选中文字相关的信息", "hideAbout": "隐藏首页“关于”", "biggerText": "大号题目文字", "day": "天", @@ -34,5 +34,19 @@ "noScroll": "翻转时不要自动滚动", "blurOptions": "选项模糊", "blurOptionsDetail": "开启后选项会被模糊,点击选项区域后恢复", - "yourWrongAnswer": "你的错误答案" + "yourWrongAnswer": "你的错误答案", + "help": "帮助", + "tool": "工具", + "add": "添加", + "name": "名称", + "url": "链接", + "prefixText": "前置文本", + "suffixText": "后置文本", + "cancel": "取消", + "save": "保存", + "confirmDelete": "确认删除吗?", + "translate": "翻译", + "search": "搜索", + "explainFollowing": "解释以下内容: ", + "toolHelp": "

每一个工具都需要设置 url。以谷歌搜索为例

https://www.google.com/search?q={q}

在选中文本后点击对应的工具时,链接中的 {q} 将会被替换为您选择的文本,然后将自动跳转到替换后的链接。

前置文本和后置文本是指在选中的文本前后添加对应的文本,然后作为 {q} 来执行替换。

" } diff --git a/tsconfig.json b/tsconfig.json index 2fd6ac1..41778b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "paths": { "@/*": ["./src/*"] - } + }, + "allowSyntheticDefaultImports": true } }