{"id":21518493,"url":"https://github.com/sunxiuguo/visualclipboard","last_synced_at":"2025-04-09T22:04:39.112Z","repository":{"id":37194939,"uuid":"187028558","full_name":"sunxiuguo/VisualClipboard","owner":"sunxiuguo","description":"A clipboard app build with electron-react-boilerplate","archived":false,"fork":false,"pushed_at":"2022-12-15T23:34:56.000Z","size":3028,"stargazers_count":6,"open_issues_count":30,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2025-04-09T22:04:25.232Z","etag":null,"topics":["electron","hooks","js","nodejs","react"],"latest_commit_sha":null,"homepage":"https://clipboard.zhusun.club","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sunxiuguo.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2019-05-16T12:58:44.000Z","updated_at":"2025-01-16T01:31:04.000Z","dependencies_parsed_at":"2023-01-29T05:16:00.116Z","dependency_job_id":null,"html_url":"https://github.com/sunxiuguo/VisualClipboard","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sunxiuguo%2FVisualClipboard","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sunxiuguo%2FVisualClipboard/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sunxiuguo%2FVisualClipboard/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sunxiuguo%2FVisualClipboard/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sunxiuguo","download_url":"https://codeload.github.com/sunxiuguo/VisualClipboard/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248119296,"owners_count":21050755,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["electron","hooks","js","nodejs","react"],"created_at":"2024-11-24T00:52:21.718Z","updated_at":"2025-04-09T22:04:39.084Z","avatar_url":"https://github.com/sunxiuguo.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n![Visual Clipboard](https://user-gold-cdn.xitu.io/2020/2/14/17042e20a1755c98?w=1383\u0026h=887\u0026f=png\u0026s=173305)\n\n\u003c/div\u003e\n\n## 本地运行\n\n`npm run start`\n\n## 打包\n\n`npm run build`\n\n## 介绍\n\nVisualClipBoard 是一款剪贴板工具，能够记录您复制、剪切的所有图片、文本、超文本历史。  \n此项目基于 electron-react-boilerplate 搭建, 启动后后台运行, 无论在何处复制都会被记录在 visualClipboard 中。\n\n可以看这篇文章 https://juejin.im/post/5e43bc71e51d45270c27735e\n\n## 支持格式\n\n-   支持文本\n-   支持图片\n-   支持 excel 表格\n-   支持 html\n\n## 特性\n\n-   自动存储您所复制的内容\n-   存储 7 天的数据，过期数据自动清理\n-   关键字筛选\n-   列表瀑布流\n-   列表缩略显示，点击弹窗显示详情\n\n## 背景\n\n女票：有的时候复制粘贴过的内容还想再看一下，然而又忘了原来的内容是在哪了，找起来还挺麻烦的\n\n我：看爸爸给你写个 app，允你免费试用！\n\n女票：？？给你脸了？\n\n![](https://user-gold-cdn.xitu.io/2020/2/14/17042da62a7e8d56?w=283\u0026h=268\u0026f=jpeg\u0026s=8608)\n\n## 动手\n\n`咳咳 是动手开始写代码, 不是被女票动手打`\n\n虽然从来没写过 electron，但是记得这货是支持 [剪贴板 API](https://www.electronjs.org/docs/api/clipboard) 的，那就撸袖子开始干，就当练练手了！\n\n首先明确我们的目标：\n\n-   实时获取系统剪贴板的内容（包括但不限于文本、图像）\n-   存储获取到的信息\n-   展示存储的信息列表\n-   能够快速查看某一项纪录并再次复制\n-   支持关键字搜索\n\n### 监听系统剪贴板\n\n监听系统剪贴板，暂时的实现是定时去读剪贴板当前的内容，定时任务使用的是 node-schedule，可以很方便地设置频率。\n\n```javascript\n// 这里是每秒都去拿一次剪贴板的内容，然后进行存储\nstartWatching = () =\u003e {\n    if (!this.watcherId) {\n        this.watcherId = schedule.scheduleJob('* * * * * *', () =\u003e {\n            Clipboard.writeImage();\n            Clipboard.writeHtml();\n        });\n    }\n    return clipboard;\n};\n```\n\n### 存储\n\n目前只是本地应用，还没有做多端的同步，所以直接用了 indexDB 来做存储。  \n上面代码中的`Clipboard.writeImage()`以及`Clipboard.writeHtml()`就是向 indexDB 中写入。\n\n-   **文本的存储很简单，直接读取，写入即可**\n\n```javascript\nstatic writeHtml() {\n    if (Clipboard.isDiffText(this.previousText, clipboard.readText())) {\n        this.previousText = clipboard.readText();\n        Db.add('html', {\n            createTime: Date.now(),\n            html: clipboard.readHTML(),\n            content: this.previousText\n        });\n    }\n}\n```\n\n-   **图像这里就比较坑了**\n\n    `老哥们如果有更好的方法欢迎提出，我学习一波。因为我是第一次写，贼菜，实在没想到其他的方法...`\n\n1. 从剪贴板读取到的是 NativeImage 对象\n2. 本来想转换为 base64 存储，尝试过后放弃了，因为存储的内容太大了，会非常卡。\n3. 最终实现是将读到的图像存储为本地临时文件,以{md5}.jpeg 命名\n4. indexDB 中直接存储 md5 值，使用的时候直接用 md5.jpeg 访问即可\n\n```javascript\nstatic writeImage() {\n    const nativeImage = clipboard.readImage();\n\n    const jpegBufferLow = nativeImage.toJPEG(jpegQualityLow);\n    const md5StringLow = md5(jpegBufferLow);\n\n    if (Clipboard.isDiffText(this.previousImageMd5, md5StringLow)) {\n        this.previousImageMd5 = md5StringLow;\n        if (!nativeImage.isEmpty()) {\n            const jpegBuffer = nativeImage.toJPEG(jpegQualityHigh);\n            const md5String = md5(jpegBuffer);\n            const now = Date.now();\n            const pathByDate = `${hostPath}/${DateFormat.format(\n                now,\n                'YYYYMMDD'\n            )}`;\n            xMkdirSync(pathByDate);\n            const path = `${pathByDate}/${md5String}.jpeg`;\n            const pathLow = `${pathByDate}/${md5StringLow}.jpeg`;\n            fs.writeFileSync(pathLow, jpegBufferLow);\n\n            Db.add('image', {\n                createTime: now,\n                content: path,\n                contentLow: pathLow\n            });\n            fs.writeFile(path, jpegBuffer, err =\u003e {\n                if (err) {\n                    console.error(err);\n                }\n            });\n        }\n    }\n}\n```\n\n-   **删除过期的临时图像文件**  \n    由于图像文件我们是临时存储在硬盘里的，为了防止存有太多垃圾文件，添加了过期清理的功能。\n\n```javascript\nstartWatching = () =\u003e {\n    if (!this.deleteSchedule) {\n        this.deleteSchedule = schedule.scheduleJob('* * 1 * * *', () =\u003e {\n            Clipboard.deleteExpiredRecords();\n        });\n    }\n    return clipboard;\n};\n\nstatic deleteExpiredRecords() {\n    const now = Date.now();\n    const expiredTimeStamp = now - 1000 * 60 * 60 * 24 * 7;\n    // delete record in indexDB\n    Db.deleteByTimestamp('html', expiredTimeStamp);\n    Db.deleteByTimestamp('image', expiredTimeStamp);\n\n    // remove jpg with fs\n    const dateDirs = fs.readdirSync(hostPath);\n    dateDirs.forEach(dirName =\u003e {\n        if (\n            Number(dirName) \u003c=\n            Number(DateFormat.format(expiredTimeStamp, 'YYYYMMDD'))\n        ) {\n            rimraf(`${hostPath}/${dirName}`, error =\u003e {\n                if (error) {\n                    console.error(error);\n                }\n            });\n        }\n    });\n}\n```\n\n### 展示列表\n\n上面已经完成了定时的写入 db，接下来我们要做的是实时展示 db 中存储的内容。\n\n**1. 定义 userInterval 来准备定时刷新**\n\n```javascript\n/**\n * react hooks - useInterval\n * https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/\n */\n\nimport { useEffect, useRef } from 'react';\n\nexport default function useInterval(callback, delay) {\n    const savedCallback = useRef();\n\n    useEffect(() =\u003e {\n        savedCallback.current = callback;\n    });\n\n    useEffect(() =\u003e {\n        function tick() {\n            savedCallback.current();\n        }\n\n        // 当delay === null时, 暂停interval\n        if (delay !== null) {\n            const timer = setInterval(tick, delay);\n            return () =\u003e clearInterval(timer);\n        }\n    }, [delay]);\n}\n```\n\n**2. 使用 userInterval 展示列表**\n\n```javascript\nconst [textList, setTextList] = React.useState([]);\n\nuseInterval(() =\u003e {\n    const getTextList = async () =\u003e {\n        let textArray = await Db.get(TYPE_MAP.HTML);\n        if (searchWords) {\n            textArray = textArray.filter(\n                item =\u003e item.content.indexOf(searchWords) \u003e -1\n            );\n        }\n        if (JSON.stringify(textArray) !== JSON.stringify(textList)) {\n            setTextList(textArray);\n        }\n    };\n    if (type === TYPE_MAP.HTML) {\n        getTextList();\n    }\n}, 500);\n```\n\n### 渲染列表项\n\n我们的列表项中需要包含\n\n1. 主体内容\n2. 剪贴内容的时间\n3. 复制按钮，以更方便地复制列表项内容\n4. 对于比较长的内容，需要支持点击弹窗显示全部内容\n\n```javascript\nconst renderTextItem = props =\u003e {\n    const { columnIndex, rowIndex, data, style } = props;\n    const index = 2 * rowIndex + columnIndex;\n    const item = data[index];\n    if (!item) {\n        return null;\n    }\n\n    if (rowIndex \u003e 3) {\n        setScrollTopBtn(true);\n    } else {\n        setScrollTopBtn(false);\n    }\n\n    return (\n        \u003cCard\n            className={classes.textCard}\n            key={index}\n            style={{\n                ...style,\n                left: style.left,\n                top: style.top + recordItemGutter,\n                height: style.height - recordItemGutter,\n                width: style.width - recordItemGutter\n            }}\n        \u003e\n            \u003cCardActionArea\u003e\n                \u003cCardMedia\n                    component=\"img\"\n                    className={classes.textMedia}\n                    image={bannerImage}\n                /\u003e\n                \u003cCardContent className={classes.textItemContentContainer}\u003e\n                    ...\n                \u003c/CardContent\u003e\n            \u003c/CardActionArea\u003e\n            \u003cCardActions\n                style={{ display: 'flex', justifyContent: 'space-between' }}\n            \u003e\n                \u003cChip\n                    variant=\"outlined\"\n                    icon={\u003cAlarmIcon /\u003e}\n                    label={DateFormat.format(item.createTime)}\n                /\u003e\n                \u003cButton\n                    size=\"small\"\n                    color=\"primary\"\n                    variant=\"contained\"\n                    onClick={() =\u003e handleClickText(item.content)}\n                \u003e\n                    复制\n                \u003c/Button\u003e\n            \u003c/CardActions\u003e\n        \u003c/Card\u003e\n    );\n};\n```\n\n**从剪贴板中读到的内容，需要按照原有格式展示**\n\n恰好`clipboard.readHTML([type])`可以直接读到 html 内容，那么我们只需要正确展示 html 内容即可。\n\n```javascript\n\u003cdiv\n    dangerouslySetInnerHTML={{ __html: item.html }}\n    style={{\n        height: 300,\n        maxHeight: 300,\n        width: '100%',\n        overflow: 'scroll',\n        marginBottom: 10\n    }}\n/\u003e\n```\n\n**列表太长，还得加一个回到顶部的按钮**\n\n```javascript\n\u003cZoom in={showScrollTopBtn}\u003e\n    \u003cdiv\n        onClick={handleClickScrollTop}\n        role=\"presentation\"\n        className={classes.scrollTopBtn}\n    \u003e\n        \u003cFab color=\"secondary\" size=\"small\" aria-label=\"scroll back to top\"\u003e\n            \u003cKeyboardArrowUpIcon /\u003e\n        \u003c/Fab\u003e\n    \u003c/div\u003e\n\u003c/Zoom\u003e;\n\nconst handleClickScrollTop = () =\u003e {\n    const options = {\n        top: 0,\n        left: 0,\n        behavior: 'smooth'\n    };\n    if (textListRef.current) {\n        textListRef.current.scroll(options);\n    } else if (imageListRef.current) {\n        imageListRef.current.scroll(options);\n    }\n};\n```\n\n### 使用 react-window 优化长列表\n\n列表元素太多，浏览时间长了会卡顿，使用 react-window 来优化列表展示，可视区域内只展示固定元素数量。\n\n```javascript\nimport { FixedSizeList, FixedSizeGrid } from 'react-window';\n\nconst renderDateImageList = () =\u003e (\n    \u003cAutoSizer\u003e\n        {({ height, width }) =\u003e (\n            \u003cFixedSizeList\n                height={height}\n                width={width}\n                itemSize={400}\n                itemCount={imageList.length}\n                itemData={imageList}\n                innerElementType={listInnerElementType}\n                outerRef={imageListRef}\n            \u003e\n                {renderDateImageItem}\n            \u003c/FixedSizeList\u003e\n        )}\n    \u003c/AutoSizer\u003e\n);\n```\n\n## License\n\n[MIT](http://opensource.org/licenses/MIT)\n\nCopyright (c) 2019 - 2020 sunxiuguo\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsunxiuguo%2Fvisualclipboard","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsunxiuguo%2Fvisualclipboard","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsunxiuguo%2Fvisualclipboard/lists"}