Jiarenxiang | 38dcb05 | 2025-03-13 16:40:09 +0800 | [diff] [blame] | 1 | import React, { useCallback, useEffect, useState } from 'react'; |
| 2 | import { Upload, Tooltip, Popover, Modal, Progress, Spin, Result } from 'antd'; |
| 3 | import * as AntdIcons from '@ant-design/icons'; |
| 4 | import { useIntl } from '@umijs/max'; |
| 5 | import './style.less'; |
| 6 | |
| 7 | const allIcons: { [key: string]: any } = AntdIcons; |
| 8 | |
| 9 | const { Dragger } = Upload; |
| 10 | interface AntdIconClassifier { |
| 11 | load: () => void; |
| 12 | predict: (imgEl: HTMLImageElement) => void; |
| 13 | } |
| 14 | declare global { |
| 15 | interface Window { |
| 16 | antdIconClassifier: AntdIconClassifier; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | interface PicSearcherState { |
| 21 | loading: boolean; |
| 22 | modalOpen: boolean; |
| 23 | popoverVisible: boolean; |
| 24 | icons: iconObject[]; |
| 25 | fileList: any[]; |
| 26 | error: boolean; |
| 27 | modelLoaded: boolean; |
| 28 | } |
| 29 | |
| 30 | interface iconObject { |
| 31 | type: string; |
| 32 | score: number; |
| 33 | } |
| 34 | |
| 35 | const 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 | |
| 233 | export default PicSearcher; |