书签书架
四个无运行时依赖的 TypeScript 小模块。每个模块可独立工作。组合起来,它们构成一个可拖拽、打开和阅读保存链接的书架。此技能描述版本 0.2.0。
| 模块 | 文件夹 | 功能 | 不关心什么 |
|---|---|---|---|
| shelf | src/shelf | 根据姿态绘制书籍体积,报告意图(focus、open、step、jump) | 时间、手势、存储 |
| motion | src/motion | 弹簧、拖拽、轻拂、吸附、打开和关闭的进度 | 书籍、DOM 结构 |
| store | src/store | 基于本地存储的版本化库:验证、迁移、导入导出、搜索、排序、单个书架作为文件或链接 | DOM、渲染 |
| reader | src/reader | 将保存的文本显示为文本节点,跟踪并恢复位置、文字大小、私人笔记、标记为已读 | 书架、存储 |
模块并不在此技能文件夹内。它们存在于 Bookmark Bookshelf 仓库的检出副本中,首要任务是找到它。将模块文件夹复制出来,不要重新输入它们。
0. 寻找源码根目录
按顺序尝试以下路径,使用第一个成功找到的。
-
技能旁边。如果此文件位于
<root>/.agents/skills/bookshelf/SKILL.md,且<root>/src/shelf/index.ts存在,则<root>即为源码根目录。当技能被原地使用,或作为保留完整仓库的插件安装时,属于此情况。 -
用户提供的路径。如果技能被复制到其他地方(如代理的技能文件夹),用户可能已有一个检出副本。如果请求中包含路径,则将其用作源码根目录。如果没有,则询问一个路径,并在同一问题中提供选项 3。
-
浅克隆,仅当用户同意后才执行。先询问,并清晰说明后续操作:将克隆公共仓库
https://github.com/OthmanAdi/bookmark-bookshelf的发布标签v0.2.0到新的临时文件夹中,不下载其他任何内容。在用户同意之前不要获取任何内容。同意后,运行:git clone --depth 1 --branch v0.2.0 https://github.com/OthmanAdi/bookmark-bookshelf <临时文件夹>临时文件夹是一个在项目之外的新建空目录(系统临时目录即可)。告知用户其位置。它将成为源码根目录。从中复制文件,切勿在其中编辑,并在最后告知用户可以删除它。如果克隆失败(无网络、无 git、标签缺失),则说明情况并返回选项 2。
-
复制前验证。在源码根目录:
package.json应包含"name": "bookmark-bookshelf"。记录其version。- 应存在以下标记文件:
src/shelf/index.ts、src/motion/index.ts、src/store/index.ts、src/reader/index.ts、src/app/bookshelf.ts、src/app/bookshelf.css、src/app/flight.ts、src/styles/tokens.css。 - 如果是 git 检出副本,记录
git -C <root> rev-parse --short HEAD。
如果缺少某个标记文件,则停止并指出具体缺少哪个。此技能描述版本
0.2.x。对于其他版本,请先阅读每个模块的types.ts,再信任此处的参考文件:0.1.0 版本没有flight.ts,且其 shelf、store 和 reader 的约定不同(参见references/modules.md结尾)。 -
报告从中复制的源路径、版本和提交哈希,以及是否为自行创建的克隆,以便后续更新时进行比较。
以下每个以 src/、examples/ 或 public/ 开头的路径都是相对于该源码根目录的。
1. 首先确定范围
询问用户需要什么,然后只取所需部分。
| 他们想要 | 取 | 还要取 |
|---|---|---|
| 可拖拽的 3D 书脊行 | shelf、motion | src/styles/tokens.css |
| 带自定义点击处理的书架,无需拖拽 | shelf | tokens.css |
| 可恢复位置的阅读视图 | reader | tokens.css、src/styles/base.css 中的 .bb-button、.bb-label 和 .bb-visually-hidden 规则 |
| 不带 UI 的书签库:导入、导出、搜索、分享书架 | store | 无 |
| 其他行元素的拖拽、轻拂和吸附效果 | motion | 无 |
| 可打开阅读器的书架,使用自己的数据 | shelf、motion、reader | tokens.css、base.css 中的相同三条规则 |
| 完整组合 | 全部四个、src/app/bookshelf.ts、src/app/bookshelf.css、src/app/flight.ts | 如果需要示例链接,则取 src/styles/*、src/data/* |
模块之间从不相互导入。如果在两个模块文件夹之间出现导入,则集成方式有误:汇合点应属于宿主自己的组合文件(参见 src/app/bookshelf.ts)。
完整组合包含七项内容,并且列表很容易遗漏一个:四个模块文件夹、src/app/bookshelf.ts、其样式表 src/app/bookshelf.css 以及 src/app/flight.ts(它在书架和页面之间运输封面)。脚本会导入其他两个,因此没有它们的构建会因文件缺失而失败。将这三个文件一起复制到与模块文件夹相邻的文件夹中。
围绕组合的应用(导航栏及其菜单、用于添加链接的对话框、批量导入和分享书架、列表视图)是 src/app/ 的其余部分以及 app.html。这些文件相互依赖,并依赖 app.html 中的元素 ID。整体复制并适应,或基于 store 的函数构建自己的界面,如 references/composition.md 所示。
2. 引入文件
- 将选定的每个文件夹从
src/原样复制(包括测试*.test.ts)到宿主的源码树中。 - 复制
src/styles/tokens.css并仅加载一次。模块读取--bb-*自定义属性用于颜色、排版、间距和时序。若要重新样式,请覆盖 tokens,不要编辑模块的 CSS。Tokens 仅定义值:页面背景、正文字体和重置样式位于src/styles/base.css中。在空项目中,也复制该文件(它会导入tokens.css和fonts.css,因此如果跳过第 3 步,则删除字体导入),或者自己在body上设置background: var(--bb-paper); color: var(--bb-ink); font-family: var(--bb-font-serif)。 - 字体是可选的。
src/styles/fonts.css打包了 Newsreader 和 IBM Plex Mono(SIL OFL 1.1),需要两个开发依赖:@fontsource-variable/newsreader和@fontsource/ibm-plex-mono。如果包含它们,请同时将许可证文本复制到public/licenses/。不包含时,从base.css中删除@import './fonts.css'行,令牌栈将回退到系统字体。 shelf.ts和reader.ts会导入自身的 CSS(import './shelf.css')。需要一个能处理 CSS 导入的打包工具(如 Vite、webpack、Parcel、带有 CSS 加载器的 esbuild)。如果没有,请删除这些导入行,并从页面链接 CSS 文件:书架是shelf.css、cover.css和variants.css,阅读器是reader.css和reader-text.css。- TypeScript:代码针对
strict、noUncheckedIndexedAccess和verbatimModuleSyntax编写,使用moduleResolution: "bundler"。在更宽松的设置下也能编译。 - 空项目的工具:
npm i -D vite typescript vitest happy-dom @playwright/test,Node 版本^22.12.0 || ^24.0.0 || >=26.0.0。references/checks.md包含最小的 Vitest 和 Playwright 配置。
框架宿主(React、Vue、Svelte):每个模块都通过 mount(element, options) 暴露,返回一个包含 destroy() 方法的句柄。在框架的“首次渲染后”钩子中挂载,在其清理过程中销毁。references/composition.md 提供了一个用于包装的普通函数。
3. 连接
阅读 references/modules.md 了解约定(输入、输出、事件),阅读 references/composition.md 了解每种组合的即用接线代码。最短的有用组合:书架加动效。
import { createShelfMotion, prefersReducedMotion } from './motion';
import { mountShelf } from './shelf';
const motion = createShelfMotion({ count: items.length, reducedMotion: prefersReducedMotion });
const shelf = mountShelf(host, {
items,
label: '我的书架',
onLoopChange: (loop) => motion.setLoop(loop), // 书架决定行是否循环
});
motion.setLoop(shelf.loop);
motion.setTiming(shelf.variant.motion);
motion.subscribe((state) => {
shelf.setPose({ position: state.position, open: 0, reduced: state.reduced });
shelf.element.dataset.settled = state.settled ? '1' : '0'; // 允许测试等待静止
});
motion.attach(shelf.surface, {
pxPerItem: () => shelf.pitch(),
extent: () => shelf.surface.clientWidth,
enabled: () => !shelf.fixed, // 短行保持静止,因此不能拖拽
});
shelf.on((intent) => {
if (intent.type === 'focus') motion.goTo(intent.slot);
else if (intent.type === 'step') motion.step(intent.direction);
else if (intent.type === 'jump') motion.goTo(intent.to === 'first' ? 0 : items.length - 1);
else openSomething(intent.id);
});
items 是一个由 { id, title, source?, author?, cloth?, pattern?, seed?, progress?, thickness?, stature?, outline?, note?, done? } 组成的数组。各组合的可运行版本位于 examples/ 中。
是否循环由书架决定。只有当行的书籍体积填满场景至少五分之四时,行才会循环。短行静止在两个书档之间(shelf.fixed):箭头、方向键和点击书脊可选择显示的书籍,拖拽无效。根据项目数量告知用户将看到哪种情况,因为桌面屏幕上的八个标题会静止。
4. 保持其正常运行的规则
这些规则一旦更改就会破坏功能。每项都有原因。
.bb-shelf__row和书籍正面之间不得设置overflow、filter、clip-path、mask、contain: paint或低于 1 的opacity。其中任何一个都会扁平化 3D 上下文,导致书籍坍缩成平面。请淡出场景(perspective 元素)代替。- 书架按照给定的姿态绘制。行移动和打开来自
setPose,动效模块拥有帧循环。向.bb-book添加 CSS 过渡会与该循环冲突。悬停效果应设置在.bb-book__body上。 - 书架不负责运输封面。当
pose.open大于 0 时,它会隐藏居中的书籍体积并分开其邻居。运输封面是宿主的职责(src/app/flight.ts):一个副本放在固定层,仅通过transform移动,从shelf.coverPlacement()到reader.flightTarget()。书架的书籍、副本和阅读器自身的封面绝不能同时可见。 - 每个项目一个原生按钮,而非每个渲染的书籍一个。循环行会渲染副本。只有真实副本携带
button.bb-book__control,副本是aria-hidden的。正面是普通的 span,按钮是它们前面一个独立且不可见的元素。不要使正面可获得焦点,也不要用<button>包裹正面。 - 在拖拽表面上保持
touch-action: pan-y。使用none时,放在书架上的手指将无法再滚动页面。 - 触控拖拽期间,忽略来自后代元素的
lostpointercapture。手指下的元素会隐式捕获指针,表面取得捕获后,其后代的丢失事件会冒泡。gesture.ts检查event.target === element。保留此检查。 - 触摸点击书籍通过指针事件识别,而非
click。在高速滑动释放后,浏览器可能会吞没下一次点击的 click 事件。因此shelf.ts在pointerup上激活书籍,条件是触摸从未离开 10 像素半径且持续时间低于 700 毫秒,并跳过浏览器针对同一点击发送的 click 事件(键盘点击的detail为 0,始终有效)。gesture.ts还会在拖拽锁定后使用touchmove,以防止浏览器启动滑动。 - 阅读器仅使用
createElement和textContent构建 DOM。src/reader中没有任何innerHTML,并且有一个源码级测试强制执行此项。链接仅保留http:和https:的。 - 存储只接受
http:和https:地址,并且从不获取它们。应用显示链接和用户粘贴的任何文本。不要添加远程获取让阅读器“更智能”:浏览器会阻止大多数站点的请求,并且代理会改变安全模型。 - 导入永远不会覆盖,来自外部的任何内容未经预览就不会被应用。书架文件或书架链接来自陌生人:使用
readShelfFile或decodeShelfLink读取,显示summariseShelfFile,并仅在用户同意后调用importShelf。阅读位置、已读标记和私人笔记永远不会进入书架文件。 - 减少动效是一个独立的路径,没有任何内容需要移动。动效模块的
reducedMotion使移动瞬间完成,并将打开和关闭变成 150 毫秒的淡入淡出。传递实时偏好(prefersReducedMotion),而非仅读取一次的值。 - 始终向用户说明阅读器的边界。没有文本的链接必须显示“打开原文”并提供添加文本的一步式方法。
5. 检查结果
运行适用的测试。references/checks.md 包含命令、用于断言的 DOM 钩子以及用于真实鼠标和触控拖拽的 Playwright 代码片段。
| 获取了 | 最低验证 |
|---|---|
| 任何模块 | npx tsc --noEmit 无错误,模块自身的 *.test.ts 在具有 happy-dom 的 Vitest 下通过 |
| shelf | 书籍渲染,setPose 更改 data-focus,每个项目恰好有一个 .bb-book__control,方向键触发 step |
| shelf + motion | 对于足够长以循环的行,真实鼠标拖拽会更改 data-focus-id 并以 data-settled="1" 结束。对于静止的行,方向键会更改它,拖拽无效。垂直触摸拖拽会滚动页面 |
| reader | Escape 调用 onClose,重新加载恢复 scrollTop,文本中的 <script> 显示为文本 |
| store | javascript: 地址被拒绝,导出然后导入可往返,同一存储上的第二个 createStore 能看到数据,书架可经受 encodeShelfLink 然后 decodeShelfLink |
| 全部四个 | references/checks.md 中的设置冒烟测试通过。打开一本书,滚动,重新加载:同一本书在同一位置打开。关闭:焦点回到那本书 |
报告运行了哪些测试以及未运行哪些。通过类型检查的构建并不表示拖拽功能正常。
外观
paper(默认)
…
[已截断]