Skip to content

Commit 9659fe5

Browse files
authored
Merge pull request #2 from Albert-PZY/feat/recycle-bin-tab
回收站改为标签页展示
2 parents 147b974 + 943fb64 commit 9659fe5

10 files changed

Lines changed: 260 additions & 240 deletions

File tree

scripts/bundle-single-file.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ async function main() {
5858
}
5959
const size = Buffer.byteLength(r.code);
6060
console.log(`[bundle] 内联脚本 ${path.relative(dist, r.file)} ${kb(size)}`);
61-
html = html.replace(r.token, `<script type="module">${guard(r.code)}</script>`);
61+
// 用替换函数而非替换字符串,避免代码里的 $&、$1、$$ 等被 String.replace 当成特殊模式解释
62+
const replacement = `<script type="module">${guard(r.code)}</script>`;
63+
html = html.replace(r.token, () => replacement);
6264
}
6365

6466
// 2) 内联遗留的 <link rel="stylesheet">(正常情况下 Astro 已内联,这里兜底)
@@ -71,7 +73,8 @@ async function main() {
7173
try {
7274
const css = await readFile(file, 'utf8');
7375
console.log(`[bundle] 内联样式 ${path.relative(dist, file)} ${kb(Buffer.byteLength(css))}`);
74-
html = html.replace(tag, `<style>${css}</style>`);
76+
const style = `<style>${css}</style>`;
77+
html = html.replace(tag, () => style);
7578
} catch {
7679
console.warn(`[bundle] 跳过(读不到):${href}`);
7780
}

src/lib/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ function bindShortcuts(list: ListView, topbar: Topbar): void {
170170
if ((e.key === 'Delete' || e.key === 'Backspace') && store.selection.size) {
171171
e.preventDefault();
172172
const ids = [...store.selection];
173+
if (store.scope.kind === 'trash') {
174+
void store.purgeBookmarks(ids).then((n) => notice(`已彻底删除 ${n} 条书签`, { kind: 'warn' }));
175+
return;
176+
}
173177
void store.deleteBookmarks(ids).then(() => {
174178
notice(`已将 ${ids.length} 条书签移入回收站`, { kind: 'ok' });
175179
toast(`已删除 ${ids.length} 条书签`, { action: { label: '撤销', onClick: () => void store.undo() } });

src/lib/state.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class Store {
7070
undoStack: string[] = [];
7171
/** 回收站:软删除的书签/分类,用户可恢复或彻底删除 */
7272
trash: TrashItem[] = [];
73+
/** 回收站作用域下的书签查找表(拍平自 trash),供主视图渲染 */
74+
trashView = new Map<string, Bookmark>();
7375

7476
/* --------------------------- 初始化 --------------------------- */
7577

@@ -212,6 +214,34 @@ class Store {
212214
recompute(): void {
213215
const { scope, query } = this;
214216
let pool: Bookmark[];
217+
218+
if (scope.kind === 'trash') {
219+
this.trashView.clear();
220+
const bag: Bookmark[] = [];
221+
// 回收站按删除时间倒序拍平,最新删除的在前
222+
for (const t of this.trash) {
223+
for (const b of t.bookmarks) {
224+
const indexed = indexOf({ ...b });
225+
this.trashView.set(b.id, indexed);
226+
bag.push(indexed);
227+
}
228+
}
229+
const tks = tokenize(query);
230+
if (tks.length) {
231+
const scored: Array<[Bookmark, number]> = [];
232+
for (const b of bag) {
233+
const s = score(b, tks);
234+
if (s > 0) scored.push([b, s]);
235+
}
236+
scored.sort((a, b) => b[1] - a[1]);
237+
this.visible = scored.map((x) => x[0].id);
238+
} else {
239+
this.visible = bag.map((b) => b.id);
240+
}
241+
return;
242+
}
243+
244+
this.trashView.clear();
215245
const all = [...this.bookmarks.values()];
216246

217247
if (scope.kind === 'folder') {
@@ -492,6 +522,79 @@ class Store {
492522

493523
/* --------------------------- 回收站 / 撤销 --------------------------- */
494524

525+
/** 统一取书签:回收站作用域优先取拍平表,其余取正常表 */
526+
getBookmark(id: string): Bookmark | undefined {
527+
if (this.scope.kind === 'trash') return this.trashView.get(id) ?? this.bookmarks.get(id);
528+
return this.bookmarks.get(id);
529+
}
530+
531+
/** 取分类名:正常表没有则回落到回收站里被删的分类,保证原样展示 */
532+
folderNameOf(folderId: string | null): string | null {
533+
if (!folderId) return null;
534+
const live = this.folders.get(folderId);
535+
if (live) return live.name;
536+
for (const t of this.trash) {
537+
const f = t.folders.find((x) => x.id === folderId);
538+
if (f) return f.name;
539+
}
540+
return null;
541+
}
542+
543+
/** 从回收站里恢复指定的若干书签(及其所需的父级分类) */
544+
async restoreBookmarks(ids: readonly string[]): Promise<number> {
545+
const idset = new Set(ids);
546+
const restoredBookmarks: Bookmark[] = [];
547+
const restoredFolders: Folder[] = [];
548+
const emptied: string[] = [];
549+
for (const t of this.trash) {
550+
const hit = t.bookmarks.filter((b) => idset.has(b.id));
551+
if (!hit.length) continue;
552+
// 补回这些书签所依赖、且当前缺失的分类
553+
for (const b of hit) {
554+
if (b.folderId && !this.folders.has(b.folderId)) {
555+
for (const f of t.folders) {
556+
if (f.id === b.folderId && !this.folders.has(f.id)) {
557+
this.folders.set(f.id, f);
558+
restoredFolders.push(f);
559+
}
560+
}
561+
}
562+
this.bookmarks.set(b.id, indexOf({ ...b }));
563+
restoredBookmarks.push(b);
564+
}
565+
t.bookmarks = t.bookmarks.filter((b) => !idset.has(b.id));
566+
if (!t.bookmarks.length) emptied.push(t.id);
567+
}
568+
if (!restoredBookmarks.length) return 0;
569+
this.trash = this.trash.filter((t) => !emptied.includes(t.id));
570+
this.undoStack = this.undoStack.filter((x) => !emptied.includes(x));
571+
await db.bulkWrite({ folders: restoredFolders, bookmarks: restoredBookmarks });
572+
await this.persistTrash();
573+
this.recompute();
574+
emit('data', 'view', 'selection');
575+
return restoredBookmarks.length;
576+
}
577+
578+
/** 从回收站里彻底删除指定书签(不可恢复) */
579+
async purgeBookmarks(ids: readonly string[]): Promise<number> {
580+
const idset = new Set(ids);
581+
let removed = 0;
582+
const emptied: string[] = [];
583+
for (const t of this.trash) {
584+
const before = t.bookmarks.length;
585+
t.bookmarks = t.bookmarks.filter((b) => !idset.has(b.id));
586+
removed += before - t.bookmarks.length;
587+
if (!t.bookmarks.length) emptied.push(t.id);
588+
}
589+
if (!removed) return 0;
590+
this.trash = this.trash.filter((t) => !emptied.includes(t.id));
591+
this.undoStack = this.undoStack.filter((x) => !emptied.includes(x));
592+
await this.persistTrash();
593+
this.recompute();
594+
emit('data', 'view', 'selection');
595+
return removed;
596+
}
597+
495598
private persistTrash(): Promise<void> {
496599
return db.setMeta('trash', this.trash.map((t) => ({ ...t, bookmarks: t.bookmarks.map(stripCache) })));
497600
}

src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export type FontFamily = 'default' | 'hei' | 'song' | 'kai' | 'yuan' | 'custom';
7575
export type SmartScope = 'all' | 'recent' | 'favorite' | 'unsorted' | 'duplicates';
7676

7777
export interface Scope {
78-
kind: 'smart' | 'folder' | 'tag';
78+
kind: 'smart' | 'folder' | 'tag' | 'trash';
7979
value: string;
8080
}
8181

src/lib/ui/crumbs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ export class Crumbs {
3333
frag.appendChild(el('h1', { class: 'crumbs__title', text: last ? last.name : '分类' }));
3434
} else if (scope.kind === 'tag') {
3535
frag.appendChild(el('h1', { class: 'crumbs__title', text: `# ${scope.value}` }));
36+
} else if (scope.kind === 'trash') {
37+
frag.appendChild(el('h1', { class: 'crumbs__title', text: '回收站' }));
3638
} else {
3739
frag.appendChild(el('h1', { class: 'crumbs__title', text: SMART_LABEL[scope.value] ?? '全部书签' }));
3840
}
3941

4042
const n = store.visible.length;
41-
const total = store.bookmarks.size;
43+
const total = scope.kind === 'trash' ? store.trashView.size : store.bookmarks.size;
4244
const text = store.query.trim() ? `${n} / ${total}` : String(n);
4345
frag.appendChild(el('span', { class: 'crumbs__count', text }));
4446

src/lib/ui/listview.ts

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { VirtualGrid, type VirtualMetrics } from '../virtual';
66
import { icon } from './icons';
77
import { openMenu } from './menu';
88
import { editBookmark, pickFolder } from './dialogs';
9+
import { confirmDialog } from './modal';
910
import { toast, notice } from './toast';
1011

1112
const METRICS: Record<ViewMode, VirtualMetrics> = {
@@ -114,10 +115,14 @@ export class ListView {
114115
host.classList.add(`view-${store.settings.view}`);
115116
}
116117

118+
private get isTrash(): boolean {
119+
return store.scope.kind === 'trash';
120+
}
121+
117122
private items(): Bookmark[] {
118123
const out: Bookmark[] = [];
119124
for (const id of store.visible) {
120-
const b = store.bookmarks.get(id);
125+
const b = store.getBookmark(id);
121126
if (b) out.push(b);
122127
}
123128
return out;
@@ -133,6 +138,15 @@ export class ListView {
133138
}
134139

135140
private paintEmpty(): void {
141+
if (this.isTrash) {
142+
const searching = store.query.trim().length > 0;
143+
this.emptyBox.replaceChildren(
144+
el('div', { class: 'empty__art', html: icon(searching ? 'search' : 'trash') }),
145+
el('div', { class: 'empty__title', text: searching ? '回收站里没有匹配项' : '回收站是空的' }),
146+
el('p', { class: 'empty__desc', text: searching ? '换个关键词试试。' : '删除的书签会先进入这里,可随时恢复或彻底清除。' }),
147+
);
148+
return;
149+
}
136150
const hasData = store.bookmarks.size > 0;
137151
const searching = store.query.trim().length > 0;
138152
const title = searching ? '没有匹配的书签' : hasData ? '这个分类还是空的' : '开始导入你的书签';
@@ -232,13 +246,26 @@ export class ListView {
232246
}
233247

234248
const folder = node.querySelector<HTMLElement>('.item__folder')!;
235-
const path = b.folderId ? store.folders.get(b.folderId)?.name : null;
249+
const path = store.folderNameOf(b.folderId);
236250
folder.textContent = path ?? '未分类';
237251
folder.classList.toggle('chip--accent', !path);
238252

239-
const star = node.querySelector<HTMLElement>('[data-act="star"]')!;
240-
star.classList.toggle('is-on', b.favorite);
241-
star.innerHTML = icon(b.favorite ? 'starOn' : 'star');
253+
const actions = node.querySelector<HTMLElement>('.item__actions')!;
254+
const trash = this.isTrash;
255+
if (actions.dataset.mode !== (trash ? 'trash' : 'normal')) {
256+
actions.dataset.mode = trash ? 'trash' : 'normal';
257+
actions.innerHTML = trash
258+
? `<button class="item__act" data-act="restore" type="button" tabindex="-1" title="恢复">${icon('undo')}</button>
259+
<button class="item__act item__act--danger" data-act="purge" type="button" tabindex="-1" title="彻底删除">${icon('trash')}</button>`
260+
: `<button class="item__act" data-act="star" type="button" tabindex="-1" title="星标">${icon('star')}</button>
261+
<button class="item__act" data-act="edit" type="button" tabindex="-1" title="编辑">${icon('pencil')}</button>
262+
<button class="item__act item__act--danger" data-act="del" type="button" tabindex="-1" title="删除">${icon('trash')}</button>`;
263+
}
264+
if (!trash) {
265+
const star = actions.querySelector<HTMLElement>('[data-act="star"]')!;
266+
star.classList.toggle('is-on', b.favorite);
267+
star.innerHTML = icon(b.favorite ? 'starOn' : 'star');
268+
}
242269

243270
const cb = node.querySelector<HTMLInputElement>('input[type=checkbox]')!;
244271
cb.checked = store.selection.has(b.id);
@@ -296,6 +323,11 @@ export class ListView {
296323
});
297324

298325
node.addEventListener('dragstart', (e) => {
326+
// 回收站里的书签不参与归类拖拽
327+
if (this.isTrash) {
328+
e.preventDefault();
329+
return;
330+
}
299331
const id = this.idOf(node);
300332
const ids = store.selection.has(id) ? [...store.selection] : [id];
301333
e.dataTransfer?.setData('application/x-cozytag', JSON.stringify(ids));
@@ -318,14 +350,14 @@ export class ListView {
318350
}
319351

320352
private open(id: string): void {
321-
const b = store.bookmarks.get(id);
353+
const b = store.getBookmark(id);
322354
if (!b) return;
323355
if (store.settings.openInNewTab) window.open(b.url, '_blank', 'noopener,noreferrer');
324356
else location.href = b.url;
325357
}
326358

327359
private async runAction(act: string, id: string): Promise<void> {
328-
const b = store.bookmarks.get(id);
360+
const b = store.getBookmark(id);
329361
if (!b) return;
330362
switch (act) {
331363
case 'star':
@@ -339,14 +371,66 @@ export class ListView {
339371
notice('已移入回收站', { kind: 'ok' });
340372
toast('已删除 1 条书签', { action: { label: '撤销', onClick: () => void store.undo() } });
341373
break;
374+
case 'restore':
375+
await store.restoreBookmarks([id]);
376+
notice('已恢复', { kind: 'ok' });
377+
toast('已恢复 1 条书签');
378+
break;
379+
case 'purge': {
380+
const ok = await confirmDialog({
381+
title: '彻底删除',
382+
message: '该书签将被永久删除,无法恢复。',
383+
confirmText: '彻底删除',
384+
danger: true,
385+
});
386+
if (ok) {
387+
await store.purgeBookmarks([id]);
388+
notice('已彻底删除', { kind: 'warn' });
389+
}
390+
break;
391+
}
342392
}
343393
}
344394

345395
private itemMenu(id: string, x: number, y: number): void {
346-
const b = store.bookmarks.get(id);
396+
const b = store.getBookmark(id);
347397
if (!b) return;
348398
const ids = store.selection.size && store.selection.has(id) ? [...store.selection] : [id];
349399
const many = ids.length > 1;
400+
if (this.isTrash) {
401+
openMenu(x, y, [
402+
{ label: many ? `打开 ${ids.length} 个标签页` : '打开', icon: 'external', onClick: () => ids.slice(0, 20).forEach((i) => this.open(i)) },
403+
{ label: '复制链接', icon: 'copy', onClick: () => void navigator.clipboard.writeText(ids.map((i) => store.getBookmark(i)?.url ?? '').join('\n')).then(() => toast('链接已复制')) },
404+
'sep',
405+
{
406+
label: many ? `恢复 ${ids.length} 条` : '恢复',
407+
icon: 'undo',
408+
onClick: async () => {
409+
const n = await store.restoreBookmarks(ids);
410+
notice(`已恢复 ${n} 条书签`, { kind: 'ok' });
411+
toast(`已恢复 ${n} 条书签`);
412+
},
413+
},
414+
{
415+
label: many ? `彻底删除 ${ids.length} 条` : '彻底删除',
416+
icon: 'trash',
417+
danger: true,
418+
onClick: async () => {
419+
const ok = await confirmDialog({
420+
title: many ? `彻底删除 ${ids.length} 条书签` : '彻底删除',
421+
message: '选中的书签将被永久删除,无法恢复。',
422+
confirmText: '彻底删除',
423+
danger: true,
424+
});
425+
if (ok) {
426+
const n = await store.purgeBookmarks(ids);
427+
notice(`已彻底删除 ${n} 条书签`, { kind: 'warn' });
428+
}
429+
},
430+
},
431+
]);
432+
return;
433+
}
350434
openMenu(x, y, [
351435
{ label: many ? `打开 ${ids.length} 个标签页` : '打开', icon: 'external', onClick: () => ids.slice(0, 20).forEach((i) => this.open(i)) },
352436
{ label: '复制链接', icon: 'copy', onClick: () => void navigator.clipboard.writeText(ids.map((i) => store.bookmarks.get(i)?.url ?? '').join('\n')).then(() => toast('链接已复制')) },

0 commit comments

Comments
 (0)