Skip to content

Commit 4e5c824

Browse files
authored
Merge pull request #4 from Albert-PZY/refactor/mobile-and-cleanup
refactor: 移动端遮挡修复 + 冗余代码精简
2 parents cd0bfa9 + 3dcc105 commit 4e5c824

13 files changed

Lines changed: 114 additions & 150 deletions

File tree

src/lib/app.ts

Lines changed: 23 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** 应用入口:装配各视图、绑定全局事件与快捷键 */
22
import { on, store } from './state';
33
import type { BackgroundConfig } from './types';
4-
import { $ } from './util';
4+
import { $, el, onScrollRAF, prefersReducedMotion } from './util';
55
import { Crumbs } from './ui/crumbs';
66
import { editBookmark } from './ui/dialogs';
77
import { openExportDialog } from './ui/exporter';
@@ -44,6 +44,7 @@ async function boot(): Promise<void> {
4444
bindShortcuts(list, topbar);
4545
bindFileDrop();
4646
bindSidebarResizer();
47+
bindTopbarHeight();
4748
bindTopbarAutohide();
4849
bindBackTop();
4950
bindScrollProgress();
@@ -64,10 +65,9 @@ function bindFooter(): void {
6465
const foot = document.getElementById('sidebar-foot');
6566
if (!foot) return;
6667
const paint = () => {
67-
foot.replaceChildren();
68-
foot.append(
69-
Object.assign(document.createElement('span'), { textContent: `${store.bookmarks.size} 书签` }),
70-
Object.assign(document.createElement('span'), { textContent: `${store.folders.size} 分类` }),
68+
foot.replaceChildren(
69+
el('span', { text: `${store.bookmarks.size} 书签` }),
70+
el('span', { text: `${store.folders.size} 分类` }),
7171
);
7272
};
7373
on('data', paint);
@@ -292,6 +292,20 @@ function bindSidebarResizer(): void {
292292
handle.addEventListener('pointercancel', finish);
293293
}
294294

295+
/**
296+
* 实时测量顶栏真实高度写入 --topbar-h。
297+
* 移动端顶栏换行后高度可变,主区/侧栏抽屉据此定位,避免搜索行遮挡内容。
298+
*/
299+
function bindTopbarHeight(): void {
300+
const bar = document.querySelector('.topbar');
301+
if (!bar) return;
302+
const sync = () => {
303+
document.documentElement.style.setProperty('--topbar-h', `${bar.getBoundingClientRect().height}px`);
304+
};
305+
new ResizeObserver(sync).observe(bar);
306+
sync();
307+
}
308+
295309
/**
296310
* 顶栏下滑自动隐藏、上滑恢复。
297311
* 监听主滚动容器,用 rAF 节流;累计滚动超阈值才切换,避免抖动误触。
@@ -304,11 +318,9 @@ function bindTopbarAutohide(): void {
304318
let lastY = 0;
305319
let acc = 0;
306320
let hidden = false;
307-
let ticking = false;
308321
const THRESHOLD = 48;
309322

310323
const update = () => {
311-
ticking = false;
312324
const y = scroller.scrollTop;
313325
const dy = y - lastY;
314326
lastY = y;
@@ -335,16 +347,7 @@ function bindTopbarAutohide(): void {
335347
}
336348
};
337349

338-
scroller.addEventListener(
339-
'scroll',
340-
() => {
341-
if (!ticking) {
342-
ticking = true;
343-
requestAnimationFrame(update);
344-
}
345-
},
346-
{ passive: true },
347-
);
350+
onScrollRAF(scroller, update);
348351
}
349352

350353
/**
@@ -357,25 +360,13 @@ function bindScrollProgress(): void {
357360
const bar = document.querySelector('.scrollprog__bar') as HTMLElement | null;
358361
if (!scroller || !bar) return;
359362

360-
let ticking = false;
361-
362363
const update = () => {
363-
ticking = false;
364364
const sh = scroller.scrollHeight - scroller.clientHeight;
365365
const pct = sh > 0 ? (scroller.scrollTop / sh) * 100 : 0;
366366
bar.style.width = `${Math.min(100, Math.max(0, pct))}%`;
367367
};
368368

369-
scroller.addEventListener(
370-
'scroll',
371-
() => {
372-
if (!ticking) {
373-
ticking = true;
374-
requestAnimationFrame(update);
375-
}
376-
},
377-
{ passive: true },
378-
);
369+
onScrollRAF(scroller, update);
379370
}
380371

381372
/** 回到顶部按钮:滚动超阈值后左下角浮现,点击平滑返顶 */
@@ -386,31 +377,19 @@ function bindBackTop(): void {
386377

387378
const THRESHOLD = 600;
388379
let shown = false;
389-
let ticking = false;
390380

391381
const update = () => {
392-
ticking = false;
393382
const next = scroller.scrollTop > THRESHOLD;
394383
if (next !== shown) {
395384
shown = next;
396385
btn.classList.toggle('is-shown', shown);
397386
}
398387
};
399388

400-
scroller.addEventListener(
401-
'scroll',
402-
() => {
403-
if (!ticking) {
404-
ticking = true;
405-
requestAnimationFrame(update);
406-
}
407-
},
408-
{ passive: true },
409-
);
389+
onScrollRAF(scroller, update);
410390

411391
btn.addEventListener('click', () => {
412-
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
413-
scroller.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
392+
scroller.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
414393
});
415394
}
416395

src/lib/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export async function bulkWrite(payload: {
151151
}
152152

153153
/** 去掉内存态检索缓存字段后再持久化 */
154-
function stripRuntime(b: Bookmark): Bookmark {
154+
export function stripRuntime(b: Bookmark): Bookmark {
155155
if (b._s === undefined) return b;
156156
const { _s, ...rest } = b;
157157
void _s;

src/lib/state.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ class Store {
596596
}
597597

598598
private persistTrash(): Promise<void> {
599-
return db.setMeta('trash', this.trash.map((t) => ({ ...t, bookmarks: t.bookmarks.map(stripCache) })));
599+
return db.setMeta('trash', this.trash.map((t) => ({ ...t, bookmarks: t.bookmarks.map(db.stripRuntime) })));
600600
}
601601

602602
/** 软删除入站;保留最近 200 条,超出则丢弃最旧 */
@@ -668,14 +668,6 @@ export function indexOf(b: Bookmark): Bookmark {
668668
return b;
669669
}
670670

671-
/** 去掉内存态检索缓存字段,用于持久化回收站 */
672-
function stripCache(b: Bookmark): Bookmark {
673-
if (b._s === undefined) return b;
674-
const { _s, ...rest } = b;
675-
void _s;
676-
return rest as Bookmark;
677-
}
678-
679671
export function tokenize(q: string): string[] {
680672
return q
681673
.trim()

src/lib/ui/dialogs.ts

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -425,38 +425,25 @@ export async function openSettings(): Promise<void> {
425425
const bgHint = el('div', { class: 'bg-preview__hint' });
426426
bgPreview.append(bgMedia, bgHint);
427427

428-
/* 背景图不透明度:仅控制背景层 */
429-
const bgOpacityLabel = el('div', { class: 'field__hint', text: `背景不透明度 ${store.background.opacity}%` });
430-
const bgOpacityRange = el('input', { class: 'range', type: 'range', min: '0', max: '100', value: String(store.background.opacity) });
431-
bgOpacityRange.addEventListener('input', () => {
432-
const v = Number(bgOpacityRange.value);
433-
bgOpacityLabel.textContent = `背景不透明度 ${v}%`;
434-
void store.setBackground({ opacity: v });
435-
});
436-
const bgOpacityWrap = el('div', { style: 'flex:1;min-width:160px;display:flex;flex-direction:column;gap:6px' });
437-
bgOpacityWrap.append(bgOpacityLabel, bgOpacityRange);
438-
439-
/* 页面内容层不透明度:让顶栏/侧栏/卡片半透明,背景透出 */
440-
const pageOpacityLabel = el('div', { class: 'field__hint', text: `页面不透明度 ${store.background.pageOpacity}%` });
441-
const pageOpacityRange = el('input', { class: 'range', type: 'range', min: '0', max: '100', value: String(store.background.pageOpacity) });
442-
pageOpacityRange.addEventListener('input', () => {
443-
const v = Number(pageOpacityRange.value);
444-
pageOpacityLabel.textContent = `页面不透明度 ${v}%`;
445-
void store.setBackground({ pageOpacity: v });
446-
});
447-
const pageOpacityWrap = el('div', { style: 'flex:1;min-width:160px;display:flex;flex-direction:column;gap:6px' });
448-
pageOpacityWrap.append(pageOpacityLabel, pageOpacityRange);
449-
450-
/* 视频音量:仅当背景为视频时显示 */
451-
const volumeLabel = el('div', { class: 'field__hint', text: `视频音量 ${store.background.volume}%` });
452-
const volumeRange = el('input', { class: 'range', type: 'range', min: '0', max: '100', value: String(store.background.volume) });
453-
volumeRange.addEventListener('input', () => {
454-
const v = Number(volumeRange.value);
455-
volumeLabel.textContent = `视频音量 ${v}%`;
456-
void store.setBackground({ volume: v });
457-
});
458-
const volumeWrap = el('div', { style: 'flex:1;min-width:160px;display:flex;flex-direction:column;gap:6px' });
459-
volumeWrap.append(volumeLabel, volumeRange);
428+
/* 百分比滑块工厂:label + range,返回 wrap 与同步函数 */
429+
const percentSlider = (name: string, value: number, onInput: (v: number) => void) => {
430+
const label = el('div', { class: 'field__hint', text: `${name} ${value}%` });
431+
const range = el('input', { class: 'range', type: 'range', min: '0', max: '100', value: String(value) });
432+
range.addEventListener('input', () => {
433+
const v = Number(range.value);
434+
label.textContent = `${name} ${v}%`;
435+
onInput(v);
436+
});
437+
const wrap = el('div', { style: 'flex:1;min-width:160px;display:flex;flex-direction:column;gap:6px' });
438+
wrap.append(label, range);
439+
const sync = (v: number) => { range.value = String(v); label.textContent = `${name} ${v}%`; };
440+
return { wrap, sync };
441+
};
442+
443+
// 背景层不透明度、页面内容层不透明度、视频音量(音量仅视频时显示)
444+
const bgOpacity = percentSlider('背景不透明度', store.background.opacity, (v) => void store.setBackground({ opacity: v }));
445+
const pageOpacity = percentSlider('页面不透明度', store.background.pageOpacity, (v) => void store.setBackground({ pageOpacity: v }));
446+
const volume = percentSlider('视频音量', store.background.volume, (v) => void store.setBackground({ volume: v }));
460447

461448
const enToggle = toggle('启用背景', '', store.background.enabled, (v) => { void store.setBackground({ enabled: v }); });
462449

@@ -514,7 +501,7 @@ export async function openSettings(): Promise<void> {
514501
bgPreview.addEventListener('pointercancel', () => { dragging = false; });
515502

516503
const bgControls = el('div', { style: 'display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap;margin-top:10px' });
517-
bgControls.append(bgOpacityWrap, pageOpacityWrap, volumeWrap);
504+
bgControls.append(bgOpacity.wrap, pageOpacity.wrap, volume.wrap);
518505
const bgActions = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px' });
519506
bgActions.append(enToggle, uploadBtn, clearBtn);
520507
bgField.append(bgPreview, bgControls, bgActions, fileInput);
@@ -528,13 +515,10 @@ export async function openSettings(): Promise<void> {
528515
bgMedia.style.backgroundPosition = `${bg.posX}% ${bg.posY}%`;
529516
bgHint.textContent = bg.enabled ? (bg.src ? '拖动调整展示区域' : '请先上传图片') : '未启用';
530517
enToggle.querySelector('.switch')?.classList.toggle('is-on', bg.enabled);
531-
bgOpacityRange.value = String(bg.opacity);
532-
bgOpacityLabel.textContent = `背景不透明度 ${bg.opacity}%`;
533-
pageOpacityRange.value = String(bg.pageOpacity);
534-
pageOpacityLabel.textContent = `页面不透明度 ${bg.pageOpacity}%`;
535-
volumeRange.value = String(bg.volume);
536-
volumeLabel.textContent = `视频音量 ${bg.volume}%`;
537-
volumeWrap.style.display = bg.mime.startsWith('video/') ? '' : 'none';
518+
bgOpacity.sync(bg.opacity);
519+
pageOpacity.sync(bg.pageOpacity);
520+
volume.sync(bg.volume);
521+
volume.wrap.style.display = bg.mime.startsWith('video/') ? '' : 'none';
538522
};
539523
paintBg();
540524

src/lib/ui/importer.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { flattenSelection, parseNetscape } from '../netscape';
33
import { normalizeUrl, store } from '../state';
44
import type { Bookmark, Folder, ParsedNode, ParseResult } from '../types';
5-
import { el, formatBytes } from '../util';
5+
import { el, formatBytes, hostOf } from '../util';
66
import { pickFolder } from './dialogs';
77
import { icon } from './icons';
88
import { openModal, type ModalHandle } from './modal';
@@ -213,18 +213,10 @@ function toTreeNodes(nodes: readonly ParsedNode[]): TreeNode[] {
213213
label: n.title,
214214
kind: n.type === 'folder' ? 'folder' : 'link',
215215
children: n.children ? toTreeNodes(n.children) : undefined,
216-
meta: n.type === 'link' ? hostLabel(n.url ?? '') : undefined,
216+
meta: n.type === 'link' ? hostOf(n.url ?? '') : undefined,
217217
}));
218218
}
219219

220-
function hostLabel(url: string): string {
221-
try {
222-
return new URL(url).hostname.replace(/^www\./, '');
223-
} catch {
224-
return '';
225-
}
226-
}
227-
228220
/** 把勾选态回写到解析树 */
229221
function applyChecked(nodes: readonly ParsedNode[], checked: ReadonlySet<string>): void {
230222
for (const n of nodes) {

src/lib/ui/listview.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** 书签主视图:虚拟网格 + 选择 / 拖拽 / 右键菜单 / 键盘导航 */
22
import { store, on, tokenize } from '../state';
33
import type { Bookmark, ViewMode } from '../types';
4-
import { dataUrlToObjectUrl, el, escapeHtml, hueOf, relativeDate } from '../util';
4+
import { dataUrlToObjectUrl, el, escapeHtml, hueOf, openUrl, relativeDate } from '../util';
55
import { VirtualGrid, type VirtualMetrics } from '../virtual';
66
import { icon } from './icons';
77
import { openMenu } from './menu';
@@ -352,8 +352,7 @@ export class ListView {
352352
private open(id: string): void {
353353
const b = store.getBookmark(id);
354354
if (!b) return;
355-
if (store.settings.openInNewTab) window.open(b.url, '_blank', 'noopener,noreferrer');
356-
else location.href = b.url;
355+
openUrl(b.url, store.settings.openInNewTab);
357356
}
358357

359358
private async runAction(act: string, id: string): Promise<void> {
@@ -397,10 +396,13 @@ export class ListView {
397396
if (!b) return;
398397
const ids = store.selection.size && store.selection.has(id) ? [...store.selection] : [id];
399398
const many = ids.length > 1;
399+
// 打开/复制两项两种作用域通用(getBookmark 在非回收站作用域回退到 bookmarks)
400+
const openItem = { label: many ? `打开 ${ids.length} 个标签页` : '打开', icon: 'external' as const, onClick: () => ids.slice(0, 20).forEach((i) => this.open(i)) };
401+
const copyItem = { label: '复制链接', icon: 'copy' as const, onClick: () => void navigator.clipboard.writeText(ids.map((i) => store.getBookmark(i)?.url ?? '').join('\n')).then(() => toast('链接已复制')) };
400402
if (this.isTrash) {
401403
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+
openItem,
405+
copyItem,
404406
'sep',
405407
{
406408
label: many ? `恢复 ${ids.length} 条` : '恢复',
@@ -432,8 +434,8 @@ export class ListView {
432434
return;
433435
}
434436
openMenu(x, y, [
435-
{ label: many ? `打开 ${ids.length} 个标签页` : '打开', icon: 'external', onClick: () => ids.slice(0, 20).forEach((i) => this.open(i)) },
436-
{ label: '复制链接', icon: 'copy', onClick: () => void navigator.clipboard.writeText(ids.map((i) => store.bookmarks.get(i)?.url ?? '').join('\n')).then(() => toast('链接已复制')) },
437+
openItem,
438+
copyItem,
437439
'sep',
438440
{
439441
label: '移动到…',

src/lib/ui/palette.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** 命令面板:Ctrl/⌘ + K —— 命令、分类跳转与书签直达三合一 */
22
import { score, store, tokenize } from '../state';
33
import type { Bookmark } from '../types';
4-
import { el } from '../util';
4+
import { el, openUrl } from '../util';
55
import { editBookmark, openSettings } from './dialogs';
66
import { icon, type IconName } from './icons';
77
import { isModalOpen, openModal } from './modal';
@@ -94,8 +94,7 @@ export function openPalette(): void {
9494
hint: b.host,
9595
run: () => {
9696
m.close();
97-
if (store.settings.openInNewTab) window.open(b.url, '_blank', 'noopener,noreferrer');
98-
else location.href = b.url;
97+
openUrl(b.url, store.settings.openInNewTab);
9998
},
10099
}));
101100
};

src/lib/ui/selbar.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ export class SelBar {
2626
this.actions.replaceChildren(...(this.mode === 'trash' ? this.trashButtons() : this.buttons()));
2727
}
2828

29+
/** 图标+文字按钮工厂 */
30+
private btn(name: IconName, label: string, run: () => void, danger = false): HTMLElement {
31+
const b = el('button', { class: `btn btn--sm${danger ? ' btn--danger' : ''}`, type: 'button', html: icon(name) });
32+
b.appendChild(el('span', { text: label }));
33+
b.addEventListener('click', run);
34+
return b;
35+
}
36+
2937
private trashButtons(): HTMLElement[] {
30-
const make = (name: IconName, label: string, run: () => void, danger = false) => {
31-
const b = el('button', { class: `btn btn--sm${danger ? ' btn--danger' : ''}`, type: 'button', html: icon(name) });
32-
b.appendChild(el('span', { text: label }));
33-
b.addEventListener('click', run);
34-
return b;
35-
};
38+
const make = this.btn.bind(this);
3639
return [
3740
make('undo', '恢复', async () => {
3841
const ids = [...store.selection];
@@ -64,12 +67,7 @@ export class SelBar {
6467
}
6568

6669
private buttons(): HTMLElement[] {
67-
const make = (name: IconName, label: string, run: () => void, danger = false) => {
68-
const b = el('button', { class: `btn btn--sm${danger ? ' btn--danger' : ''}`, type: 'button', html: icon(name) });
69-
b.appendChild(el('span', { text: label }));
70-
b.addEventListener('click', run);
71-
return b;
72-
};
70+
const make = this.btn.bind(this);
7371
return [
7472
make('move', '移动到', async () => {
7573
const ids = [...store.selection];

0 commit comments

Comments
 (0)