blob: 3a4cf01617c52d63784ec0ccae1f1a9c51ff97cc [file] [log] [blame]
Jiarenxiang38dcb052025-03-13 16:40:09 +08001import React, { useCallback, useEffect, useState } from 'react';
2import { Upload, Tooltip, Popover, Modal, Progress, Spin, Result } from 'antd';
3import * as AntdIcons from '@ant-design/icons';
4import { useIntl } from '@umijs/max';
5import './style.less';
6
7const allIcons: { [key: string]: any } = AntdIcons;
8
9const { Dragger } = Upload;
10interface AntdIconClassifier {
11 load: () => void;
12 predict: (imgEl: HTMLImageElement) => void;
13}
14declare global {
15 interface Window {
16 antdIconClassifier: AntdIconClassifier;
17 }
18}
19
20interface PicSearcherState {
21 loading: boolean;
22 modalOpen: boolean;
23 popoverVisible: boolean;
24 icons: iconObject[];
25 fileList: any[];
26 error: boolean;
27 modelLoaded: boolean;
28}
29
30interface iconObject {
31 type: string;
32 score: number;
33}
34
35const PicSearcher: React.FC = () => {
36 const intl = useIntl();
37 const {formatMessage} = intl;
38 const [state, setState] = useState<PicSearcherState>({
39 loading: false,
40 modalOpen: false,
41 popoverVisible: false,
42 icons: [],
43 fileList: [],
44 error: false,
45 modelLoaded: false,
46 });
47 const predict = (imgEl: HTMLImageElement) => {
48 try {
49 let icons: any[] = window.antdIconClassifier.predict(imgEl);
50 if (gtag && icons.length) {
51 gtag('event', 'icon', {
52 event_category: 'search-by-image',
53 event_label: icons[0].className,
54 });
55 }
56 icons = icons.map(i => ({ score: i.score, type: i.className.replace(/\s/g, '-') }));
57 setState(prev => ({ ...prev, loading: false, error: false, icons }));
58 } catch {
59 setState(prev => ({ ...prev, loading: false, error: true }));
60 }
61 };
62 // eslint-disable-next-line class-methods-use-this
63 const toImage = (url: string) =>
64 new Promise(resolve => {
65 const img = new Image();
66 img.setAttribute('crossOrigin', 'anonymous');
67 img.src = url;
68 img.onload = () => {
69 resolve(img);
70 };
71 });
72
73 const uploadFile = useCallback((file: File) => {
74 setState(prev => ({ ...prev, loading: true }));
75 const reader = new FileReader();
76 reader.onload = () => {
77 toImage(reader.result as string).then(predict);
78 setState(prev => ({
79 ...prev,
80 fileList: [{ uid: 1, name: file.name, status: 'done', url: reader.result }],
81 }));
82 };
83 reader.readAsDataURL(file);
84 }, []);
85
86 const onPaste = useCallback((event: ClipboardEvent) => {
87 const items = event.clipboardData && event.clipboardData.items;
88 let file = null;
89 if (items && items.length) {
90 for (let i = 0; i < items.length; i++) {
91 if (items[i].type.includes('image')) {
92 file = items[i].getAsFile();
93 break;
94 }
95 }
96 }
97 if (file) {
98 uploadFile(file);
99 }
100 }, []);
101 const toggleModal = useCallback(() => {
102 setState(prev => ({
103 ...prev,
104 modalOpen: !prev.modalOpen,
105 popoverVisible: false,
106 fileList: [],
107 icons: [],
108 }));
109 if (!localStorage.getItem('disableIconTip')) {
110 localStorage.setItem('disableIconTip', 'true');
111 }
112 }, []);
113
114 useEffect(() => {
115 const script = document.createElement('script');
116 script.onload = async () => {
117 await window.antdIconClassifier.load();
118 setState(prev => ({ ...prev, modelLoaded: true }));
119 document.addEventListener('paste', onPaste);
120 };
121 script.src = 'https://cdn.jsdelivr.net/gh/lewis617/antd-icon-classifier@0.0/dist/main.js';
122 document.head.appendChild(script);
123 setState(prev => ({ ...prev, popoverVisible: !localStorage.getItem('disableIconTip') }));
124 return () => {
125 document.removeEventListener('paste', onPaste);
126 };
127 }, []);
128
129 return (
130 <div className="iconPicSearcher">
131 <Popover
132 content={formatMessage({id: 'app.docs.components.icon.pic-searcher.intro'})}
133 open={state.popoverVisible}
134 >
135 <AntdIcons.CameraOutlined className="icon-pic-btn" onClick={toggleModal} />
136 </Popover>
137 <Modal
138 title={intl.formatMessage({
139 id: 'app.docs.components.icon.pic-searcher.title',
140 defaultMessage: '信息',
141 })}
142 open={state.modalOpen}
143 onCancel={toggleModal}
144 footer={null}
145 >
146 {state.modelLoaded || (
147 <Spin
148 spinning={!state.modelLoaded}
149 tip={formatMessage({
150 id: 'app.docs.components.icon.pic-searcher.modelloading',
151
152 })}
153 >
154 <div style={{ height: 100 }} />
155 </Spin>
156 )}
157 {state.modelLoaded && (
158 <Dragger
159 accept="image/jpeg, image/png"
160 listType="picture"
161 customRequest={o => uploadFile(o.file as File)}
162 fileList={state.fileList}
163 showUploadList={{ showPreviewIcon: false, showRemoveIcon: false }}
164 >
165 <p className="ant-upload-drag-icon">
166 <AntdIcons.InboxOutlined />
167 </p>
168 <p className="ant-upload-text">
169 {formatMessage({id: 'app.docs.components.icon.pic-searcher.upload-text'})}
170 </p>
171 <p className="ant-upload-hint">
172 {formatMessage({id: 'app.docs.components.icon.pic-searcher.upload-hint'})}
173 </p>
174 </Dragger>
175 )}
176 <Spin
177 spinning={state.loading}
178 tip={formatMessage({id: 'app.docs.components.icon.pic-searcher.matching'})}
179 >
180 <div className="icon-pic-search-result">
181 {state.icons.length > 0 && (
182 <div className="result-tip">
183 {formatMessage({id: 'app.docs.components.icon.pic-searcher.result-tip'})}
184 </div>
185 )}
186 <table>
187 {state.icons.length > 0 && (
188 <thead>
189 <tr>
190 <th className="col-icon">
191 {formatMessage({id: 'app.docs.components.icon.pic-searcher.th-icon'})}
192 </th>
193 <th>{formatMessage({id: 'app.docs.components.icon.pic-searcher.th-score'})}</th>
194 </tr>
195 </thead>
196 )}
197 <tbody>
198 {state.icons.map(icon => {
199 const { type } = icon;
200 const iconName = `${type
201 .split('-')
202 .map(str => `${str[0].toUpperCase()}${str.slice(1)}`)
203 .join('')}Outlined`;
204 return (
205 <tr key={iconName}>
206 <td className="col-icon">
207 <Tooltip title={icon.type} placement="right">
208 {React.createElement(allIcons[iconName])}
209 </Tooltip>
210 </td>
211 <td>
212 <Progress percent={Math.ceil(icon.score * 100)} />
213 </td>
214 </tr>
215 );
216 })}
217 </tbody>
218 </table>
219 {state.error && (
220 <Result
221 status="500"
222 title="503"
223 subTitle={formatMessage({id: 'app.docs.components.icon.pic-searcher.server-error'})}
224 />
225 )}
226 </div>
227 </Spin>
228 </Modal>
229 </div>
230 );
231};
232
233export default PicSearcher;