Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ auto-imports.d.ts
*.njsproj
*.sln
*.sw?

# Local test data (copied / repaired from AppData)
.testdata*/
47 changes: 45 additions & 2 deletions src/database/history.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { exists, remove } from "@tauri-apps/plugin-fs";
import type { AnyObject } from "antd/es/_util/type";
import type { SelectQueryBuilder } from "kysely";
import { type SelectQueryBuilder, sql } from "kysely";
import { getDefaultSaveImagePath } from "tauri-plugin-clipboard-x-api";
import type { DatabaseSchema, DatabaseSchemaHistory } from "@/types/database";
import { join } from "@/utils/path";
import { getDatabase } from ".";

type QueryBuilder = SelectQueryBuilder<DatabaseSchema, "history", AnyObject>;

// 列表查询时 text 类型的 value 截断长度(避免几 MB 的纯文本拖慢渲染)
// HTML/RTF 需要完整 value 才能渲染富文本,不截断
const LIST_VALUE_TRUNCATE = 500;

/**
* 查询历史记录(列表用,仅 text 类型的 value 截断)
*/
export const selectHistory = async (
fn?: (qb: QueryBuilder) => QueryBuilder,
) => {
const db = await getDatabase();

let qb = db.selectFrom("history").selectAll() as QueryBuilder;
// text 类型截断 value(列表只显示前几行预览,不需要完整内容)
// html/rtf 需要完整 value 来渲染富文本,不截断
let qb = db
.selectFrom("history")
.select([
"id",
"type",
"group",
sql<string>`CASE WHEN type = 'text' THEN substr(value, 1, ${sql.lit(LIST_VALUE_TRUNCATE)}) ELSE value END`.as(
"value",
),
"search",
"count",
"width",
"height",
"favorite",
"createTime",
"note",
"subtype",
]) as QueryBuilder;

if (fn) {
qb = fn(qb);
Expand All @@ -22,6 +48,23 @@ export const selectHistory = async (
return qb.execute() as Promise<DatabaseSchemaHistory[]>;
};

/**
* 获取单条记录的完整 value(粘贴/导出/复制时用)
*/
export const getHistoryFullValue = async (
id: string,
): Promise<string | undefined> => {
const db = await getDatabase();

const result = await db
.selectFrom("history")
.select("value")
.where("id", "=", id)
.executeTakeFirst();

return result?.value as string | undefined;
};

export const insertHistory = async (data: DatabaseSchemaHistory) => {
const db = await getDatabase();

Expand Down
27 changes: 26 additions & 1 deletion src/database/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Database from "@tauri-apps/plugin-sql";
import { isBoolean } from "es-toolkit";
import { Kysely } from "kysely";
import { Kysely, sql } from "kysely";
import { TauriSqliteDialect } from "kysely-dialect-tauri";
import { SerializePlugin } from "kysely-plugin-serialize";
import type { DatabaseSchema } from "@/types/database";
Expand Down Expand Up @@ -31,6 +31,9 @@ export const getDatabase = async () => {
],
});

// 启用 WAL 模式:读写并发,搜索时不阻塞剪贴板写入
await sql`PRAGMA journal_mode = WAL`.execute(db);

await db.schema
.createTable("history")
.ifNotExists()
Expand All @@ -48,6 +51,28 @@ export const getDatabase = async () => {
.addColumn("subtype", "text")
.execute();

// 索引:大库下提升排序/筛选性能(尤其是 ORDER BY createTime DESC LIMIT)
await db.schema
.createIndex("idx_history_createTime")
.ifNotExists()
.on("history")
.column("createTime")
.execute();

await db.schema
.createIndex("idx_history_group_createTime")
.ifNotExists()
.on("history")
.columns(["group", "createTime"])
.execute();

await db.schema
.createIndex("idx_history_favorite_createTime")
.ifNotExists()
.on("history")
.columns(["favorite", "createTime"])
.execute();

return db;
};

Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getClipboardTextSubtype } from "@/plugins/clipboard";
import { clipboardStore } from "@/stores/clipboard";
import type { DatabaseSchemaHistory } from "@/types/database";
import { formatDate } from "@/utils/dayjs";
import { isURL } from "@/utils/is";

