Repository navigation
Frontend Guidelines
主界面是 HTML + CSS + vanilla JS(ES modules);复杂编辑面用隔离的 React/TypeScript 功能岛。Vite 既是开发服务器也是打包器,产物到 dist/,由 Tauri 作为 frontendDist 加载。
- UI 文本英文,代码注释中文
- 主题用 CSS 自定义属性,通过
data-theme切换 - React/TS 功能岛必须通过
npx tsc --noEmit
src/
├── index.html / settings.html / pin.html / viewer.html
│ capture-overlay.html / longshot-controller.html
├── styles/
│ ├── base.css reset、滚动条、排版
│ ├── themes.css 只放 CSS 自定义属性定义
│ └── components.css 组件样式,颜色一律 var(--*)
├── js/
│ ├── api.ts 全部 typed Tauri IPC(唯一耦合点)
│ ├── ipc-types.ts Rust serde 载荷契约
│ ├── app.js 入口:初始化与接线
│ ├── preview/ 预览渲染器按职责拆分
│ └── ...
└── react/ 功能岛
命名:JS/CSS 文件 kebab-case,函数 camelCase,CSS 类 kebab-case,自定义属性 --category-name,数据属性 data-kebab-case。
// api.ts —— 其它模块只 import typed wrapper,绝不直接调 Tauri
import { invoke } from "@tauri-apps/api/core";
export async function getClips(query, favoritesOnly, offset, limit) {
return invoke('get_clips', { query, favoritesOnly, offset, limit });
}
export function onClipAdded(callback) {
return listen('clip-added', (event) => callback(event.payload));
}这样 IPC 契约是显式的,Rust serde 字段名可测试,功能模块不接触 Tauri 细节。
事件监听在 app.js 初始化时注册一次。需要清理时保存 unlisten 句柄;setTimeout/setInterval 在拆卸时清掉。
用户输入触发 IPC 一律加防抖(搜索框 200 ms)。
每个 vanilla 组件是一个 JS 模块,导出 init() 并自己拥有一棵 DOM 子树:
let listContainer;
export function init(container) { listContainer = container; /* 监听、首屏 */ }
export function renderClips(clips) { /* 增量更新 */ }一个模块的 replaceChildren() 不得摧毁另一个模块(尤其 React root)的挂载点。
- 动态内容用
document.createElement() - 用户文本一律
textContent -
innerHTML仅用于无用户数据的静态模板;富文本必须先过严格 DOMPurify 配置 - 元素附带数据用
dataset
样式全在 src/styles/,JS 里不写内联 style。状态用 CSS 类表达:
el.classList.add('selected'); // Good
el.style.backgroundColor = '#4a90d9'; // Bad连续内容共享最外层滚动容器。 不要给下半段内容再设固定 max-height + 第二个 overflow: auto——滚轮会被嵌套滚动区截获,内容也容易被挤成不可读的小窗口。用普通文档流排列子项,由面板级容器统一承担滚动。
- 原生焦点不适用时补
tabindex - 键盘导航:↑/↓ 移动、Enter 选择、Escape 关闭
- 自定义交互元素带
role与aria-* - 焦点可见性用
:focus-visible
- 弹窗打开时背景
inert,捕获阶段也要拦截旧目标的操作——只靠遮罩挡不住键盘激活背景按钮 - 记录整个确认会话的原始焦点;已有弹窗时原生关闭应复用该会话,不能销毁重建后把已删除的按钮当返回目标
- 取消恢复焦点要等背景
inert移除,并用preventScroll;忙状态禁用动作并把焦点留在 dialog - 按实际容器宽度排列按钮:小窗口用全宽纵向动作,不把中文逐字挤压。短窗标题和动作保持可达,正文与错误统一滚动,不靠缩小字体容纳内容
- 正文的普通滚轮必须放行,不能被全局缩放拦截;
Ctrl/Meta/pinch 缩放仍受模态保护
传统模块用模块级变量;React 功能岛用 useSyncExternalStore 对接模块级 store。不引入跨窗口全局状态库,后端始终是持久数据的权威来源。
数据单向流动:后端事件 → 入口监听 → 组件增量更新;用户操作 → api.ts → 后端 → 可选事件回流。
模块间通过函数调用通信,不共享可变变量:
search.onSearch((query) => clipboardList.setQuery(query)); // Good
import { currentQuery } from './search.js'; // Bad需要从后端刷新的时机:面板显示(隐藏期间可能漏事件)、查询变化、标签切换、增删事件。
同一个 DOM 容器显示不同条目时,只比较条目 id 不足以防竞态。
- 每次真正切换或清空都递增单调
render generation,异步工作捕获当时代次 - 每个
await、Promise continuation、图片load/error回调,在修改共享 DOM、元信息或触发下一项昂贵工作前,必须同时确认条目 id 和代次仍有效 - 同一条目的重复焦点通知应保持当前在途渲染,不能先使代次失效再因「id 未变」提前返回;若取消了另一个待处理切换,必须完整重渲染
- 旧代次不仅不能写 UI,也不能继续触发 OCR、复制等有副作用的 IPC
window.hide() 不保证产生新的 DOM blur:窗口可能早已失焦,或由 D-Bus、托盘、关闭请求、后端命令直接隐藏。
- 大列表、缩略图、预览、翻译结果不能只靠
blur释放 - 所有原生隐藏入口必须走同一个后端函数,由事件通知前端执行同一份幂等清理
- 前端自己调隐藏的路径要显式清理;正常
blur仍负责用户切走窗口的路径 - 新增隐藏入口时两条路径都要有回归守卫
- 释放列表后把 store 标 dirty;下次真正获得焦点时只重新加载首屏,不保留上一次的分页快照或滚动位置
- 收起行内操作时逻辑焦点必须回到行本体,不可见的删除操作不得继续是 Enter 的目标
- modal、输入元素、原生按钮和文本选区拥有自己的键盘行为。全局列表快捷键不得取消密码输入、按钮原生 Enter/Space 或选区复制;输入法 composing 与已处理事件不再转发
- 列表缓存必须能证明自己对应当前 query、mode 和数据版本。 增删事件使在途请求失效,丢弃旧响应后必须补查,不能留下永久 loading、错误清除 dirty 或恢复已删除记录
- OFFSET 分页在数据变化后必须重建连续范围,末端去重解决不了漏项。有搜索词时不得无条件 prepend 新记录,过滤结果仍以后端查询为准
- 翻译与朗读各自拥有请求身份,旧请求的成功、失败和 finally 都不能清除新请求状态
- 保存并关闭必须保护整个保存区间并检查版本;系统关闭与 UI 关闭走同一未保存确认。失败后保留编辑,获准后再销毁,避免关闭回环
主界面是普通 JavaScript,靠一致的数据形状 + 必要处的 JSDoc;React/TS 岛用 TypeScript。
进程内 IPC 数据可依赖 Rust serde 的类型契约,但由文件、网络或剪贴板恢复的持久化数据仍是用户可控输入:Rust 先在信任边界校验,React/TS 在放入状态前再做防御性逐字段 schema 校验。不能用 type assertion 把 unknown 直接变成文档对象。
禁止:eval() / new Function();假设图片字段是 base64 字符串(它是来自 Rust 的字节数组);假设所有字段都存在(先看 content_type 再访问类型专属字段)。
| 模式 | 原因 | 替代 |
|---|---|---|
用户数据进 innerHTML
|
XSS |
textContent / createElement
|
全局变量 window.x
|
命名污染 | ES module 作用域 |
var |
提升陷阱 |
const / let
|
api.ts 之外直接调 Tauri |
破坏解耦与类型 | 从 api.ts 导入 |
JS 里内联 style
|
难维护、难换主题 | CSS 类 |
setTimeout 做动画 |
卡顿 | CSS 过渡 / requestAnimationFrame
|
alert()/confirm()/prompt()
|
阻塞线程 | 自定义 UI |
原生 <select>(主窗口) |
WebKitGTK 原生下拉是独立 GTK 弹窗,一打开 webview 就失焦 | js/custom-select.js |
-
base.css—— reset、滚动条、字体、body -
themes.css—— 只放自定义属性定义 -
components.css—— 组件样式,颜色一律var(--*)
- 真实组件通过依赖注入接入合成服务,不设置全局
__TAURI__假实现;原生调用仍只经过api.ts - 明确区分真实产品组件与未接入的设计稿
- 生产构建入口不包含审阅页
- 窄窗夹具必须拥有对应的 viewport/容器布局——在宽宿主里画一个窄边框不等于验收了原生窄窗
- 浏览器只证明布局与网页交互;原生拖动、文件保存、系统剪贴板要另记证据
- 没有未净化的
innerHTML,富文本过 DOMPurify - 全部 Tauri 调用经
api.ts - 颜色用 CSS 自定义属性,无硬编码色值
- 键盘导航可用(↑/↓/Enter/Escape)
- UI 文本英文,注释中文
- 无全局变量、无
var - 触发 IPC 的用户输入有防抖
- 异步路径有渲染代次保护