Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
34cd7d9
fix(paste): 修复跨平台快捷键与卡片键盘路由
harris20240508 Aug 31, 2026
6f61d87
feat(paste): 新增 ZTools 快捷键设置入口
harris20240508 Aug 31, 2026
a23b155
test(paste): 补充 Windows 快捷键回归测试
harris20240508 Aug 31, 2026
6d34a6b
docs(paste): 更新 Windows 快捷键说明
harris20240508 Aug 31, 2026
916ccc3
fix(paste): 移除未发布宿主能力残留
harris20240508 Aug 31, 2026
407506d
feat(pasteboard-pro): 适配截图、拖拽与 pluginData
harris20240508 Sep 1, 2026
e2d80f6
fix(pasteboard-pro): 迁移历史数据至插件目录
harris20240508 Sep 2, 2026
bd12eb6
fix(pasteboard-pro): 快捷键唤起时保持主窗口隐藏
harris20240508 Sep 8, 2026
d57fa58
perf(pasteboard-pro): 虚拟化剪贴板列表并合并历史读取
harris20240508 Sep 8, 2026
4f6429b
fix(pasteboard-pro): 首屏就绪后显示窗口以消除启动闪烁
harris20240508 Sep 8, 2026
1696537
docs(pasteboard-pro): 补充列表性能与启动时序验证说明
harris20240508 Sep 8, 2026
b3cb06f
fix(pasteboard-pro): 补齐工作区类型检查配置与测试夹具类型
harris20240508 Sep 8, 2026
c0523ed
fix(pasteboard-pro): 修复外接屏窗口显示时的坐标偏移
harris20240508 Sep 8, 2026
3bf8cf9
docs(pasteboard-pro): 说明窗口显示后的坐标校准机制
harris20240508 Sep 8, 2026
da66307
perf(pasteboard-pro): 后台搜索全量历史并按页加载
harris20240508 Sep 9, 2026
a0b2f2c
docs(pasteboard-pro): 说明官方CLI流程与性能验证边界
harris20240508 Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions plugins/pasteboard-pro/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# 更新日志

## 1.3.0 - 2026-09-01

- 适配 ZTools 3.2:新增截图入库、原生文件外拖和插件专属 `pluginData` 附件目录;首次启动会校验迁移旧附件、重写数据库路径并删除旧目录。ZTools 2.4–3.1 直接安装仍使用旧目录与浏览器拖拽回退。截图对公开的一参数 wrapper 以第二参数 `false` 作 best-effort 编辑态请求,不能保证 wrapper 会转发;区域尺寸按宿主是否回传而可选。
- ZTools Shelf 截图只写系统剪贴板,由主窗口唯一负责历史镜像与广播,避免双窗口重复入库。
- 插件附件删除改为内容寻址白名单,复核 SHA-256、无符号链接文件句柄、canonical 路径、父链和 inode;无法验证或仍被记录引用时拒绝删除。
- 低于 ZTools 2.4 时只显示升级提示,不初始化剪贴板数据库、同步、窗口或后台监听。
- Windows/Linux 列表快捷键改用 Ctrl,覆盖搜索、新建、复制、暂停、编辑、重命名、全选、多选和数字快捷粘贴。
- 通用设置新增 ZTools 全局快捷键入口,并明确 `Win + V` 为 Windows 系统剪贴板历史快捷键。
- 修复带 Ctrl、Meta 或 Alt 的方向键和 Enter 被误判为裸键、导致搜索框失焦或列表误操作的问题。
- 统一卡片与列表的 Enter/Space 键盘路由,避免事件冒泡造成重复粘贴或绕过修饰键保护。

## 1.2.0 - 2026-08-03

