@forge-kit/plugin-qr-code 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg width="192" height="192" viewBox="0 0 192 192" fill="" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M85 0H8C3.575 0 0 3.575 0 8V85C0 86.1 0.9 87 2 87H85C86.1 87 87 86.1 87 85V2C87 0.9 86.1 0 85 0ZM71 71H16V16H71V71Z" />
3
+ <path d="M36.5 52.5H50.5C51.6 52.5 52.5 51.6 52.5 50.5V36.5C52.5 35.4 51.6 34.5 50.5 34.5H36.5C35.4 34.5 34.5 35.4 34.5 36.5V50.5C34.5 51.6 35.4 52.5 36.5 52.5ZM85 105H2C0.9 105 0 105.9 0 107V184C0 188.425 3.575 192 8 192H85C86.1 192 87 191.1 87 190V107C87 105.9 86.1 105 85 105ZM71 176H16V121H71V176Z" />
4
+ <path d="M36.5 157.5H50.5C51.6 157.5 52.5 156.6 52.5 155.5V141.5C52.5 140.4 51.6 139.5 50.5 139.5H36.5C35.4 139.5 34.5 140.4 34.5 141.5V155.5C34.5 156.6 35.4 157.5 36.5 157.5ZM184 0H107C105.9 0 105 0.9 105 2V85C105 86.1 105.9 87 107 87H190C191.1 87 192 86.1 192 85V8C192 3.575 188.425 0 184 0ZM176 71H121V16H176V71Z" />
5
+ <path d="M141.5 52.5H155.5C156.6 52.5 157.5 51.6 157.5 50.5V36.5C157.5 35.4 156.6 34.5 155.5 34.5H141.5C140.4 34.5 139.5 35.4 139.5 36.5V50.5C139.5 51.6 140.4 52.5 141.5 52.5ZM190 105H178C176.9 105 176 105.9 176 107V140.5H156.5V107C156.5 105.9 155.6 105 154.5 105H107C105.9 105 105 105.9 105 107V190C105 191.1 105.9 192 107 192H119C120.1 192 121 191.1 121 190V129H140.5V154.5C140.5 155.6 141.4 156.5 142.5 156.5H190C191.1 156.5 192 155.6 192 154.5V107C192 105.9 191.1 105 190 105Z" />
6
+ <path d="M154.5 176H142.5C141.4 176 140.5 176.9 140.5 178V190C140.5 191.1 141.4 192 142.5 192H154.5C155.6 192 156.5 191.1 156.5 190V178C156.5 176.9 155.6 176 154.5 176ZM190 176H178C176.9 176 176 176.9 176 178V190C176 191.1 176.9 192 178 192H190C191.1 192 192 191.1 192 190V178C192 176.9 191.1 176 190 176Z" />
7
+ </svg>
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@forge-kit/plugin-qr-code",
3
+ "entry": "./dist/app.js",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "files": [
8
+ "dist",
9
+ "src"
10
+ ],
11
+ "author": "yu.pan <panyupy@vip.qq.com>",
12
+ "license": "MIT",
13
+ "version": "0.0.1",
14
+ "type": "module",
15
+ "scripts": {
16
+ "dev": "vite",
17
+ "build": "tsc -b && vite build",
18
+ "lint": "eslint .",
19
+ "preview": "vite preview"
20
+ },
21
+ "dependencies": {
22
+ "@forge-kit/component": "workspace:*",
23
+ "@forge-kit/icons": "workspace:*",
24
+ "@forge-kit/types": "workspace:*",
25
+ "classnames": "^2.5.1",
26
+ "jsqr": "^1.4.0",
27
+ "lodash-es": "^4.17.23",
28
+ "qrcode": "^1.5.4",
29
+ "react": "^19.2.0",
30
+ "react-dom": "^19.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.39.1",
34
+ "@forge-kit/vite-plugin-forge-kit": "workspace:*",
35
+ "@types/lodash-es": "^4.17.12",
36
+ "@types/node": "^24.10.1",
37
+ "@types/qrcode": "^1.5.6",
38
+ "@types/react": "^19.2.5",
39
+ "@types/react-dom": "^19.2.3",
40
+ "@vitejs/plugin-react": "^5.1.1",
41
+ "eslint": "^9.39.1",
42
+ "eslint-plugin-react-hooks": "^7.0.1",
43
+ "eslint-plugin-react-refresh": "^0.4.24",
44
+ "globals": "^16.5.0",
45
+ "typescript": "~5.9.3",
46
+ "typescript-eslint": "^8.46.4",
47
+ "vite": "npm:rolldown-vite@7.2.5"
48
+ },
49
+ "overrides": {
50
+ "vite": "npm:rolldown-vite@7.2.5"
51
+ }
52
+ }
package/src/App.less ADDED
@@ -0,0 +1,34 @@
1
+ html,
2
+ body,
3
+ #root {
4
+ -webkit-app-region: drag;
5
+ background-color: transparent;
6
+ margin: 0;
7
+ height: 100%;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .forge-kit-plugin-qr-code {
12
+ width: 100%;
13
+ height: 100%;
14
+ overflow: hidden;
15
+
16
+ .qr-tabs {
17
+ display: flex;
18
+ flex-direction: column;
19
+ width: 100%;
20
+ overflow: hidden;
21
+ height: 100%;
22
+
23
+ .tab-list {
24
+ flex-shrink: 0;
25
+ }
26
+
27
+ .tab-panel {
28
+ flex: 1;
29
+ margin-top: var(--space-3);
30
+ overflow: hidden;
31
+ height: max-content;
32
+ }
33
+ }
34
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,33 @@
1
+ import React from 'react'
2
+ import {Theme, Tabs} from '@forge-kit/component'
3
+ import '@forge-kit/component/style.css'
4
+ import {QrDecoderPanel} from '@/components/qr-decoder-panel'
5
+ import {QrGeneratorPanel} from '@/components/qr-generator-panel'
6
+ import './App.less'
7
+
8
+ interface AppProps {
9
+ themeProps?: React.ComponentProps<typeof Theme>
10
+ }
11
+
12
+ export const App: React.FC<AppProps> = (props) => {
13
+ const {themeProps} = props
14
+
15
+ return (
16
+ <Theme className="forge-kit-plugin-qr-code" accentColor="gray" appearance="dark" radius="full" grayColor="slate" panelBackground="translucent" {...themeProps}>
17
+ <Tabs.Root defaultValue="generate" className="qr-tabs">
18
+ <Tabs.List size="2" className="tab-list">
19
+ <Tabs.Trigger value="generate">生成二维码</Tabs.Trigger>
20
+ <Tabs.Trigger value="decode">解析二维码</Tabs.Trigger>
21
+ </Tabs.List>
22
+
23
+ <Tabs.Content value="generate" className="tab-panel">
24
+ <QrGeneratorPanel/>
25
+ </Tabs.Content>
26
+
27
+ <Tabs.Content value="decode" className="tab-panel">
28
+ <QrDecoderPanel/>
29
+ </Tabs.Content>
30
+ </Tabs.Root>
31
+ </Theme>
32
+ )
33
+ }
@@ -0,0 +1,58 @@
1
+ .qr-decoder-panel {
2
+ &-grid {
3
+ width: 100%;
4
+ height: 100%;
5
+ }
6
+
7
+ &-card {
8
+ height: 100%;
9
+ box-sizing: border-box;
10
+ padding: var(--space-4);
11
+ }
12
+
13
+ &-control,
14
+ &-preview {
15
+ flex: 1;
16
+ }
17
+
18
+ &-preview {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ }
23
+
24
+ &-preview-content {
25
+ width: 100%;
26
+ height: 100%;
27
+ }
28
+
29
+ &-preview-stage {
30
+ flex: 1;
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ overflow: hidden;
35
+ flex-shrink: 0;
36
+ border: 1px dashed var(--gray-7);
37
+ border-radius: var(--radius-3);
38
+ padding: var(--space-2);
39
+ background-color: var(--gray-2);
40
+ }
41
+
42
+ &-result {
43
+ border: 1px solid var(--gray-6);
44
+ border-radius: var(--radius-3);
45
+ padding: var(--space-3);
46
+ background-color: var(--gray-2);
47
+ overflow: auto;
48
+ white-space: pre-wrap;
49
+ word-break: break-all;
50
+ }
51
+
52
+ &-preview-image {
53
+ max-width: 100%;
54
+ max-height: 260px;
55
+ object-fit: contain;
56
+ border-radius: var(--radius-2);
57
+ }
58
+ }
@@ -0,0 +1,105 @@
1
+ import React, {useEffect} from 'react'
2
+ import {Box, Button, Card, Flex, Text} from '@forge-kit/component'
3
+ import classNames from "classnames";
4
+ import {UploadFileView} from '@/components/upload-file-view'
5
+ import {decodeQrFromImageUrl} from '@/utils/qr-code'
6
+ import {usePartialState} from '@/hooks/use-partial-state'
7
+ import {useToRef} from '@/hooks/use-to-ref'
8
+ import './qr-decoder-panel.less'
9
+
10
+ interface DecoderState {
11
+ decodeText: string
12
+ decodeError: string
13
+ decodeImageName: string
14
+ decodeImageUrl: string
15
+ isDecoding: boolean
16
+ }
17
+
18
+ const DEFAULT_DECODER_STATE: DecoderState = {
19
+ decodeText: '',
20
+ decodeError: '',
21
+ decodeImageName: '',
22
+ decodeImageUrl: '',
23
+ isDecoding: false,
24
+ }
25
+
26
+ export const QrDecoderPanel: React.FC = () => {
27
+ const {state: decoderState, updateState: updateDecoderState} = usePartialState<DecoderState>(DEFAULT_DECODER_STATE)
28
+
29
+ const revokeDecodeImageUrl = useToRef(() => {
30
+ if (!decoderState.decodeImageUrl) return
31
+ URL.revokeObjectURL(decoderState.decodeImageUrl)
32
+ })
33
+
34
+ useEffect(() => {
35
+ const revokeDecodeImageUrlFn = revokeDecodeImageUrl.current
36
+ return () => revokeDecodeImageUrlFn()
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ }, [decoderState.decodeImageUrl])
39
+
40
+ const handleDecodeFile = async (file: File) => {
41
+ const imageUrl = URL.createObjectURL(file)
42
+ revokeDecodeImageUrl.current()
43
+
44
+ updateDecoderState({
45
+ decodeImageName: file.name,
46
+ decodeImageUrl: imageUrl,
47
+ decodeError: '',
48
+ decodeText: '',
49
+ isDecoding: true,
50
+ })
51
+
52
+ try {
53
+ const result = await decodeQrFromImageUrl(imageUrl)
54
+ updateDecoderState({decodeText: result})
55
+ } catch (error) {
56
+ const errorMessage = error instanceof Error ? error.message : '二维码识别失败,请重试'
57
+ updateDecoderState({decodeError: errorMessage})
58
+ } finally {
59
+ updateDecoderState({isDecoding: false})
60
+ }
61
+ }
62
+
63
+ const handleInvalidFile = () => {
64
+ updateDecoderState({decodeError: '仅支持上传图片文件'})
65
+ }
66
+
67
+ const handleCopyDecodedText = async () => {
68
+ if (!decoderState.decodeText) return
69
+ try {
70
+ await navigator.clipboard.writeText(decoderState.decodeText)
71
+ } catch {
72
+ updateDecoderState({decodeError: '复制失败,请手动复制识别结果'})
73
+ }
74
+ }
75
+
76
+ return (
77
+ <Flex className="qr-decoder-panel-grid" gap="4">
78
+ <Card className={classNames('qr-decoder-panel-card', 'qr-decoder-panel-control')}>
79
+ <Flex direction="column" gap="3">
80
+ <Text size="2" weight="medium">上传二维码图片</Text>
81
+ <UploadFileView onFileSelected={handleDecodeFile} onInvalidFile={handleInvalidFile}/>
82
+ {decoderState.isDecoding && <Text size="1" color="gray">识别中...</Text>}
83
+ {Boolean(decoderState.decodeError) && <Text size="1" color="red">{decoderState.decodeError}</Text>}
84
+ {Boolean(decoderState.decodeImageName) && <Text size="1" color="gray">当前文件: {decoderState.decodeImageName}</Text>}
85
+ </Flex>
86
+ </Card>
87
+
88
+ <Card className={classNames('qr-decoder-panel-card', 'qr-decoder-panel-preview')}>
89
+ <Flex direction="column" gap="3" className="qr-decoder-panel-preview-content">
90
+ <Box className="qr-decoder-panel-preview-stage">
91
+ {Boolean(decoderState.decodeImageUrl) && (<img className="qr-decoder-panel-preview-image" src={decoderState.decodeImageUrl} alt="上传二维码预览"/>)}
92
+ {!decoderState.decodeImageUrl && <Text color="gray">上传图片后在此预览</Text>}
93
+ </Box>
94
+
95
+ <Box className="qr-decoder-panel-result">
96
+ {Boolean(decoderState.decodeText) && <Text>{decoderState.decodeText}</Text>}
97
+ {!decoderState.decodeText && <Text color="gray">识别结果会显示在这里</Text>}
98
+ </Box>
99
+
100
+ <Button variant="soft" disabled={!decoderState.decodeText} onClick={handleCopyDecodedText}>复制结果</Button>
101
+ </Flex>
102
+ </Card>
103
+ </Flex>
104
+ )
105
+ }
@@ -0,0 +1,57 @@
1
+ .qr-generator-panel {
2
+ width: 100%;
3
+ height: 100%;
4
+
5
+ &-card {
6
+ height: 100%;
7
+ box-sizing: border-box;
8
+ padding: var(--space-4);
9
+ }
10
+
11
+ &-control,
12
+ &-preview {
13
+ flex: 1;
14
+ }
15
+
16
+ &-preview {
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ }
21
+
22
+ &-preview-content {
23
+ width: 100%;
24
+ height: 100%;
25
+ }
26
+
27
+ &-preview-stage {
28
+ flex: 1;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ overflow: hidden;
33
+ }
34
+
35
+ &-textarea {
36
+ margin-top: var(--space-2);
37
+ min-height: 140px;
38
+ }
39
+
40
+ &-field-label {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: 6px;
44
+ min-width: 120px;
45
+ flex: 1;
46
+ }
47
+
48
+ &-preview-image {
49
+ max-width: 100%;
50
+ max-height: 100%;
51
+ object-fit: contain;
52
+ border-radius: var(--radius-2);
53
+ border: 1px solid var(--gray-6);
54
+ padding: var(--space-2);
55
+ background: #fff;
56
+ }
57
+ }
@@ -0,0 +1,154 @@
1
+ import React, {useEffect} from 'react'
2
+ import {Box, Button, Card, Flex, Text, TextArea, Select, TextField} from '@forge-kit/component'
3
+ import {
4
+ downloadDataUrl,
5
+ generateQrDataUrl,
6
+ QR_ERROR_CORRECTION_LEVEL_OPTIONS,
7
+ } from '@/utils/qr-code'
8
+ import classNames from "classnames";
9
+ import type {QRCodeErrorCorrectionLevel} from 'qrcode'
10
+ import {useDebouncedCallback} from '@/hooks/use-debounced-callback'
11
+ import {useLatestTaskGuard} from '@/hooks/use-latest-task-guard'
12
+ import {usePartialState} from '@/hooks/use-partial-state'
13
+ import {useToRef} from '@/hooks/use-to-ref'
14
+ import './qr-generator-panel.less'
15
+
16
+ interface GeneratorConfigState {
17
+ text: string
18
+ size: number
19
+ margin: number
20
+ errorCorrectionLevel: QRCodeErrorCorrectionLevel
21
+ }
22
+
23
+ interface GeneratorResultState {
24
+ qrDataUrl: string
25
+ generateError: string
26
+ isGenerating: boolean
27
+ }
28
+
29
+ const DEFAULT_GENERATOR_CONFIG: GeneratorConfigState = {
30
+ text: '',
31
+ size: 320,
32
+ margin: 2,
33
+ errorCorrectionLevel: 'M',
34
+ }
35
+
36
+ const DEFAULT_GENERATOR_RESULT: GeneratorResultState = {
37
+ qrDataUrl: '',
38
+ generateError: '',
39
+ isGenerating: false,
40
+ }
41
+
42
+ export const QrGeneratorPanel: React.FC = () => {
43
+ const {state: configState, updateState: updateConfigState} = usePartialState<GeneratorConfigState>(DEFAULT_GENERATOR_CONFIG)
44
+ const {state: resultState, updateState: updateResultState} = usePartialState<GeneratorResultState>(DEFAULT_GENERATOR_RESULT)
45
+
46
+ const generateLatestTaskGuardQrDataUrl = useLatestTaskGuard(async (nextConfig: GeneratorConfigState) => {
47
+ const {text, size, margin, errorCorrectionLevel} = nextConfig
48
+ return generateQrDataUrl(text.trim(), {width: size, margin, errorCorrectionLevel})
49
+ })
50
+
51
+ const runGenerateByConfig = useToRef(async (nextConfig: GeneratorConfigState) => {
52
+ const normalizedText = nextConfig.text.trim()
53
+
54
+ if (!normalizedText) {
55
+ updateResultState({generateError: '请输入二维码内容', qrDataUrl: '', isGenerating: false})
56
+ return
57
+ }
58
+
59
+ updateResultState({isGenerating: true, generateError: ''})
60
+ try {
61
+ const dataUrl = await generateLatestTaskGuardQrDataUrl(nextConfig)
62
+ if (dataUrl === undefined) return
63
+ updateResultState({qrDataUrl: dataUrl, isGenerating: false})
64
+ } catch {
65
+ updateResultState({generateError: '二维码生成失败,请检查输入后重试', qrDataUrl: '', isGenerating: false})
66
+ }
67
+ })
68
+
69
+ const debouncedGenerate = useDebouncedCallback((nextConfig: GeneratorConfigState) => {
70
+ return runGenerateByConfig.current(nextConfig)
71
+ }, 300)
72
+
73
+ useEffect(() => {
74
+ debouncedGenerate(configState)
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [configState])
77
+
78
+ const handleDownload = () => {
79
+ if (!resultState.qrDataUrl) return
80
+ downloadDataUrl(resultState.qrDataUrl, 'qrcode.png')
81
+ }
82
+
83
+ return (
84
+ <Flex className="qr-generator-panel" gap="4">
85
+ <Card className={classNames('qr-generator-panel-card', 'qr-generator-panel-control')}>
86
+ <Flex direction="column" gap="3">
87
+ <Box>
88
+ <Text size="1" color="gray">内容</Text>
89
+ <TextArea
90
+ className="qr-generator-panel-textarea"
91
+ size="2"
92
+ value={configState.text}
93
+ placeholder="输入文本、链接或任意内容"
94
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => updateConfigState({text: event.target.value})}
95
+ />
96
+ </Box>
97
+
98
+ <Flex gap="3" wrap="wrap">
99
+ <Flex className="qr-generator-panel-field-label">
100
+ <Text size="1" color="gray">尺寸(px)</Text>
101
+ <TextField.Root
102
+ type="number"
103
+ radius="large"
104
+ min={120}
105
+ max={1024}
106
+ value={configState.size}
107
+ onChange={(event) => updateConfigState({size: Number(event.target.value) || DEFAULT_GENERATOR_CONFIG.size})}
108
+ />
109
+ </Flex>
110
+
111
+ <Flex className="qr-generator-panel-field-label">
112
+ <Text size="1" color="gray">边距</Text>
113
+ <TextField.Root
114
+ type="number"
115
+ radius="large"
116
+ min={0}
117
+ max={12}
118
+ value={configState.margin}
119
+ onChange={(event) => updateConfigState({margin: Number(event.target.value) || 0})}
120
+ />
121
+ </Flex>
122
+
123
+ <Flex className="qr-generator-panel-field-label">
124
+ <Text size="1" color="gray">容错级别</Text>
125
+ <Select.Root
126
+ value={configState.errorCorrectionLevel}
127
+ onValueChange={(value: QRCodeErrorCorrectionLevel) => updateConfigState({errorCorrectionLevel: value})}
128
+ >
129
+ <Select.Trigger radius="large"/>
130
+ <Select.Content>
131
+ {QR_ERROR_CORRECTION_LEVEL_OPTIONS.map((level) => (
132
+ <Select.Item key={level} value={level}>{level}</Select.Item>
133
+ ))}
134
+ </Select.Content>
135
+ </Select.Root>
136
+ </Flex>
137
+ </Flex>
138
+ {resultState.isGenerating && <Text size="1" color="gray">生成中...</Text>}
139
+ {Boolean(resultState.generateError) && <Text size="1" color="red">{resultState.generateError}</Text>}
140
+ </Flex>
141
+ </Card>
142
+
143
+ <Card className={classNames('qr-generator-panel-card', 'qr-generator-panel-preview')}>
144
+ <Flex direction="column" gap="3" className="qr-generator-panel-preview-content">
145
+ <Box className="qr-generator-panel-preview-stage">
146
+ {Boolean(resultState.qrDataUrl) && (<img className="qr-generator-panel-preview-image" src={resultState.qrDataUrl} alt="二维码预览"/>)}
147
+ {!resultState.qrDataUrl && <Text color="gray">输入内容后将自动生成二维码</Text>}
148
+ </Box>
149
+ <Button variant="soft" onClick={handleDownload} disabled={!resultState.qrDataUrl}>下载 PNG</Button>
150
+ </Flex>
151
+ </Card>
152
+ </Flex>
153
+ )
154
+ }
@@ -0,0 +1,26 @@
1
+ .upload-file-view {
2
+ &-input {
3
+ display: none;
4
+ }
5
+
6
+ &-dropzone {
7
+ min-height: 180px;
8
+ border: 1px dashed var(--gray-7);
9
+ border-radius: var(--radius-3);
10
+ background-color: var(--gray-2);
11
+ padding: var(--space-3);
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ cursor: pointer;
16
+ transition: border-color 0.2s ease, background-color 0.2s ease;
17
+ outline: none;
18
+ }
19
+
20
+ &-dropzone:hover,
21
+ &-dropzone:focus-visible,
22
+ &-dropzone.is-drag-over {
23
+ border-color: var(--gray-10);
24
+ background-color: var(--gray-3);
25
+ }
26
+ }
@@ -0,0 +1,80 @@
1
+ import React, {useRef, useState} from 'react'
2
+ import {Box, Flex, Text} from '@forge-kit/component'
3
+ import {getImageFileFromClipboard, getImageFileFromFileList} from '@/utils/qr-code'
4
+ import classNames from 'classnames'
5
+ import './upload-file-view.less'
6
+
7
+ interface UploadFileViewProps {
8
+ helperText?: string
9
+ onFileSelected: (file: File) => Promise<void> | void
10
+ onInvalidFile?: () => void
11
+ }
12
+
13
+ export const UploadFileView: React.FC<UploadFileViewProps> = (props) => {
14
+ const {helperText = '支持拖动、Ctrl/Cmd+V 粘贴、点击上传', onFileSelected, onInvalidFile} = props
15
+ const [isDragOver, setIsDragOver] = useState(false)
16
+ const fileInputRef = useRef<HTMLInputElement>(null)
17
+
18
+ const handleInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
19
+ const file = getImageFileFromFileList(event.target.files)
20
+ if (!file) return
21
+ await onFileSelected(file)
22
+ event.target.value = ''
23
+ }
24
+
25
+ const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
26
+ event.preventDefault()
27
+ setIsDragOver(false)
28
+
29
+ const file = getImageFileFromFileList(event.dataTransfer.files)
30
+ if (!file) {
31
+ if (event.dataTransfer.files.length > 0) onInvalidFile?.()
32
+ return
33
+ }
34
+ await onFileSelected(file)
35
+ }
36
+
37
+ const handlePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
38
+ const file = getImageFileFromClipboard(event.clipboardData)
39
+ if (!file) return
40
+ event.preventDefault()
41
+ await onFileSelected(file)
42
+ }
43
+
44
+ const handleChooseFileClick = () => {
45
+ fileInputRef.current?.click()
46
+ }
47
+
48
+ const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
49
+ event.preventDefault()
50
+ setIsDragOver(true)
51
+ }
52
+
53
+ const handleDragLeave = () => {
54
+ setIsDragOver(false)
55
+ }
56
+
57
+ return (
58
+ <Flex className="upload-file-view" direction="column" gap="2">
59
+ <Text size="1" color="gray">{helperText}</Text>
60
+ <input
61
+ ref={fileInputRef}
62
+ className="upload-file-view-input"
63
+ type="file"
64
+ accept="image/*"
65
+ onChange={handleInputChange}
66
+ />
67
+ <Box
68
+ className={classNames('upload-file-view-dropzone', {'is-drag-over': isDragOver})}
69
+ tabIndex={0}
70
+ onClick={handleChooseFileClick}
71
+ onPaste={handlePaste}
72
+ onDragOver={handleDragOver}
73
+ onDragLeave={handleDragLeave}
74
+ onDrop={handleDrop}
75
+ >
76
+ <Text align="center" color="gray">拖拽 / 点击 / 粘贴 上传</Text>
77
+ </Box>
78
+ </Flex>
79
+ )
80
+ }
@@ -0,0 +1,14 @@
1
+ import debounce from 'lodash-es/debounce'
2
+ import {useEffect, useMemo} from 'react'
3
+
4
+ export const useDebouncedCallback = <Args extends unknown[]>(callback: (...args: Args) => void, wait: number) => {
5
+ const debouncedCallback = useMemo(() => {
6
+ return debounce(callback, wait)
7
+ }, [callback, wait])
8
+
9
+ useEffect(() => {
10
+ return () => debouncedCallback.cancel()
11
+ }, [debouncedCallback])
12
+
13
+ return debouncedCallback
14
+ }
@@ -0,0 +1,21 @@
1
+ import {useCallback, useRef} from 'react'
2
+
3
+ export const useLatestTaskGuard = <Args extends unknown[], Result>(
4
+ execute: (...args: Args) => Promise<Result>,
5
+ ) => {
6
+ const latestTaskIdRef = useRef(0)
7
+
8
+ return useCallback(async (...args: Args): Promise<Result | undefined> => {
9
+ latestTaskIdRef.current += 1
10
+ const currentTaskId = latestTaskIdRef.current
11
+
12
+ try {
13
+ const result = await execute(...args)
14
+ if (currentTaskId !== latestTaskIdRef.current) return
15
+ return result
16
+ } catch (error) {
17
+ if (currentTaskId !== latestTaskIdRef.current) return
18
+ throw error
19
+ }
20
+ }, [execute])
21
+ }
@@ -0,0 +1,15 @@
1
+ import {useCallback, useState} from 'react'
2
+
3
+ export const usePartialState = <T extends object>(initialState: T) => {
4
+ const [state, setState] = useState<T>(initialState)
5
+
6
+ const updateState = useCallback((patch: Partial<T>) => {
7
+ setState((prev) => ({...prev, ...patch}))
8
+ }, [])
9
+
10
+ return {
11
+ state,
12
+ setState,
13
+ updateState,
14
+ }
15
+ }
@@ -0,0 +1,11 @@
1
+ import {useEffect, useRef} from 'react'
2
+
3
+ export const useToRef = <T>(value: T) => {
4
+ const valueRef = useRef<T>(value)
5
+
6
+ useEffect(() => {
7
+ valueRef.current = value
8
+ }, [value])
9
+
10
+ return valueRef
11
+ }