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/build/rollup.js b/build/rollup.js index 5fec1a4..9bd8e3c 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({ @@ -164,6 +161,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/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..cb2f805 --- /dev/null +++ b/src/components/tools/edit.tsx @@ -0,0 +1,65 @@ +import { Button } from '../button'; +import { Input } from '../input'; +import { Tool } from '@/store/tools'; +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..958cd1e --- /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('Are you sure to delete?')) { + 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..3dd5c3d --- /dev/null +++ b/src/store/tools.ts @@ -0,0 +1,37 @@ +import { atomWithGlobalStorage } from '@/utils/storage'; +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: 'Search', + url: + locale === 'zh' + ? 'https://www.baidu.com/#wd={q}' + : 'https://www.google.com/search?q={q}', + }, + { + id: 'translate', + name: '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: 'Explain following content: ', + 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 73% rename from locales/en.json rename to translations/en.json index 6dc0a8d..ff1c4bf 100644 --- a/locales/en.json +++ b/translations/en.json @@ -34,5 +34,7 @@ "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", + "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 72% rename from locales/zh.json rename to translations/zh.json index 2148a05..ac478c8 100644 --- a/locales/zh.json +++ b/translations/zh.json @@ -34,5 +34,7 @@ "noScroll": "翻转时不要自动滚动", "blurOptions": "选项模糊", "blurOptionsDetail": "开启后选项会被模糊,点击选项区域后恢复", - "yourWrongAnswer": "你的错误答案" + "yourWrongAnswer": "你的错误答案", + "help": "帮助", + "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 } }