- 插件源码目录与唯一 ID 保持为 `pasteboard-pro`,兼容既有安装、数据和同步协议。
Expand Down
48 changes: 45 additions & 3 deletions plugins/pasteboard-pro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,32 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
- 按内容、来源 App、日期、类型和分组搜索历史。
- 创建、重命名、排序和着色分组,支持拖动内容加入或移出分组。
- 预览文本、图片与 PDF,支持平台文件预览、图片旋转和 OCR。
- 使用方向键浏览,按 Enter 粘贴,按 Escape 关闭支持 `Command + 1–9` 快捷粘贴。
- 使用方向键浏览,按 Enter 粘贴,按 Escape 关闭;macOS 支持 `Command + 1–9`,Windows/Linux 支持 `Ctrl + 1–9` 快捷粘贴。
- 将多项内容加入粘贴队列;macOS 关闭面板后连续按 `Command + V` 依次粘贴,Windows/Linux 按 Enter 后按选择顺序逐项粘贴。
- 新复制内容实时定位;图片优先加载轻量缩略图,避免阻塞历史列表。
- 支持紧凑布局和上、下、左、右贴边显示,始终跟随当前鼠标所在屏幕。
- 提供历史保留、附件预算、敏感内容排除、屏幕共享保护和暂停捕获。
- 使用端到端加密 WebDAV 同步正文、OCR、分组、图片、PDF 与外观配置。
- 主题色、纯色背景和背景图片持久保存在插件本地;选图时不会上传,只有启用 WebDAV 后才进入加密同步。
- 可从设置的隐私页清空全部剪贴板历史、插件自管附件和当前系统剪贴板。
- ZTools 3.2 可从工具栏截图:Shelf 只写入系统剪贴板,再由主窗口唯一的历史监听器入库;插件会 best-effort 传入 `autoConfirm=false`,但当前公开的一参数 wrapper 可能忽略它,因此不能保证进入截图编辑态。截图区域尺寸也只在宿主实际回传时显示。ZTools 3.2 还支持将本地图片和文件原生拖到外部应用。

## 使用方式

1. 在 ZTools 中搜索 `Paste剪切板`、`剪贴板`、`paste` 或 `clipboard`。
2. 首次打开后,插件会登记为随 ZTools 启动;后续无需手动打开即可持续记录复制内容。
3. 使用鼠标、方向键或搜索框定位内容,按 Enter 或点击卡片完成粘贴。
4. 多选后点击“队列”;macOS 可连续按 `Command + V`,Windows/Linux 在面板内按 Enter 逐项粘贴。
5. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。
5. 在“设置 → 通用”中可跳转到 ZTools 的全局快捷键配置;Windows 请使用未被系统占用的组合,`Win + V` 保留给系统剪贴板历史。
6. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。

## 平台支持

- macOS:完整支持历史捕获、独立贴边浮窗、直接粘贴、Quick Look、Vision OCR、屏幕共享保护和连续粘贴队列。
- Windows / Linux:支持历史捕获、搜索、分组、复制、直接粘贴、文件拖拽和系统文件预览;OCR 需要安装 Tesseract,图片旋转需要安装 ImageMagick。
- ATools:使用 Svelte UI 与 ATools 原生 bridge。
- ZTools:使用 Vue 3 UI、Electron preload;macOS 使用 Vision helper,Windows/Linux 使用系统安全存储和外部 Tesseract/ImageMagick 命令。
- 最低支持 ZTools 2.4;2.4–3.1 使用旧数据目录与浏览器拖拽路径。3.2 首次启动会校验迁移旧附件并重写数据库路径,完成后删除旧目录,之后仅使用宿主提供的插件专属 `pluginData`。

## 隐私与同步

Expand All @@ -46,6 +49,7 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
- WebDAV record、blob 和 index 均经过端到端加密。
- WebDAV 凭据与派生密钥不进入 renderer,本地密钥材料存放于系统钥匙串。
- 搜索工具仅返回脱敏的结构化元数据,不把 OCR 正文写入 Agent 审计内容。
- 附件清理只接受插件生成的内容寻址路径,并在删除前校验实际 SHA-256、`O_NOFOLLOW` 文件句柄、canonical 路径及 inode;校验异常时拒绝删除。Node.js 没有 `unlinkat`,因此这里不宣称能原子防御同一系统账号的恶意进程在最终 unlink 瞬间并发替换父目录。

## 技术实现

Expand All @@ -58,11 +62,14 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
## 本地开发

```bash
npm install -g @ztools-center/plugin-cli
corepack pnpm@9.15.9 install --frozen-lockfile
corepack pnpm@9.15.9 --filter @pasteboard-pro/ztools dev
```

