feat: 阅读器连续选择与范围调整技术验证 (#4) #22
@@ -273,9 +273,10 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
|
||||
- F01~F12 已确认进入 MVP,X 系列本轮不纳入;用户指定 Quant-UX 原型,原型审核后才拆实施工单。默认学习语言已确认英语,原型语料为虚构样例;不导出本地 HTML。
|
||||
- 原型尽量减少说明文字,与目标页面一致:产品页只保留实际字段、操作与必要反馈;功能编号、模拟边界和审核说明放在独立导览或工单。
|
||||
- 当前 Quant-UX v1 已获用户验收(工单 #1 评论 7498),实施总览为 #16、单元工单为 #2~#15;LinguaCafe 对照与真实划词验证由 #4 承接,不能记作已完成。
|
||||
- 当前 Quant-UX v1 已获用户验收(工单 #1 评论 7498),实施总览为 #16、单元工单为 #2~#15;LinguaCafe 源码对照与桌面划词验证已由 #4 交付并获用户验收;真机详细测试证据缺口仍保留。
|
||||
- 学习端和管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式,邮箱不作为必填登录标识;后端独立校验管理权限与本人学习数据归属。
|
||||
- 已验证 MySQL 8.4.3,本机 127.0.0.1:3308;开发库 lexgo_dev、测试库 lexgo_test_issue2。密码只从环境或忽略的 .env.local 读取。迁移测试只能使用 lexgo_test_ 前缀专用库,不能借用其他数据库。
|
||||
- 后端命令使用 `python scripts/server.py migrate|bootstrap|serve|build|test|test-integration`;仅显式 migrate 修改表。bootstrap 只接受尚无账号的 LexGo 库,不覆盖已有管理员。Go 1.26.5、Node 22.22.1、pnpm 9.15.1;两端分别构建。
|
||||
- #18 登录日志与操作审计已通过用户验收:schema v2 显式迁移;日志只保存白名单字段,禁止保存凭据、请求/响应正文及私人学习内容。仅管理员查询,默认保留 90 天;启动/每小时及 `python scripts/server.py audit-cleanup` 仅清理两张审计表的过期记录。
|
||||
- #3 独立小样位于 `spikes/english/`,使用 `.local/nlp-venv/Scripts/python.exe`(3.12.12)运行;固定 spaCy 3.8.7、英语模型 3.8.0、NLTK 3.9.2、WordNet 3.0。资源仅显式准备时下载,摘要见 resources.json。不得把本机无账号的实验接口用于正式学习端;后续集成仍需 Go 授权、数据归属和任务设计。原文不归一化,位置区分 cp/UTF-8/UTF-16,lemma 不自动合并学习状态。
|
||||
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
|
||||
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
|
||||
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
|
||||
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#18 已验收,剩余 #4~#15 规划参考 43~70 人日;后续结合划词验证重估,旧全量研究仅供参考。
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#18 已验收,剩余 #5~#15 与新增 #21 规划参考 44~71 人日;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
|
||||
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
|
||||
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: fac5f2dcf94e57d85afbc2d043fc1272aba8d54b
|
||||
synchronized_at: 2026-09-10T13:50:46Z
|
||||
wiki_revision: 5bc7ccf33d1cc1cc28598c90e27a2ab6af4146bd
|
||||
synchronized_at: 2026-09-10T14:33:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -148,3 +148,30 @@ admin/src/views/AuditLogs.vue 通过 kind 复用登录/操作列表;audit-logs
|
||||
WordNet 使用 ZIP 内原始 index/data/exception 文件,不使用 SysDict 或新增业务库。NLTK 默认 synsets 会隐式词形还原,本小样直接读取其固定版本索引以区分 exact 和显式 lemma;禁用依赖全局 corpus 的 OMW 跨版本映射,只接受 WordNet 3.0。升级 NLTK 或词典时必须重跑契约测试。
|
||||
|
||||
候选比较:正则分词+WordNet 默认名词 morphology 依赖少、速度快,但不具备上下文判断,缩写和词性歧义处理弱;纯 Go 规则同样需要自行维护这些语言规则。本次 spaCy 在 12 个明确样例中答对 11 个,基线 6 个,因此推荐保留独立 Python NLP 边界。样例量不足以证明总体准确率;不宣称部署或正式阅读功能已完成。
|
||||
|
||||
|
||||
## #4 阅读选择小样与 LinguaCafe 对照
|
||||
|
||||
小样位于 spikes/selection:serve.py 只提供白名单静态文件,app.mjs 负责 DOM 原生选择/键盘/面板状态,range.mjs 负责原文范围和匹配,fixtures.mjs 提供两章虚构文本。保存仅在内存 Map,刷新清空,不接入账号、MySQL、正式词典、复习或 #21 附件。使用 Intl.Segmenter 的词与字形边界验证 UI,不能替代 #3 的 spaCy 结果;正式阅读器必须以章节原文、内容版本与 NLP tokens 为共同基准。
|
||||
|
||||
参考版本固定 LinguaCafe c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8。本次实际读取以下源码;未启动 LinguaCafe、未亲测其浏览器或手机行为。下表“参考行为”是源码证据;LexGo 方案是独立实现与取舍,不应标为上游已经验证的体验。
|
||||
|
||||
| 项目 | 参考行为与来源 | LexGo 采用及保留差异 |
|
||||
|---|---|---|
|
||||
| 阅读器 | TextBlockGroup.vue L56–100 用词/短语 stage、selected、hover 等状态;InteractiveTextStyling.scss L20–126 由主题决定颜色。TextReaderChapterList.vue L15–43 展示章节统计及已处理章节阅读入口;TextReader.vue L195–214 提供阅读完成后的书库/下一章 | 保留正文上下文、词语状态与选中区分、章节切换;小样以两章和学习中/已认识/忽略验证,章节统计及已读持久化仍属后续功能。侧栏/底部面板为 LexGo 布局,不宣称复制上游排版 |
|
||||
| 查词面板 | VocabularyBox.vue L124–169、374–404 区分 Translation 与字典搜索,新短语需 Save phrase;TextBlockGroup.vue L1351–1380 失选时自动保存单词/已有短语。VocabularySearchBox.vue L149–179 及保存 catch 未证明完整错误反馈 | 沿用 v1 的词典释义/我的释义、明确保存、关闭继续阅读。LexGo 关闭不自动保存;保存后可见反馈,无词典/无结果仍可手填。小样用固定虚构释义与故障状态,不把它当成真实词典/网络重试验证 |
|
||||
| 短语与键盘 | TextBlockGroup.vue L399–599 自定义鼠标范围;L374–457 为手机 500ms 长按及后续触摸移动,选区开始后阻止默认滚动;L1071–1202 的 Shift+方向键跳高亮词,而非扩展范围,Esc 失选 | LexGo 采用原生鼠标拖选、手机长按/系统手柄,不拦截 touchmove;起止端点按钮可调整。←/→ 相邻词,Shift+←/→ 扩缩范围,Esc 取消。原型的预设短语按钮被真实正文选区替代,属于 #4 明确要求的验证;手机手柄是否与底部面板冲突仍待真机 |
|
||||
| 复习 | Review.vue L270–345、559–692 为 Reveal→I was correct/Again,正确移除卡、Again 保留并随机抽剩余卡,最后一张正确完成;ReviewHotkeyInformationDialog.vue L14–24 提供快捷键。练习模式不写状态 | 已验收 v1 保留中文显答、答对/答错、再学与完成,单词/短语分别有状态。上游随机下一卡、阶段降级和快捷键不是本单已实现行为;#8 再明确调度、重学和幂等,#4 不新增复习引擎 |
|
||||
|
||||
源码链接:
|
||||
- [resources/js/components/Text/TextBlockGroup.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/TextBlockGroup.vue)
|
||||
- [resources/sass/Text/InteractiveTextStyling.scss](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/sass/Text/InteractiveTextStyling.scss)
|
||||
- [resources/js/components/TextReader/TextReaderChapterList.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReaderChapterList.vue)
|
||||
- [resources/js/components/TextReader/TextReader.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReader.vue)
|
||||
- [resources/js/components/Text/VocabularyBox.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/VocabularyBox.vue)
|
||||
- [resources/js/components/Text/VocabularySearchBox.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/VocabularySearchBox.vue)
|
||||
- [resources/js/components/TextReader/TextReaderHotkeyInformationDialog.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReaderHotkeyInformationDialog.vue)
|
||||
- [resources/js/components/Review/Review.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/Review.vue)
|
||||
- [resources/js/components/Review/ReviewHotkeyInformationDialog.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/ReviewHotkeyInformationDialog.vue)
|
||||
|
||||
上游 LICENSE 为 GPL v3,本单只核对并描述行为,没有移植源码。#1 评论 7497 的四项对照登记由本节补充;原型 v1 的保存/关闭主要流程保持,不更改其历史验收记录,不声称已经在 Quant-UX 新建修订版。范围调整与原生手柄作为可运行小样验证,若真机结果导致主要流程变化,应先更新关键原型状态并由用户确认。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 493544b5fd723c028d9173ecf590b66fea72c20a
|
||||
synchronized_at: 2026-09-10T12:58:34Z
|
||||
wiki_revision: 227c6cb92d30490f0f1f4b1b9f29cdbed8559ee4
|
||||
synchronized_at: 2026-09-10T14:33:25Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -99,3 +99,16 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
|
||||
POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转 ASCII,不改变原文位置;先精确查询 surface,再尝试调用方提供的 lemma。结果 status 为 exact、lemma、not_found 或 resource_missing,含 matched_form 和最多 12 条 entries(lemma/pos/definition/examples)。`dog` 直接命中;点击 `went` 可用上下文 lemma `go` 回退;手动只输入 `went` 不猜词性而返回未找到。词典缺失和模型缺失分别标识 wordnet/model,不能伪装成查无结果。
|
||||
|
||||
实验查询无用户状态、写入或缓存私人输入;重复查询确定性返回,原文哈希可检测文本版本变化,但尚未定义生产 token ID、任务幂等或个人词语合并规则。lemma 不等于学习状态身份,禁止自动合并原词/词元。WordNet 仅英英释义,按 n/v/a/r 与原生 sense 顺序截取,不做上下文义项排序、翻译或发音;`The leaves fell.` 的 leaves 实测被模型错误还原为 leave,此限制保留供后续用户选择/修正方案参考。
|
||||
|
||||
|
||||
## #4 阅读选择与重叠匹配建议
|
||||
|
||||
位置始终引用当前章节不变的原文,半开区间 [start,end)。浏览器 DOM Range 的 start/end 先按正文文本节点累计为 UTF-16,再扩展至触及的完整可选择词元;反向拖选得到同一规范区间。首尾空白/纯标点不独立成词,选区内部标点、空白和换行原样保留。emoji 字形可选,代理对、组合重音和 ZWJ 不拆开;cp/utf8/utf16 均与 #3 单位约定一致。
|
||||
|
||||
推荐以章节 ID、内容版本、原文区间标识一次出现,不使用短语文字定位所有实例。重复出现的 a small step 保持不同 offsets;查找采用区分大小写的精确原文匹配,并要求完整词元端点,不擅自折叠空白或 NFC。例:`a small step; a small step` 两次区间分别为 [0,12)、[14,26)。跨行 `small\nstep` 不与 `small step` 自动等同。
|
||||
|
||||
重叠匹配保留所有候选;只在显示层采用“起点靠前优先,同起点长范围优先,跳过已占用区间”,不合并或删除词条身份。例如 a small step 与 small step 都可保存,前者优先显示;当前用户选区高亮优先于已保存状态。#11 正式集成前再依据实际语言 tokens 验证候选索引与长度限制,本小样不继承未经实测的上游 14 词上限。
|
||||
|
||||
桌面鼠标/手机长按使用原生 Selection;正文中键盘 ←/→ 选相邻词,Shift+←/→ 从锚点扩缩连续范围,起点/终点按钮可用 Tab/Enter。Esc 或关闭清除选区及未保存编辑,保留滚动和键盘阅读位置;切换章节清除选区,当前页面内分别记住章节滚动位置。纯标点的新选区清除旧面板,防止操作上一个词。个人释义必须显式保存,状态变化也随保存提交;本小样仅写内存。
|
||||
|
||||
手机方案保留浏览器原生长按、选择手柄和滚动,面板最多占底部 42dvh,正文有底部阅读余量;键盘定位用滚动边距避开面板。未获得真实手机结果,不能判定手柄、系统菜单、虚拟键盘或触摸滚动冲突已经解决。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: a7b674447980d341dddeb7ad778672915f4f5b75
|
||||
synchronized_at: 2026-09-10T13:50:49Z
|
||||
wiki_revision: 694facbb7a861182122c9f6c69d28c58b005cb1a
|
||||
synchronized_at: 2026-09-10T14:52:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -228,3 +228,21 @@ node --test spikes/english/view.test.mjs
|
||||
|
||||
|
||||
#3 于 2026-09-10T21:50:14+08:00 通过用户验收(工单评论 7609),独立小样验证完成。此前实测结果和限制保持;本次验收未重跑测试。
|
||||
|
||||
|
||||
## #4 阅读选择验证
|
||||
|
||||
启动:`python spikes/selection/serve.py`,本机 http://127.0.0.1:5184/。默认仅 loopback,提供白名单静态文件,无目录列表或写入接口。虚构英文两章,任意词/连续短语可选;模拟释义与保存仅页面内有效。完整操作及真机清单见 spikes/selection/README.md;本单不配置 supervisor。
|
||||
|
||||
自动测试:`node --test spikes/selection/range.test.mjs spikes/selection/ui.test.mjs`。Node 22.22.1;范围测试无外部依赖,DOM 测试复用 learner 锁定的 jsdom(需要时先 `pnpm --dir learner install --frozen-lockfile`)。11 项通过,覆盖原文分区、反选、词边界、跨行/标点、组合字符/emoji/ZWJ、三位置单位、重复/重叠、关闭后键盘续读、标点取消旧选区、DOM Range 映射与显式保存。关闭丢失键盘位置及标点残留均复现旧代码失败,再验证修正通过。JS 语法检查通过。
|
||||
|
||||
桌面 Chrome 实际验证:鼠标拖选 `Mira opened the workshop`,原生选择与正文高亮相等;按钮扩展终点后可包含 before;键盘连续选择及关闭后下一词续读正常;模拟保存与词典不可用提示正常。章节中段直接点击关闭前后 scrollY 同为 909.5999755859375。自动化 locator.click 会在事件之前滚动到按钮,因此用基于可见坐标的直接点击复核;页面“验证设置”记录事件内关闭前后位置,避免将工具预滚动误判为页面变化。
|
||||
|
||||
桌面窄屏约 390×844 CSS 像素检查无横向溢出,底部面板正常;键盘选中词底部 464.4、面板顶部 489.5,未被遮挡。此为桌面浏览器宽度检查,不是移动系统或触摸模拟结果。视口已恢复。
|
||||
|
||||
**真机项未通过:尚未取得实际手机浏览器结果。** 需要用户记录设备/系统/浏览器版本、长按/手柄扩缩跨行范围、普通滚动、底部面板遮挡、虚拟键盘以及关闭位置,回填 #4。可在明确进行测试时以 `--host` 指定电脑实际局域网 IPv4,手机同网访问;不自动放行防火墙或开放公网。用户已明确验收 #4 并关闭工单;该详细测试证据缺口仍保留,不能将其改记为已执行通过。
|
||||
|
||||
独立规格及代码评审已修复已知问题;没有真实账号、生产数据、词典联网或 SRS 测试。停止小样即可回退,现有学习端/管理端/数据库不变。#21 附件不在本次范围。
|
||||
|
||||
|
||||
#4 用户验收记录:2026-09-10T22:51:52+08:00 用户确认“#4通过验收”(评论 7636)。未补充手机型号/浏览器/操作记录,未重跑测试,未自动合并 PR。后续正式移动端集成应补真机回归。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Workload-Estimate
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Workload-Estimate.-
|
||||
wiki_revision: 15f80d261369ab18138df0b8b8872fb17520eb97
|
||||
synchronized_at: 2026-09-10T13:51:03Z
|
||||
wiki_revision: f43e3793edfa02d319d5a1d566e082d3f3a1361d
|
||||
synchronized_at: 2026-09-10T14:52:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo MVP 工作量估算
|
||||
@@ -165,3 +165,8 @@ Quant-UX 桌面和手机原型 v1 已建立,待人工审核;这不计为产
|
||||
## #3 验收后的剩余计划
|
||||
|
||||
2026-09-10T21:50:14+08:00 用户验收 #3。从此前 46~75 人日扣除该单原计划 3~5 人日后,#4~#15 剩余规划参考为 43~70 人日。#2、#3、#18 均已验收;这不是实际消耗工时,后续仍需结合划词验证重估。
|
||||
|
||||
|
||||
## #4 验收与附件增补后的剩余计划
|
||||
|
||||
2026-09-10T22:51:52+08:00 用户验收 #4。原范围从43~70扣除本单3~5后,#5~#15剩余40~65人日;加已建单 #21 书籍音频/封面初估4~6后,当前剩余规划44~71人日。是计划参考,不代表实际耗时;#21仍待原型与限制细化。
|
||||
|
||||
+6
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: 428a6c6bcf5b147210299c5b37fde2d738646b95
|
||||
synchronized_at: 2026-09-10T13:50:42Z
|
||||
wiki_revision: a645b99632c70734b493ed030ad6d4d2acfd13be
|
||||
synchronized_at: 2026-09-10T14:52:14Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -59,4 +59,7 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
日志审计 #18 于 2026-09-10T20:40:04+08:00 获用户验收并关闭;#16 已更新完成索引。该验收时 #3/#4 尚未开始。
|
||||
|
||||
|
||||
英语分词/原文定位/本地词典 #3 独立可运行小样已通过用户验收并关闭。代码与复现命令位于 spikes/english;临时入口 http://127.0.0.1:5183/。推荐 Python spaCy 英语模型与 WordNet 3.0 的离线组合;尚未接入正式学习端,#4 划词验证仍未开始。
|
||||
英语分词/原文定位/本地词典 #3 独立可运行小样已通过用户验收并关闭。代码与复现命令位于 spikes/english;临时入口 http://127.0.0.1:5183/。推荐 Python spaCy 英语模型与 WordNet 3.0 的离线组合;尚未接入正式学习端。
|
||||
|
||||
|
||||
#4 阅读选择小样已实现桌面鼠标/键盘、范围调整与原文位置验证,入口 http://127.0.0.1:5184/;固定版本 LinguaCafe 四项源码对照已记录。用户已验收并关闭 #4;真实手机详细测试证据仍缺失,详见本地验证页。#21 仍待实施。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# #4 阅读选择技术验证
|
||||
|
||||
独立小样,默认英语;虚构章节与释义,保存仅在当前页面内有效,刷新即清空。不是生产阅读器,不连接账号、数据库、词典服务或复习调度。
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python spikes/selection/serve.py
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:5184/>。点词或用鼠标拖选连续文本;使用面板起点/终点按钮调整。正文获得焦点后,←/→ 选择相邻词,Shift+←/→ 调整连续范围,Escape 取消;关闭面板保留阅读位置和键盘续读位置。修改释义后必须点击保存;关闭不会自动保存。
|
||||
|
||||
测试(Node 22.22.1;DOM 测试复用学习端锁定的 jsdom 开发依赖):
|
||||
|
||||
```powershell
|
||||
pnpm --dir learner install --frozen-lockfile
|
||||
node --test spikes/selection/range.test.mjs spikes/selection/ui.test.mjs
|
||||
node --check spikes/selection/app.mjs
|
||||
```
|
||||
|
||||
`range.mjs` 的 Intl.Segmenter 仅用于验证 UI 选择边界,不替代 #3 的 spaCy 分词。正式集成时必须使用章节原文、内容版本和 NLP token 映射。代码没有复制 LinguaCafe 实现;固定上游版本与四项对照见 [架构 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map),范围/重叠策略见 [业务规则 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary)。
|
||||
|
||||
## 手机测试证据(待补充)
|
||||
|
||||
用户已确认 #4 验收通过,工单已关闭;没有提供具体手机测试记录,以下证据缺口保留。390 CSS 像素的桌面窄屏检查不是真机结果。需要使用实际手机浏览器记录设备、系统、浏览器版本和以下结果:
|
||||
|
||||
1. 普通上下滑动正文,不应意外选词或阻止滚动。
|
||||
2. 长按单词,用系统选择手柄扩展/缩短跨行短语;正文反馈、面板文字和“选中范围”一致。特别检查靠近底部面板的位置,系统菜单/手柄是否被遮挡。
|
||||
3. 使用面板起点/终点按钮调整,验证 emoji、组合字符、连字符及两次出现的 `a small step` 有不同位置。
|
||||
4. 关闭面板继续阅读,打开“验证设置与快捷键”核对关闭前后的滚动位置;输入释义时检查虚拟键盘与面板滚动。
|
||||
5. 测试词典无结果/资源不可用,仍可填写虚构释义、显式保存并继续阅读。
|
||||
|
||||
默认服务器只监听本机。手机需与电脑处于同一可信局域网,在明确进行手机测试时,用电脑的实际局域网 IPv4 地址启动:
|
||||
|
||||
```powershell
|
||||
python spikes/selection/serve.py --host <电脑局域网IPv4地址>
|
||||
```
|
||||
|
||||
手机打开 `http://<电脑局域网IPv4地址>:5184/`。只提供本目录白名单静态文件,不提供目录列表、私人内容或写入接口。若系统防火墙阻止访问,先由用户确认网络放行范围;不自动修改防火墙或发布公网。测试完停止进程。后续真机回归结果回填相关实施工单,并引用 #4 的证据缺口。
|
||||
@@ -0,0 +1,120 @@
|
||||
import {tokenize,normalizeRange,pointSelection,adjustRange,occurrences,resolveOverlaps} from './range.mjs'
|
||||
import {chapters} from './fixtures.mjs'
|
||||
|
||||
const $=id=>document.getElementById(id)
|
||||
const reader=$('reader'), panel=$('panel'), saved=new Map(), positions=new Map()
|
||||
const meanings={'curiosity':'好奇心;求知欲','a small step':'一小步','small step':'小小的一步','learning':'学习','café':'咖啡馆'}
|
||||
let chapterIndex=0,tokens=[],nodes=[],selected=null,anchor=null,focus=null,changingChapter=false,timer
|
||||
|
||||
function chapter(){return chapters[chapterIndex]}
|
||||
function clearNative(){window.getSelection()?.removeAllRanges()}
|
||||
function paint(){
|
||||
const candidates=[]
|
||||
for(const [text,item] of saved) for(const range of occurrences(chapter().text,text)) candidates.push({...range,state:item.state})
|
||||
const visible=resolveOverlaps(candidates)
|
||||
nodes.forEach((node,index)=>{
|
||||
const token=tokens[index]
|
||||
const match=visible.find(range=>token.start>=range.start && token.end<=range.end)
|
||||
node.className=(token.word?'word ':'')+(match?.state || '')
|
||||
node.classList.toggle('selected',!!selected && token.start>=selected.start && token.end<=selected.end)
|
||||
})
|
||||
}
|
||||
function dictionary(){
|
||||
if(!selected)return
|
||||
const mode=$('dictionary-mode').value
|
||||
$('dictionary').textContent=mode==='missing'?'该语言词典暂不可用,仍可填写释义。':mode==='empty'?'未找到释义,可手动填写。':meanings[selected.text.toLocaleLowerCase('en')] || '未找到释义,可手动填写。'
|
||||
}
|
||||
function choose(range,{resetAnchor=true}={}){
|
||||
if(!range)return
|
||||
if(selected?.start===range.start && selected?.end===range.end)return
|
||||
selected=range
|
||||
if(resetAnchor){anchor=range.first;focus=range.last}
|
||||
panel.hidden=false;$('empty').hidden=true
|
||||
$('term').textContent=range.text
|
||||
$('kind').textContent=range.first===range.last?'单词':'短语'
|
||||
const item=saved.get(range.text)
|
||||
$('meaning').value=item?.meaning || ''
|
||||
$('word-state').value=item?.state || 'learning'
|
||||
$('feedback').textContent=''
|
||||
$('range-output').textContent=JSON.stringify({chapter:chapter().id,text:range.text,utf16:[range.start,range.end],code_point:[range.start_cp,range.end_cp],utf8:[range.start_utf8,range.end_utf8]},null,2)
|
||||
dictionary();paint()
|
||||
document.querySelectorAll('[data-edge]').forEach(button=>{
|
||||
const next=adjustRange(chapter().text,tokens,range,button.dataset.edge,Number(button.dataset.direction))
|
||||
button.disabled=next.start===range.start && next.end===range.end
|
||||
})
|
||||
}
|
||||
function close(){
|
||||
const scroll=window.scrollY
|
||||
selected=null;anchor=focus
|
||||
clearTimeout(timer);clearNative();panel.hidden=true;$('empty').hidden=false
|
||||
paint();reader.focus({preventScroll:true});window.scrollTo({top:scroll,behavior:'instant'})
|
||||
$('event-status').textContent=`关闭前 ${scroll},关闭后 ${window.scrollY}`
|
||||
}
|
||||
function readNativeSelection(){
|
||||
const selection=window.getSelection()
|
||||
if(changingChapter || !selection || selection.isCollapsed || !selection.rangeCount)return false
|
||||
const range=selection.getRangeAt(0)
|
||||
if(!reader.contains(range.startContainer)||!reader.contains(range.endContainer))return false
|
||||
const prefix=document.createRange();prefix.selectNodeContents(reader);prefix.setEnd(range.startContainer,range.startOffset)
|
||||
const start=prefix.toString().length
|
||||
prefix.setEnd(range.endContainer,range.endOffset)
|
||||
const end=prefix.toString().length
|
||||
const normalized=normalizeRange(chapter().text,tokens,start,end)
|
||||
if(!normalized){
|
||||
selected=null;panel.hidden=true;$('empty').hidden=false;paint()
|
||||
return false
|
||||
}
|
||||
choose(normalized)
|
||||
return true
|
||||
}
|
||||
function renderChapter(){
|
||||
changingChapter=true;clearTimeout(timer);clearNative();selected=null;anchor=null;focus=null
|
||||
$('title').textContent=chapter().title;$('chapter-number').textContent=`${chapterIndex+1} / ${chapters.length}`
|
||||
$('previous').disabled=chapterIndex===0;$('next').disabled=chapterIndex===chapters.length-1
|
||||
tokens=tokenize(chapter().text)
|
||||
nodes=tokens.map((token,index)=>{const span=document.createElement('span');span.textContent=token.text;span.dataset.index=String(index);return span})
|
||||
reader.replaceChildren(...nodes)
|
||||
if(reader.textContent!==chapter().text)throw new Error('Original text reconstruction failed')
|
||||
panel.hidden=true;$('empty').hidden=false;paint();changingChapter=false
|
||||
}
|
||||
reader.addEventListener('click',event=>{
|
||||
if(readNativeSelection())return
|
||||
const node=event.target.closest('[data-index]')
|
||||
if(node)choose(pointSelection(chapter().text,tokens,Number(node.dataset.index)))
|
||||
})
|
||||
// Native selection remains in charge of mouse dragging and mobile long-press handles.
|
||||
// No touchmove preventDefault, pointer capture or text-node replacement on selection.
|
||||
document.addEventListener('selectionchange',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,100)})
|
||||
reader.addEventListener('pointerup',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,0)})
|
||||
reader.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'){event.preventDefault();close();return}
|
||||
if(!['ArrowLeft','ArrowRight'].includes(event.key)||event.ctrlKey||event.metaKey||event.altKey)return
|
||||
event.preventDefault();clearNative()
|
||||
const direction=event.key==='ArrowLeft'?-1:1
|
||||
let next=focus===null?(direction===1?0:tokens.length-1):focus+direction
|
||||
while(next>=0 && next<tokens.length && !tokens[next].word)next+=direction
|
||||
if(next<0||next>=tokens.length)return
|
||||
if(!event.shiftKey||anchor===null){anchor=next;focus=next;choose(pointSelection(chapter().text,tokens,next),{resetAnchor:false})}
|
||||
else {focus=next;choose(normalizeRange(chapter().text,tokens,Math.min(tokens[anchor].start,tokens[focus].start),Math.max(tokens[anchor].end,tokens[focus].end)),{resetAnchor:false})}
|
||||
// Keyboard users can navigate a long chapter without losing the active word.
|
||||
nodes[next].scrollIntoView({block:'nearest',inline:'nearest',behavior:'instant'})
|
||||
})
|
||||
document.addEventListener('keydown',event=>{if(event.key==='Escape' && !panel.hidden && event.target!==reader){event.preventDefault();close()}})
|
||||
document.querySelectorAll('[data-edge]').forEach(button=>button.addEventListener('click',()=>{
|
||||
if(!selected)return
|
||||
clearNative();choose(adjustRange(chapter().text,tokens,selected,button.dataset.edge,Number(button.dataset.direction)))
|
||||
}))
|
||||
$('close').addEventListener('click',close)
|
||||
$('save').addEventListener('click',()=>{
|
||||
if(!selected)return
|
||||
const meaning=$('meaning').value.trim()
|
||||
if(!meaning){$('feedback').textContent='请填写释义。';return}
|
||||
saved.set(selected.text,{meaning,state:$('word-state').value})
|
||||
$('feedback').textContent='已保存 · 本次页面内有效';paint()
|
||||
})
|
||||
$('dictionary-mode').addEventListener('change',dictionary)
|
||||
for(const [id,delta] of [['previous',-1],['next',1]])$(id).addEventListener('click',()=>{
|
||||
positions.set(chapter().id,window.scrollY);chapterIndex+=delta;renderChapter()
|
||||
reader.focus({preventScroll:true});window.scrollTo({top:positions.get(chapter().id)||0,behavior:'instant'})
|
||||
})
|
||||
renderChapter()
|
||||
@@ -0,0 +1,54 @@
|
||||
// Fictional public test material, never copied from a learner's library.
|
||||
export const chapters = [
|
||||
{
|
||||
id: 'lantern-1',
|
||||
title: 'The Lantern Workshop',
|
||||
text: `Mira opened the workshop before sunrise. A folded map lay beside the window, and a small step separated the quiet room from the garden. Her curiosity had brought her here. Today she would build a lantern that could travel across the valley.
|
||||
|
||||
“Begin with a small step,” her friend Rowan said. He placed a paper frame on the table. Mira examined the thin edges, turned the frame around, and found a tiny mark near the corner. The mark looked like a star, but its five points were not quite equal.
|
||||
|
||||
Outside, rain tapped against the glass. Inside, the workshop smelled of cedar and warm tea. They measured the paper twice. Then they measured it again, because a hurried answer could turn an ordinary mistake into a long afternoon of repairs.
|
||||
|
||||
Mira wrote a note: keep the frame light, keep the corners strong, and leave enough room for the light. Rowan read the note aloud. Neither of them knew whether the lantern would fly, but both wanted to understand why it might.
|
||||
|
||||
Their first attempt leaned to the left. The second leaned to the right. At the third attempt, Mira laughed. “A well-known problem,” she said, “can still offer an unexpected lesson.” She did not throw the old frames away. Each one showed a different part of the problem.
|
||||
|
||||
By noon the rain had stopped. A narrow line of sunlight crossed the wooden floor. Mira moved the lantern into the light, and the paper shone like a pale orange leaf. Her curiosity was no smaller than it had been in the morning.
|
||||
|
||||
On the shelf was a postcard from an imaginary traveler: “Hello 🙂! The café has a sign that reads café. Our paper astronaut 👩🚀 is ready for the journey. A second sign says 中文.” Mira kept every character exactly as it was written, including the accent that followed its letter.
|
||||
|
||||
The last instruction covered two lines:
|
||||
take a small step
|
||||
and watch what changes.
|
||||
Rowan pointed at the break between them. A pause was useful, he thought; it gave the reader time to notice the words on either side. The paper itself had not lost a single letter.
|
||||
|
||||
In the garden, a light breeze lifted the grass. Mira carried the lantern to the gate and waited. She could hear a bird in the old pear tree, a cart on the distant road, and Rowan counting softly behind her. She held the frame with both hands until the counting stopped.
|
||||
|
||||
The lantern rose a little, turned slowly, and settled beside the gate. It had traveled less than a meter. Mira smiled anyway. For a first journey, a small step was enough. Tomorrow they would return to the workshop, read their notes, and try again.`,
|
||||
},
|
||||
{
|
||||
id: 'lantern-2',
|
||||
title: 'Across the Quiet Valley',
|
||||
text: `The next morning, Rowan arrived with a blue notebook. He had drawn the valley as a series of gentle curves. A small bridge crossed the river near the mill, and a winding path connected the bridge to the workshop. Mira studied the drawing before she touched the lantern.
|
||||
|
||||
“Yesterday was a small step,” she said. “Today we should learn what the wind is doing.” They tied a ribbon to the gate. It pointed toward the river, fell still, and then lifted toward the hills. The wind had more than one answer.
|
||||
|
||||
They waited beneath the pear tree. Waiting felt different when there was something to observe. Rowan noted the direction of each gust. Mira counted the seconds between them. After a while, the blank page held a pattern that neither of them had expected.
|
||||
|
||||
A neighbor passed with an empty basket. She asked why they were staring at a ribbon. Mira explained the experiment, and the neighbor offered a practical suggestion: walk to the bridge before making another lantern. The river often changed the air near its banks.
|
||||
|
||||
At the bridge, the breeze was cooler. Water slipped around the stones with a soft, uneven sound. Rowan opened the notebook to a fresh page. Mira held the ribbon above the railing and watched its end flicker. Their curiosity made an ordinary walk feel like a journey.
|
||||
|
||||
There was no single perfect direction. Near the bank, the ribbon pointed downstream. At the center of the bridge, it twisted back toward the trees. They wrote down both observations. An answer that left out the difficult part would not help them tomorrow.
|
||||
|
||||
On their way home, they stopped beside a weather-beaten sign. Its painted words had faded, but the letters were still clear: a small step can begin a long journey. Underneath, someone had drawn a smiling face 🙂 and a tiny astronaut 👩🚀 beside an imaginary moon.
|
||||
|
||||
Back at the workshop, Mira compared the old paper frames. One was too heavy at its base. Another had a loose corner. She chose the simplest frame and changed only one thing. Rowan recorded the change before they carried the lantern outside.
|
||||
|
||||
This time it floated beyond the gate. For a moment, the orange paper rested against the blue sky like a leaf on clear water. Then the breeze shifted, and the lantern drifted gently into the grass. Nothing had broken. They could try again with the same frame.
|
||||
|
||||
Mira did not call the experiment a failure. The notebook now held a direction, a time, and a careful description of the flight. They had learned something they could test. That was enough to make the next question smaller and more useful.
|
||||
|
||||
When evening came, they closed the window and cleared the table. The map remained beside the notebook. Tomorrow might bring a longer flight, or another surprising turn. Either way, they would begin with a small step, pay attention, and preserve the story exactly as it happened.`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LexGo · 阅读选择验证</title><link rel="stylesheet" href="/style.css"></head>
|
||||
<body><header><a class="brand" href="/">LexGo<span>.</span></a><span>英语 · 阅读选择验证</span></header>
|
||||
<main><div class="book"><div><p class="eyebrow">A SMALL STEP, EVERY DAY</p><h1 id="title"></h1></div><nav aria-label="章节"><button id="previous" class="quiet">上一章</button><span id="chapter-number"></span><button id="next" class="quiet">下一章</button></nav></div>
|
||||
<div class="layout"><section class="paper"><p class="hint" id="reader-help">点词查词,划选连续短语。</p><article id="reader" tabindex="0" aria-label="章节正文" aria-describedby="reader-help"></article></section>
|
||||
<aside class="side"><p id="empty">选择一个词或短语</p><section id="panel" aria-label="查词面板" hidden><div class="panel-top"><span id="kind"></span><button id="close" class="quiet" aria-label="关闭,继续阅读">关闭</button></div><h2 id="term"></h2><div class="range-controls" aria-label="调整选择范围"><span>起点</span><button data-edge="start" data-direction="-1" aria-label="起点前移">−</button><button data-edge="start" data-direction="1" aria-label="起点后移">+</button><span>终点</span><button data-edge="end" data-direction="-1" aria-label="终点前移">−</button><button data-edge="end" data-direction="1" aria-label="终点后移">+</button></div><p id="dictionary"></p><label for="meaning">我的释义</label><textarea id="meaning" rows="2" maxlength="500"></textarea><label for="word-state">状态</label><select id="word-state"><option value="learning">学习中</option><option value="known">已认识</option><option value="ignored">忽略</option></select><button id="save">保存到生词本</button><p id="feedback" role="status" aria-live="polite"></p><details><summary>选中范围</summary><output id="range-output"></output></details></section></aside></div>
|
||||
<details class="settings"><summary>验证设置与快捷键</summary><p>正文获得焦点后:← / → 选择相邻词;Shift + ← / → 调整连续范围;Escape 取消。范围按钮也可用 Tab、Enter 操作。</p><label for="dictionary-mode">词典状态</label><select id="dictionary-mode"><option value="ready">正常</option><option value="empty">无结果</option><option value="missing">资源不可用</option></select><p>虚构文本与释义,保存仅在本次页面内有效。刷新即清空。</p><p id="event-status" role="status"></p></details>
|
||||
</main><footer>交互验证小样 · 不保存私人数据</footer><script type="module" src="/app.mjs"></script></body></html>
|
||||
@@ -0,0 +1,85 @@
|
||||
// UI selection boundaries only: this does not replace the #3 NLP tokenizer.
|
||||
const words = new Intl.Segmenter('en', { granularity: 'word' });
|
||||
const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function tokenize(text) {
|
||||
const tokens = [];
|
||||
for (const part of words.segment(text)) {
|
||||
if (part.isWordLike) {
|
||||
tokens.push({ text: part.segment, start: part.index, end: part.index + part.segment.length, word: true });
|
||||
} else {
|
||||
for (const item of graphemes.segment(part.segment)) {
|
||||
const start = part.index + item.index;
|
||||
tokens.push({ text: item.segment, start, end: start + item.segment.length, word: /\p{Extended_Pictographic}/u.test(item.segment) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function selection(text, tokens, first, last) {
|
||||
const start = tokens[first].start;
|
||||
const end = tokens[last].end;
|
||||
return {
|
||||
start, end, text: text.slice(start, end), first, last,
|
||||
start_cp: [...text.slice(0, start)].length,
|
||||
end_cp: [...text.slice(0, end)].length,
|
||||
start_utf8: encoder.encode(text.slice(0, start)).length,
|
||||
end_utf8: encoder.encode(text.slice(0, end)).length,
|
||||
start_utf16: start, end_utf16: end,
|
||||
};
|
||||
}
|
||||
|
||||
// Inputs and outputs use half-open UTF-16 offsets into the unchanged original.
|
||||
export function normalizeRange(text, tokens, start, end) {
|
||||
if (![start, end].every(n => Number.isInteger(n) && n >= 0 && n <= text.length) || start === end) return null;
|
||||
const low = Math.min(start, end);
|
||||
const high = Math.max(start, end);
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
tokens.forEach((token, index) => {
|
||||
if (token.word && token.start < high && token.end > low) {
|
||||
if (first === -1) first = index;
|
||||
last = index;
|
||||
}
|
||||
});
|
||||
return first === -1 ? null : selection(text, tokens, first, last);
|
||||
}
|
||||
|
||||
export function pointSelection(text, tokens, index) {
|
||||
return Number.isInteger(index) && tokens[index]?.word ? selection(text, tokens, index, index) : null;
|
||||
}
|
||||
|
||||
// direction -1/+1 moves a boundary left/right; it never collapses a selection.
|
||||
export function adjustRange(text, tokens, range, edge, direction) {
|
||||
if (!range || !['start', 'end'].includes(edge) || ![-1, 1].includes(direction)) return range;
|
||||
let next = (edge === 'start' ? range.first : range.last) + direction;
|
||||
while (next >= 0 && next < tokens.length && !tokens[next].word) next += direction;
|
||||
if (next < 0 || next >= tokens.length) return range;
|
||||
const first = edge === 'start' ? next : range.first;
|
||||
const last = edge === 'end' ? next : range.last;
|
||||
return first > last ? range : selection(text, tokens, first, last);
|
||||
}
|
||||
|
||||
export function occurrences(text, needle) {
|
||||
if (!needle) return [];
|
||||
const tokens = tokenize(text);
|
||||
const result = [];
|
||||
for (let start = text.indexOf(needle); start !== -1; start = text.indexOf(needle, start + 1)) {
|
||||
const end = start + needle.length;
|
||||
const range = normalizeRange(text, tokens, start, end);
|
||||
if (range?.start === start && range.end === end) result.push(range);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display precedence only. Saved occurrence identities remain separate.
|
||||
export function resolveOverlaps(ranges) {
|
||||
const sorted = [...ranges].sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
const result = [];
|
||||
for (const range of sorted) {
|
||||
if (range.end > range.start && (!result.length || range.start >= result.at(-1).end)) result.push(range);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tokenize, normalizeRange, pointSelection, adjustRange, occurrences, resolveOverlaps } from './range.mjs';
|
||||
|
||||
test('partition preserves original Unicode, whitespace and punctuation exactly', () => {
|
||||
const text = 'Hi, cafe\u0301!\n🙂 👩🚀 well-known 中文';
|
||||
const tokens = tokenize(text);
|
||||
assert.equal(tokens.map(t => t.text).join(''), text);
|
||||
let end = 0;
|
||||
for (const t of tokens) {
|
||||
assert.equal(t.start, end);
|
||||
assert.equal(text.slice(t.start, t.end), t.text);
|
||||
end = t.end;
|
||||
}
|
||||
assert.deepEqual(tokens.filter(t => t.word).map(t => t.text), ['Hi', 'cafe\u0301', '🙂', '👩🚀', 'well', 'known', '中文']);
|
||||
});
|
||||
|
||||
test('reverse partial selection aligns words and preserves interior punctuation/newlines', () => {
|
||||
const text = ' bright,\nsmall step! ';
|
||||
const tokens = tokenize(text);
|
||||
const range = normalizeRange(text, tokens, 18, 4);
|
||||
assert.equal(range?.text, 'bright,\nsmall step');
|
||||
assert.equal(range.start, 2);
|
||||
assert.equal(range.end, 20);
|
||||
assert.deepEqual(normalizeRange(text, tokens, 0, text.length), range);
|
||||
});
|
||||
|
||||
test('collapsed, whitespace, punctuation and invalid bounds do not select neighbors', () => {
|
||||
const text = 'a, b';
|
||||
const tokens = tokenize(text);
|
||||
for (const [a, b] of [[1, 1], [1, 3], [-1, 2], [0, 5], [NaN, 3], [0.5, 3]]) {
|
||||
assert.equal(normalizeRange(text, tokens, a, b), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('selection cannot cut a surrogate pair, ZWJ or combining grapheme', () => {
|
||||
const text = '🙂 e\u0301 👩🚀';
|
||||
const tokens = tokenize(text);
|
||||
const first = normalizeRange(text, tokens, 1, 2);
|
||||
assert.equal(first?.text, '🙂');
|
||||
const accent = normalizeRange(text, tokens, 4, 5);
|
||||
assert.equal(accent?.text, 'e\u0301');
|
||||
const astronaut = normalizeRange(text, tokens, 8, 9);
|
||||
assert.equal(astronaut?.text, '👩🚀');
|
||||
assert.deepEqual([astronaut.start_cp, astronaut.end_cp, astronaut.start_utf8, astronaut.end_utf8, astronaut.start_utf16, astronaut.end_utf16], [5, 8, 9, 20, 6, 11]);
|
||||
});
|
||||
|
||||
test('point selection and boundary adjustment skip separators and never invert', () => {
|
||||
const text = 'one, two\nthree';
|
||||
const tokens = tokenize(text);
|
||||
const middle = tokens.findIndex(t => t.text === 'two');
|
||||
const one = pointSelection(text, tokens, middle);
|
||||
assert.equal(one?.text, 'two');
|
||||
assert.equal(pointSelection(text, tokens, middle - 1), null);
|
||||
const left = adjustRange(text, tokens, one, 'start', -1);
|
||||
assert.equal(left?.text, 'one, two');
|
||||
const all = adjustRange(text, tokens, left, 'end', 1);
|
||||
assert.equal(all?.text, text);
|
||||
assert.equal(adjustRange(text, tokens, all, 'start', 1)?.text, 'two\nthree');
|
||||
assert.equal(adjustRange(text, tokens, all, 'end', -1)?.text, 'one, two');
|
||||
assert.deepEqual(adjustRange(text, tokens, one, 'start', 1), one);
|
||||
assert.deepEqual(adjustRange(text, tokens, one, 'end', -1), one);
|
||||
assert.deepEqual(adjustRange(text, tokens, all, 'start', -1), all);
|
||||
assert.deepEqual(adjustRange(text, tokens, all, 'end', 1), all);
|
||||
});
|
||||
|
||||
test('occurrences preserve distinct offsets, case and exact combining form', () => {
|
||||
const text = 'a small step; a small step. A small step. steps e\u0301 é';
|
||||
assert.deepEqual(occurrences(text, 'a small step').map(r => [r.start, r.end]), [[0, 12], [14, 26]]);
|
||||
assert.equal(occurrences(text, 'step').length, 3);
|
||||
assert.equal(occurrences(text, 'e\u0301').length, 1);
|
||||
assert.equal(occurrences(text, 'é').length, 1);
|
||||
assert.equal(occurrences(text, 'small st').length, 0);
|
||||
assert.deepEqual(occurrences(text, ''), []);
|
||||
});
|
||||
|
||||
test('overlapping occurrences remain available; display picks leftmost longest without mutation', () => {
|
||||
assert.deepEqual(occurrences('a a a', 'a a').map(r => r.start), [0, 2]);
|
||||
const ranges = [{ start: 2, end: 9, id: 'later' }, { start: 0, end: 3, id: 'short' }, { start: 0, end: 5, id: 'long' }, { start: 5, end: 7, id: 'next' }];
|
||||
const before = structuredClone(ranges);
|
||||
assert.deepEqual(resolveOverlaps(ranges).map(r => r.id), ['long', 'next']);
|
||||
assert.deepEqual(ranges, before);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Serve only the fictional reading experiment; default is loopback."""
|
||||
import argparse
|
||||
import ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
FILES = {'/': ('index.html', 'text/html'), '/style.css': ('style.css', 'text/css'),
|
||||
'/app.mjs': ('app.mjs', 'text/javascript'), '/range.mjs': ('range.mjs', 'text/javascript'),
|
||||
'/fixtures.mjs': ('fixtures.mjs', 'text/javascript')}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
timeout = 10
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
item = FILES.get(self.path)
|
||||
data = (ROOT / item[0]).read_bytes() if item else b'Not found'
|
||||
self.send_response(200 if item else 404)
|
||||
self.send_header('Content-Type', (item[1] if item else 'text/plain') + '; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; frame-ancestors 'none'; base-uri 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host', default='127.0.0.1', help='Numeric local address; LAN testing is opt-in')
|
||||
parser.add_argument('--port', type=int, default=5184)
|
||||
args = parser.parse_args()
|
||||
ipaddress.IPv4Address(args.host)
|
||||
with HTTPServer((args.host, args.port), Handler) as server:
|
||||
print(f'LexGo selection experiment: http://{args.host}:{server.server_port}/', flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:"Segoe UI","Microsoft YaHei",sans-serif;color:#203d33;background:#f6f4ed;--green:#285c49;--line:#dbe1d8}*{box-sizing:border-box}body{margin:0}header{height:76px;background:#fff;border-bottom:1px solid var(--line);padding:0 5%;display:flex;align-items:center;justify-content:space-between}header>span{color:#6c7971;font-size:13px}.brand{color:inherit;text-decoration:none;font-size:28px;font-weight:750;letter-spacing:-1px}.brand span{color:#538461}main{max-width:1220px;margin:36px auto;padding:0 24px}.book{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:25px}.eyebrow{font-size:11px;letter-spacing:2px;color:#738276}h1{font-size:26px;font-weight:600;margin:10px 0}nav{display:flex;gap:12px;align-items:center;white-space:nowrap}button,input,textarea,select{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:var(--green);color:#fff;touch-action:manipulation}button:disabled{opacity:.4;cursor:default}.quiet{background:#fff;color:var(--green)}button:focus-visible,select:focus-visible,textarea:focus-visible,article:focus-visible{outline:2px solid #588364;outline-offset:3px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:24px;align-items:start}.paper{background:#fff;border:1px solid var(--line);border-radius:8px;padding:32px 38px}.hint{font-size:12px;color:#738276;margin:0 0 22px}article{font:22px/1.95 Georgia,"Times New Roman",serif;white-space:pre-wrap;overflow-wrap:anywhere;user-select:text;-webkit-user-select:text;touch-action:auto;outline-offset:10px}article span.word{cursor:text}article .learning{background:#f5e7b7}article .known{box-shadow:inset 0 -2px #a2c1ab}article .ignored{color:#849086}article .selected{background:#d7e9b3;box-shadow:inset 0 -2px #56743d}article ::selection{background:#bad59a;color:#18321e}.side{position:sticky;top:18px;min-height:100px}#empty{font-size:14px;color:#859186;padding:20px;text-align:center}#panel{background:#fff;border:1px solid var(--line);border-radius:8px;padding:22px}.panel-top{display:flex;align-items:center;justify-content:space-between;color:#7b897f;font-size:12px}h2{font:27px/1.4 Georgia,serif;margin:14px 0;overflow-wrap:anywhere;max-height:120px;overflow:auto}.range-controls{display:flex;align-items:center;gap:5px;font-size:12px;flex-wrap:wrap}.range-controls button{min-width:32px;padding:6px;background:#f5f7ef;color:var(--green)}.range-controls span:nth-of-type(2){margin-left:8px}#dictionary{font-size:14px;line-height:1.6;border-top:1px solid var(--line);padding-top:16px}label{display:block;font-size:12px;color:#667b6d;margin:16px 0 7px}textarea,select{width:100%;border:1px solid var(--line);border-radius:4px;padding:9px;background:white;color:inherit}textarea{resize:vertical}#save{width:100%;margin-top:18px}#feedback{min-height:20px;font-size:13px;color:#467345}details{font-size:12px;color:#748276}summary{cursor:pointer}output{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.5 Consolas,monospace;margin-top:10px}.settings{margin-top:28px;max-width:720px;line-height:1.8}.settings select{max-width:240px}footer{text-align:center;font-size:12px;color:#839083;padding:30px}[hidden]{display:none!important}@media(max-width:760px){header{height:60px}.book{display:block}nav{margin-top:18px;justify-content:space-between}main{padding:0 14px;margin-top:22px}.layout{display:block}.paper{padding:24px 20px}article{font-size:20px;line-height:1.95}article .word{scroll-margin-bottom:45dvh}body{padding-bottom:360px}.side{position:static;min-height:0}#empty{display:none}#panel{position:fixed;bottom:0;left:0;right:0;z-index:2;max-height:42dvh;overflow:auto;overscroll-behavior:contain;border-radius:14px 14px 0 0;padding:16px 20px;box-shadow:0 -4px 20px #233c3314}.panel-top{position:sticky;top:-16px;background:#fff;padding:5px 0}h2{font-size:23px;max-height:65px}.range-controls button{min-height:40px;min-width:40px}}
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFile} from 'node:fs/promises'
|
||||
import {JSDOM} from '../../learner/node_modules/jsdom/lib/api.js'
|
||||
|
||||
let serial=0
|
||||
async function page(run){
|
||||
const html=await readFile(new URL('./index.html',import.meta.url),'utf8')
|
||||
const dom=new JSDOM(html,{url:'http://127.0.0.1:5184/'})
|
||||
globalThis.window=dom.window;globalThis.document=dom.window.document
|
||||
dom.window.scrollTo=()=>{}
|
||||
dom.window.HTMLElement.prototype.scrollIntoView=()=>{}
|
||||
await import(`./app.mjs?test=${++serial}`)
|
||||
const $=id=>document.getElementById(id)
|
||||
try{await run($,dom.window)}finally{await new Promise(resolve=>setTimeout(resolve,120));dom.window.close()}
|
||||
}
|
||||
|
||||
test('close preserves keyboard cursor: next arrow continues at the adjacent word',()=>page(($,w)=>{
|
||||
const span=[...$('reader').children].find(n=>n.textContent==='workshop')
|
||||
span.click();assert.equal($('term').textContent,'workshop')
|
||||
$('close').click();assert.equal($('panel').hidden,true)
|
||||
$('reader').dispatchEvent(new w.KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))
|
||||
assert.equal($('term').textContent,'before')
|
||||
}))
|
||||
test('native punctuation-only selection clears stale word and cannot save it',()=>page(async($,w)=>{
|
||||
[...$('reader').children].find(n=>n.textContent==='curiosity').click()
|
||||
const dot=[...$('reader').children].find(n=>n.textContent==='.')
|
||||
const range=document.createRange();range.selectNodeContents(dot)
|
||||
w.getSelection().removeAllRanges();w.getSelection().addRange(range)
|
||||
document.dispatchEvent(new w.Event('selectionchange'))
|
||||
await new Promise(resolve=>setTimeout(resolve,130))
|
||||
assert.equal($('panel').hidden,true)
|
||||
assert.equal($('reader').querySelectorAll('.selected').length,0)
|
||||
}))
|
||||
test('native selection maps DOM text through spans and expands to full token endpoints',()=>page(async($,w)=>{
|
||||
const spans=[...$('reader').children]
|
||||
const start=spans.find(n=>n.textContent==='Mira'),end=spans.find(n=>n.textContent==='workshop')
|
||||
const range=document.createRange();range.setStart(start.firstChild,2);range.setEnd(end.firstChild,3)
|
||||
w.getSelection().removeAllRanges();w.getSelection().addRange(range)
|
||||
document.dispatchEvent(new w.Event('selectionchange'))
|
||||
await new Promise(resolve=>setTimeout(resolve,130))
|
||||
assert.equal($('term').textContent,'Mira opened the workshop')
|
||||
const result=JSON.parse($('range-output').textContent)
|
||||
assert.equal($('reader').textContent.slice(...result.utf16),result.text)
|
||||
}))
|
||||
test('explicit save updates repeated words; unsaved edits are discarded on close',()=>page(($)=>{
|
||||
const words=[...$('reader').children].filter(n=>n.textContent==='curiosity')
|
||||
words[0].click();$('meaning').value='虚构释义';$('save').click()
|
||||
assert.ok(words.every(n=>n.classList.contains('learning')))
|
||||
$('meaning').value='未保存修改';$('close').click();words[1].click()
|
||||
assert.equal($('meaning').value,'虚构释义')
|
||||
$('next').click();assert.equal($('panel').hidden,true)
|
||||
}))
|
||||
Reference in New Issue
Block a user