windows 上好像已经有了自带的粘贴板历史工具,但是 mac 上没有自带的。之前一直在使用一个叫 pastebot 的工具,后面不支持 mac 新系统了(我的是 13.2.1),然后又找到一个叫 Paste 的工具,但是是收费的。
我觉得这种软件实现起来应该很简单吧,所以不如动手自己做一个。

技术选型

考虑到主要是桌面端使用,首先想到的就是 Electron 了,之前也做过相关开发,对 web 前端开发者算是很友好了。但是总觉得 Electron 实现的效果不够优雅(总觉得太像网页了哈哈)。
想了一下,最终选择了 Flutter,可以同时支持桌面端和移动端(因为我后面还想做一些小工具,比如私人GPT小助手,想支持跨端)。
哈哈,我的想法可太多了。

实现效果

先来看看最终实现的效果吧
粘贴板历史管理器实现效果

点击历史项就可以复制到粘贴板

实现思路

其实本质上就是通过监听系统粘贴板事件,然后取出里面的内容,通过 Flutter Listview 展示出来。做一些简单的交互即可。
这其中用到了一些库,主要有:

关键代码

mytoolbox仓库地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:clipboard_watcher/clipboard_watcher.dart';
import 'package:super_clipboard/super_clipboard.dart';

class Paster extends StatefulWidget {
const Paster({Key? key}) : super(key: key);

@override
PasterState createState() => PasterState();
}

class PasterState extends State<Paster> with ClipboardListener {
List<ClipboardHistoryItem> historyList = [];

bool tempDisableListener = false;

@override
void initState() {
// 监听粘贴板
clipboardWatcher.addListener(this);
// start watch
clipboardWatcher.start();
super.initState();
}

@override
void dispose() {
clipboardWatcher.removeListener(this);
// stop watch
clipboardWatcher.stop();
super.dispose();
}

@override
void onClipboardChanged() async {
if (tempDisableListener) {
return;
}

final item = await ClipboardHistoryItem.readFromClipboard();
if (item.contentType == ClipboardContentType.empty) {
return;
}
setState(() {
historyList.insert(0, item);
});
}

@override
Widget build(BuildContext context) {
return Column(
children: [
const Padding(padding: EdgeInsets.all(8), child: Text('粘贴板历史')),
const Divider(),
Flexible(
child: Align(
alignment: Alignment.topLeft,
child: _buildClipboardHistoryList(context))),
],
);
}

Widget _buildClipboardHistoryList(BuildContext context) {
// 横向滚动
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.separated(
padding: const EdgeInsets.all(8),
scrollDirection: Axis.horizontal,
itemCount: historyList.length,
itemBuilder: (BuildContext context, int index) {
final historyItem = historyList[index];

late Widget title;
switch (historyItem.contentType) {
case ClipboardContentType.text:
title = Text(historyItem.text!);
break;
case ClipboardContentType.image:
title = Image.memory(historyItem.imageBytes!);
break;
default:
title = const Text('未知类型');
}

return Container(
width: 280,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8)),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'${historyItem.timestamp!.hour}:${historyItem.timestamp!.minute}:${historyItem.timestamp!.second}')),
IconButton(
onPressed: () {
setState(() {
historyList.removeAt(index);
});
},
icon: const Icon(Icons.delete))
],
),
const Divider(),
Expanded(
child: ListTile(
title: title,
onTap: () async {
// 临时禁用监听
tempDisableListener = true;
// 写入剪贴板
await historyItem.writeToClipboard();
// TODO 待解决:理想情况下,首先需要使应用进入后台,然后激活之前的应用(有输入框的应用)
// 然后进行粘贴操作
// await FlutterClipboard.paste();
// 延迟后再次启用监听
Future.delayed(const Duration(milliseconds: 100),
() {
tempDisableListener = false;
// 并将当前的历史记录移动到第一位
setState(() {
historyList.removeAt(index);
historyList.insert(0, historyItem);
});
});

// 展示提示信息
const snackBar = SnackBar(
content: Text('已复制到粘贴板~'),
duration: Duration(seconds: 2),
showCloseIcon: true,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(snackBar);
}))
],
));
},
separatorBuilder: (BuildContext context, int index) =>
const VerticalDivider(width: 10, color: Colors.transparent),
));
}
}

enum ClipboardContentType { empty, text, image }

class ClipboardHistoryItem {
String? text;
Uint8List? imageBytes;
DateTime? timestamp;

late ClipboardContentType contentType;

ClipboardHistoryItem({this.text, this.imageBytes}) {
if (text != null) {
contentType = ClipboardContentType.text;
} else if (imageBytes != null) {
contentType = ClipboardContentType.image;
} else {
contentType = ClipboardContentType.empty;
}
timestamp = DateTime.now();
}

Future<void> writeToClipboard() async {
final item = DataWriterItem();
if (imageBytes != null) {
item.add(Formats.png(imageBytes!));
}
if (text != null) {
item.add(Formats.plainText(text!));
}
await ClipboardWriter.instance.write([item]);
}

static Future<ClipboardHistoryItem> readFromClipboard() async {
final reader = await ClipboardReader.readClipboard();

if (reader.canProvide(Formats.plainText)) {
final text = await reader.readValue(Formats.plainText);
return ClipboardHistoryItem(text: text);
} else if (reader.canProvide(Formats.png)) {
// 暂时只支持 png file
final imageBytes =
await ClipboardHistoryItem.readFile(reader, Formats.png);
return ClipboardHistoryItem(imageBytes: imageBytes);
} else {
return ClipboardHistoryItem();
}
}

static Future<Uint8List?>? readFile(
ClipboardReader reader, FileFormat format) {
final c = Completer<Uint8List?>();
final progress = reader.getFile(format, (file) async {
try {
final all = await file.readAll();
c.complete(all);
} catch (e) {
c.completeError(e);
}
}, onError: (e) {
c.completeError(e);
});
if (progress == null) {
c.complete(null);
}
return c.future;
}
}