ZTools 开发页面默认由 Vite 启动;ATools 与 ZTools 使用独立 UI,不共享前端框架。
官方 CLI 1.2.0 提供 `ztools create`、`ztools publish` 和 `ztools pull-contributions`。现有项目通过包内脚本启动 Vite、构建和测试;CLI 没有独立的 dev/build 子命令。独立插件仓库完成提交后使用 `ztools publish`;已经位于中心仓库工作树、且 PR 使用自定义分支时,需要沿用该 PR 的实际 head 分支更新,避免 CLI 固定的 `plugin/<name>` 分支另建 PR。1.2.0 的 `publish --help` 会执行发布流程,不可当作只读帮助命令。

ATools 与 ZTools 使用独立 UI,不共享前端框架。

## 验证

Expand All @@ -85,6 +92,41 @@ pnpm verify:visual-artifact

仓库通用构建器会读取插件根目录的 `plugin.json`,在对应平台 runner 上构建;macOS 编译并临时签名 Vision helper,Windows/Linux 跳过该 helper 并使用对应平台命令,最后从 `dist/ztools` 打包 ZIP。native 构建逻辑仍限制在插件目录内。

## 大数据量列表回归

ZTools 时间线按可视范围动态挂载 30~50 张卡片(结果不足 30 条时全部挂载,超大视口优先覆盖完整可视范围),支持横向、纵向与紧凑模式。首批加载 50 条,接近末尾时预取下一页;跨页方向键等待页面返回后继续移动,刷新保留已经加载的范围和选择。拖拽排序在完整分组顺序上计算,可保存十万条 ID,避免覆盖未加载记录的顺序。

搜索在主插件窗口持有的 Node Worker 中执行,关闭 Paste 面板后复用缓存;输入合并窗口为 120 ms,过期结果不会覆盖新查询。后台缓存完整正文、HTML、OCR 和规范化搜索字段,沿用共享查询函数的子串、短语和筛选规则,搜索不再受界面已加载记录或一万条限制。返回列表的文本预览截取至 4096 字符;粘贴、批量粘贴、编辑和预览按 ID 读取完整原文。图片缩略图和原生拖拽按记录 ID 读取,不再为每批缩略图全量扫描历史。

分页游标指向带版本的搜索结果快照。数据更新或游标过期时重新查询,避免跳项;新增、编辑、删除和插件 WebDAV 同步通过文档 ID 通知更新缓存。旧宿主或未知变更通知回退为一次完整刷新。

```bash
# 启动时序用例直接验证生产 renderer,先生成它:
pnpm --filter @pasteboard-pro/ztools build:renderer
pnpm exec playwright test --config playwright.performance.config.ts
# 如本地没有 Playwright 自带浏览器,可使用已安装的 Chrome:
PLAYWRIGHT_CHANNEL=chrome pnpm exec playwright test --config playwright.performance.config.ts
# 可选:与虚拟列表改动之前的提交比较相同 10,000 条记录的组件挂载耗时
PB_BENCH_REF=bd12eb67 PLAYWRIGHT_CHANNEL=chrome pnpm exec playwright test --config playwright.performance.config.ts
```

浏览器报告位于 `artifacts/virtual-timeline/`,包含十万条模拟数据的生产 Vue 界面、全库搜索、连续输入、分页和键盘回归。打包后的 Node Worker 可单独压测:

```bash
pnpm --filter @pasteboard-pro/ztools build
node scripts/benchmark-history-worker.mjs
```

报告位于 `artifacts/search-capacity-20260908/worker-performance.json`,包括十万条模拟记录中的长文本、HTML、OCR、冷启动、查询往返 P95、调用线程定时器间隔和增量读取次数。该基准不包含 ZTools 数据库 IPC 与原生窗口创建,不代表十万条真实历史在所有机器上的性能保证。

宿主数据库目前没有公开原生游标/limit 查询,本次实现的是 Worker 缓存上的结果分页:主插件首次建立缓存、重载或未知外部变更仍需全量读取一次。数据库加载、宿主迁移、保留策略清理与同步仍可能产生额外全量读取;没有改写历史格式,也没有引入数据库迁移。

## 窗口首次显示