export const useClipboard = (
state: State,
Expand All @@ -41,11 +42,42 @@ export const useClipboard = (
search: text?.value,
} as DatabaseSchemaHistory;

let isPureImage = false;

if (image && !copyPlain) {
if (html?.value) {
try {
const doc = new DOMParser().parseFromString(
html.value,
"text/html",
);
const body = doc.body;
const nonVisibleTags = body.querySelectorAll(
"meta, style, script, link, title, noscript",
);
nonVisibleTags.forEach((el) => {
el.remove();
});

const imgs = body.getElementsByTagName("img");
if (imgs.length > 0 && !body.textContent?.trim()) {
isPureImage = true;
}
} catch {}
} else if (!text?.value?.trim() || isURL(text.value)) {
isPureImage = true;
}
}

if (files) {
Object.assign(data, files, {
group: "files",
search: files.value.join(" "),
});
} else if (image && isPureImage) {
Object.assign(data, image, {
group: "image",
});
} else if (html && !copyPlain) {
Object.assign(data, html);
} else if (rtf && !copyPlain) {
Expand Down
11 changes: 9 additions & 2 deletions src/hooks/useContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { find, isArray, remove } from "es-toolkit/compat";
import { type MouseEvent, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useSnapshot } from "valtio";
import { deleteHistory, updateHistory } from "@/database/history";
import {
deleteHistory,
getHistoryFullValue,
updateHistory,
} from "@/database/history";
import { MainContext } from "@/pages/Main";
import type { ItemProps } from "@/pages/Main/components/HistoryList/components/Item";
import { pasteToClipboard, writeToClipboard } from "@/plugins/clipboard";
Expand Down Expand Up @@ -57,11 +61,14 @@ export const useContextMenu = (props: UseContextMenuProps) => {
const exportToFile = async () => {
if (isArray(value)) return;

// 列表里的 value 可能被截断,导出时需要完整内容
const fullValue = (await getHistoryFullValue(id)) ?? value;

const extname = type === "text" ? "txt" : type;
const fileName = `${env.appName}_${id}.${extname}`;
const path = join(await downloadDir(), fileName);

await writeTextFile(path, value);
await writeTextFile(path, fullValue);

revealItemInDir(path);
};
Expand Down
102 changes: 86 additions & 16 deletions src/hooks/useHistoryList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { isBlank } from "@/utils/is";
import { getSaveImagePath, join } from "@/utils/path";
import { useTauriListen } from "./useTauriListen";

// 图片路径解析缓存:避免每次翻页都重复查磁盘
const imagePathCache = new Map<string, string>();

interface Options {
scrollToTop: () => void;
}
Expand All @@ -19,19 +22,27 @@ export const useHistoryList = (options: Options) => {
const { scrollToTop } = options;
const { rootState } = useContext(MainContext);
const state = useReactive({
fetchId: 0,
loading: false,
noMore: false,
page: 1,
pendingReload: false,
// 防止“输入太快/重复触发”时结果回退:只保留最新一次 reload
queryKey: "",
size: 20,
});

const fetchData = async () => {
try {
if (state.loading) return;
const getQueryKey = () => {
const { group, search } = rootState;

return JSON.stringify([group, search ?? ""]);
};

const fetchData = async (key: string, page: number) => {
try {
state.loading = true;

const { page } = state;
const currentFetchId = ++state.fetchId;

const list = await selectHistory((qb) => {
const { size } = state;
Expand All @@ -55,22 +66,60 @@ export const useHistoryList = (options: Options) => {
.orderBy("createTime", "desc");
});

// 如果查询条件变了(用户继续输入/切组),丢弃旧请求结果,避免 UI “回退”
if (currentFetchId !== state.fetchId) return;
if (key !== state.queryKey) return;

for (const item of list) {
const { type, value } = item;

if (!isString(value)) continue;

if (type === "image") {
const oldPath = join(getSaveImagePath(), value);
const newPath = join(await getDefaultSaveImagePath(), value);

if (await exists(oldPath)) {
await copyFile(oldPath, newPath);

remove(oldPath);
// 缓存命中:直接用已解析过的路径,跳过磁盘 I/O
const cached = imagePathCache.get(value);

if (cached) {
item.value = cached;
} else {
// 尝试多个可能的图片路径,找到真正存在的那个
const defaultImageDir = await getDefaultSaveImagePath();
const legacyImageDir = getSaveImagePath();

const candidates = [
value,
join(defaultImageDir, value),
join(legacyImageDir, value),
];

let resolved = join(defaultImageDir, value);

for (const candidate of candidates) {
if (await exists(candidate)) {
resolved = candidate;
break;
}
}

// 如果图片在旧目录,迁移到新目录
if (
resolved === join(legacyImageDir, value) &&
resolved !== join(defaultImageDir, value)
) {
const newPath = join(defaultImageDir, value);

try {
await copyFile(resolved, newPath);
remove(resolved);
resolved = newPath;
} catch {
// 迁移失败,继续用旧路径
}
}

imagePathCache.set(value, resolved);
item.value = resolved;
}

item.value = newPath;
}

if (type === "files") {
Expand All @@ -91,22 +140,43 @@ export const useHistoryList = (options: Options) => {
rootState.list = unionBy(rootState.list, list, "id");
} finally {
state.loading = false;

// 处理中途触发的 reload:用最新条件再拉一次
if (state.pendingReload) {
state.pendingReload = false;
reload();
}
}
};

const reload = () => {
const key = getQueryKey();

state.queryKey = key;
state.page = 1;
state.noMore = false;

return fetchData();
// 立即清空,给用户明确反馈“正在按新条件加载”
rootState.list = [];
rootState.activeId = void 0;

if (state.loading) {
state.pendingReload = true;
return;
}

return fetchData(key, 1);
};

const loadMore = () => {
if (state.noMore) return;
if (state.loading) return;

const nextPage = state.page + 1;

state.page += 1;
state.page = nextPage;

fetchData();
fetchData(state.queryKey || getQueryKey(), nextPage);
};

useTauriListen(LISTEN_KEY.REFRESH_CLIPBOARD_LIST, reload);
Expand Down
15 changes: 11 additions & 4 deletions src/hooks/useTauriFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,23 @@ export const useTauriFocus = (props: Props) => {

const wait = isMac ? 0 : 100;

const debounced = debounce(({ payload }) => {
if (payload) {
// Windows 上偶尔会收到“假失焦”事件,这里用 isFocused 二次确认
const debounced = debounce(async () => {
const focused = await appWindow.isFocused();

if (focused) {
onFocus?.();
} else {
onBlur?.();
}
}, wait);

unlistenRef.current = await appWindow.onFocusChanged(debounced);
unlistenRef.current = await appWindow.onFocusChanged(() => {
void debounced();
});
});

useUnmount(unlistenRef.current);
useUnmount(() => {
unlistenRef.current?.();
});
};
Loading