Shelf 和独立面板以隐藏状态创建。宿主 DOM-ready 后等待 renderer 的 `pasteboard-pro:window-ready` 信号;Vue 在主题、首屏数据和 DOM 更新完成后发出该信号。已就绪标记支持晚到的宿主回调,窗口不再主动刷新。macOS 的首次 `show()` 可能重新放置外接屏窗口,因此显示时先设为透明,调用 show/focus 后重新校准最新屏幕坐标,再恢复可见;尺寸补偿仅在实际 bounds 不匹配时执行。旧宿主不提供透明度 API 时仍立即校准显示后的坐标。加载期间关闭或替换窗口不会在异步加载结束后抢焦点;初始化失败时显示可关闭的错误提示。

`apps/ztools/tests/window.test.ts` 覆盖首次显示、连续打开、关闭重开和加载失败;`tests/performance/window-startup.spec.ts` 对生产 renderer 验证延迟主题/历史、空历史、错误状态、独立编辑窗口以及万条历史搜索。浏览器测试不代替 ZTools 原生窗口的视觉验收。

## 目录结构

```text
Expand Down
2 changes: 1 addition & 1 deletion plugins/pasteboard-pro/apps/ztools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasteboard-pro/ztools",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
68 changes: 66 additions & 2 deletions plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { markRecordChanged, rememberRecord, recordDocumentFor } from "./record-index";
import {
compareStableOrder,
orderKeyBetween,
Expand Down Expand Up @@ -57,6 +58,7 @@ export type DeleteRecordsResult = Readonly<{
export type CanonicalStoreOptions = Readonly<{
deviceId?: string;
now?: () => number;
ready?: Promise<void>;
}>;

export interface HostClipboardApi {
Expand Down Expand Up @@ -157,7 +159,7 @@ async function getOptionalDocument(
}
}

function storedRecord(value: unknown): CanonicalClipboardRecord | undefined {
export function storedRecord(value: unknown): CanonicalClipboardRecord | undefined {
if (
!isRecord(value) ||
value.type !== "pasteboard-pro-record" ||
Expand Down Expand Up @@ -246,16 +248,19 @@ async function putWithConflictRetry(
export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
private readonly deviceId: string;
private readonly now: () => number;
private readonly ready: Promise<void>;

constructor(
private readonly database: ZToolsDocumentDatabase,
options: CanonicalStoreOptions = {},
) {
this.deviceId = options.deviceId?.trim() || "ztools-local";
this.now = options.now ?? Date.now;
this.ready = options.ready ?? Promise.resolve();
}

async findByFingerprint(fingerprint: string): Promise<PasteItem | undefined> {
await this.ready;
const document = await getOptionalDocument(
this.database,
this.recordDocumentId(fingerprint),
Expand All @@ -264,13 +269,16 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}

async put(record: CanonicalClipboardRecord): Promise<void> {
await this.ready;
const id = this.recordDocumentId(record.item.contentFingerprint);
await putWithConflictRetry(this.database, id, (revision) => ({
_id: id,
...(revision === undefined ? {} : { _rev: revision }),
type: "pasteboard-pro-record",
record: structuredClone(record),
} satisfies StoredRecordDocument));
rememberRecord(this.database, record.item.id, id);
markRecordChanged(this.database, id);
}

async putSyncedItem(item: PasteItem): Promise<void> {
Expand Down Expand Up @@ -320,6 +328,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}
const current = await this.findRecordByItemId(itemId);
if (current === undefined) return;
markRecordChanged(this.database, this.recordDocumentId(current.item.contentFingerprint));
try {
await this.database.remove(
await this.database.get(this.recordDocumentId(current.item.contentFingerprint)),
Expand All @@ -330,12 +339,14 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}

async getCursor(): Promise<HostCursor | undefined> {
await this.ready;
return storedCursor(
await getOptionalDocument(this.database, CURSOR_DOCUMENT_ID),
);
}

async setCursor(cursor: HostCursor): Promise<void> {
await this.ready;
await putWithConflictRetry(
this.database,
CURSOR_DOCUMENT_ID,
Expand All @@ -348,7 +359,44 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
);
}

async readHistoryDocuments(): Promise<unknown[]> {
await this.ready;
if (this.database.allDocs === undefined) throw new TypeError("ZTools database does not expose allDocs");
const result = await this.database.allDocs({
include_docs: true, startkey: "pasteboard-pro:record:", endkey: "pasteboard-pro:record:\uffff",
});
const documents = documentsFromAllDocs(result, "ZTools database returned invalid record rows");
// Only inspect IDs here. Full schema validation and search run in the worker.
for (let offset = 0; offset < documents.length; offset += 500) {
for (const document of documents.slice(offset, offset + 500)) {
if (isRecord(document) && typeof document._id === "string" && isRecord(document.record) &&
isRecord(document.record.item) && typeof document.record.item.id === "string") {
rememberRecord(this.database, document.record.item.id, document._id);
}
}
if (offset + 500 < documents.length) await new Promise(resolve => setTimeout(resolve, 0));
}
return documents;
}

async readHistoryChanges(ids: readonly string[]): Promise<unknown[]> {
await this.ready;
const documents: unknown[] = [];
for (let offset = 0; offset < ids.length; offset += 50) {
const batch = await Promise.all(ids.slice(offset, offset + 50).map(id => getOptionalDocument(this.database, id)));
for (const document of batch) {
const record = storedRecord(document);
if (record !== undefined && isRecord(document) && typeof document._id === "string") {
rememberRecord(this.database, record.item.id, document._id);
documents.push(document);
}
}
}
return documents;
}

async listRecords(): Promise<CanonicalClipboardRecord[]> {
await this.ready;
if (this.database.allDocs === undefined) {
throw new TypeError("ZTools database does not expose allDocs");
}
Expand All @@ -363,6 +411,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
)
.flatMap((document) => {
const record = storedRecord(document);
if (record !== undefined) rememberRecord(this.database, record.item.id, this.recordDocumentId(record.item.contentFingerprint));
return record === undefined ? [] : [record];
})
.sort(
Expand All @@ -376,6 +425,13 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
query: string,
limit: number,
): Promise<Readonly<{ items: PasteItem[]; total: number }>> {
return (await this.searchWithRecords(query, limit)).result;
}

async searchWithRecords(query: string, limit: number): Promise<Readonly<{
result: Readonly<{ items: PasteItem[]; total: number }>;
records: CanonicalClipboardRecord[];
}>> {
if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) {
throw new RangeError("Search limit must be an integer between 1 and 10000");
}
Expand All @@ -384,12 +440,18 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
records.map((record) => record.item),
query,
);
return { items: matched.slice(0, limit), total: matched.length };
return { result: { items: matched.slice(0, limit), total: matched.length }, records };
}

async findRecordByItemId(
itemId: string,
): Promise<CanonicalClipboardRecord | undefined> {
const documentId = recordDocumentFor(this.database, itemId);
if (documentId !== undefined) {
await this.ready;
const record = storedRecord(await getOptionalDocument(this.database, documentId));
return record?.item.id === itemId ? record : undefined;
}
return (await this.listRecords()).find((record) => record.item.id === itemId);
}

Expand Down Expand Up @@ -572,9 +634,11 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}));
const document = await this.database.get(documentId);
await this.database.remove(document);
markRecordChanged(this.database, documentId);
deletedIds.push(record.item.id);
} catch (error) {
if (isDatabaseStatus(error, 404)) {
markRecordChanged(this.database, documentId);
deletedIds.push(record.item.id);
} else {
failures.push({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type ClipboardWindowRole = "primary" | "shelf" | "panel";

export function clipboardWindowRole(
parameters: URLSearchParams,
): ClipboardWindowRole {
if (parameters.get("shelf") === "1") return "shelf";
if (parameters.has("panel")) return "panel";
return "primary";
}

export function ownsClipboardHistoryMirror(
role: ClipboardWindowRole,
): boolean {
return role === "primary";
}
16 changes: 16 additions & 0 deletions plugins/pasteboard-pro/apps/ztools/preload/history-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { PasteItem } from "@pasteboard-pro/core";

export type HistoryRequest = Readonly<{
query?: string;
pinboardId?: string;
orderedIds?: readonly string[];
cursor?: string;
limit?: number;
}>;

export type HistoryPage = Readonly<{
items: PasteItem[];
total: number;
nextCursor?: string;
reset?: boolean;
}>;
Loading
Loading