ElyMark: the page hands every link click to the app (M-05)

Preview clicks and Cmd/Ctrl clicks in the editor go through one
click handler; it stops the browser and sends the link mark's own
address, since Milkdown blanks unsafe hrefs in the DOM. Reviews run
at effort xhigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-10 11:24:05 -04:00
1 parent fa5c0b7a75
commit c5f614d095
12 files changed
+160 -18

No files matched your search

+4 -2
View File
@@ -157,6 +157,7 @@ tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖)
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
@@ -191,7 +192,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 5d6efd4(2026-10-09 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
@@ -278,5 +279,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
+19 -10
View File
@@ -266,16 +266,7 @@ impl MarkView {
cx.emit(Reported);
}
Event::Query(said, reply) => self.query(said, reply, cx),
// A click on a link: web and mail open outside.
Event::Leave(url) if ely_office::outward(url) => {
log::info!("mark: opens a link outside");
cx.open_url(url);
}
Event::Leave(_) => {
log::info!("mark: a link of another kind was refused");
let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string();
cx.emit(Failed(said));
}
Event::Leave(url) => self.follow(url, cx),
Event::Failed(why) => {
let lapsed = self.tally.lapse();
self.end_renders(lapsed, why, cx);
@@ -301,6 +292,17 @@ impl MarkView {
});
}
/// A clicked link: web and mail open outside.
fn follow(&mut self, url: &str, cx: &mut Context<Self>) {
if ely_office::outward(url) {
log::info!("mark: opens a link outside");
return cx.open_url(url);
}
log::info!("mark: a link of another kind was refused");
let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string();
cx.emit(Failed(said));
}
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
let said: Value = match serde_json::from_str(said) {
Ok(said) => said,
@@ -367,6 +369,13 @@ impl MarkView {
return reply.fail("bad search state");
}
},
(Some("link"), ..) => match said["href"].as_str() {
Some(url) => self.follow(url, cx),
None => {
log::error!("mark: the page sent a link without its address");
return reply.fail("bad link");
}
},
(Some("table"), ..) if said["done"] == false => {
let key = match self.mode {
Mode::Visual => "mark.table.none",
+4 -1
View File
@@ -43,6 +43,9 @@ function flush() {
pending = null
}
/** A link the user opens; the app decides. */
const linked = (href) => tell({ kind: 'link', generation, href })
/** The shown editor's headings, for the app's outline. */
function outline() {
if (current) tell({ kind: 'outline', generation, items: current.outline() })
@@ -83,7 +86,7 @@ async function build(text, mode) {
const edited = (write) => changed(() => front + write())
let made
try {
made = await visual(place, body, mode === 'visual', texts, edited, edge)
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
} catch (error) {
// A broken editor falls back to source and says so.
console.error(`ely: the visual editor failed: ${error.name}`)
+25 -1
View File
@@ -39,7 +39,7 @@ import { search } from 'prosemirror-search'
import { style } from './style.js'
/** An editor over `markdown`; null when blocks cannot map. */
export async function visual(root, markdown, editable, texts, changed, edge) {
export async function visual(root, markdown, editable, texts, changed, edge, linked) {
const owner = diagrams()
let write = () => ({ text: markdown, spans: [] })
// Setting up raw blocks is no edit.
@@ -71,6 +71,29 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
},
}),
)
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
const mac = navigator.platform.startsWith('Mac')
const links = $prose(
() =>
new Plugin({
props: {
handleDOMEvents: {
click: (view, event) => {
const anchor = event.target.closest?.('a')
if (!anchor || !view.dom.contains(anchor)) return false
event.preventDefault()
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
// The DOM href is sanitized; the mark is not.
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
const mark = node?.marks.find((mark) => mark.type.name === 'link')
if (!mark) throw new Error('ely: a link without its mark')
linked(mark.attrs.href)
return true
},
},
},
}),
)
// Past their history, undo and redo reach the page.
const ends = $prose(() =>
keymap({
@@ -103,6 +126,7 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
.use(watch)
.use(current)
.use(ends)
.use(links)
.use($prose(() => search()))
.create()
const editor = await made.catch((error) => {
+17 -1
View File
@@ -267,7 +267,23 @@ pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
/// Addresses a click may open outside: web and mail.
pub fn outward(uri: &str) -> bool {
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
// Schemes ignore case.
["https://", "http://", "mailto:"]
.iter()
.any(|scheme| uri.get(..scheme.len()).is_some_and(|head| head.eq_ignore_ascii_case(scheme)))
}
#[cfg(test)]
mod outward_tests {
#[test]
fn web_and_mail_go_out_in_any_case() {
for uri in ["https://a.example/", "HTTPS://a.example/", "Http://a.example/", "MAILTO:x@a.example"] {
assert!(super::outward(uri), "{uri}");
}
for uri in ["ftp://a.example/", "file:///etc/passwd", "javascript:alert(1)", "httpsx://a", "ht", "\u{e9}https://"] {
assert!(!super::outward(uri), "{uri}");
}
}
}
/// A localized message.
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Usage: review.sh <item-id> <round> [path...] — codex high, fast review.
# Usage: review.sh <item-id> <round> [path...] — codex xhigh, fast review.
set -euo pipefail
[ "${HERDR_ENV:-}" = 1 ] || { echo "review.sh: not inside herdr" >&2; exit 2; }
@@ -37,7 +37,7 @@ pane=$(herdr pane split --current --direction right --cwd "$PWD" --no-focus | jq
echo "$pane" >"$last"
started=
for _ in 1 2 3 4 5; do
if err=$(herdr agent start "$name" --kind codex --pane "$pane" --timeout 120000 -- --yolo --model gpt-6.1-sol -c 'model_reasoning_effort="high"' -c 'service_tier="fast"' -c check_for_update_on_startup=false 2>&1); then
if err=$(herdr agent start "$name" --kind codex --pane "$pane" --timeout 120000 -- --yolo --model gpt-6.1-sol -c 'model_reasoning_effort="xhigh"' -c 'service_tier="fast"' -c check_for_update_on_startup=false 2>&1); then
started=1
break
fi
+1 -1
View File
@@ -65,7 +65,7 @@
- [x] M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定” — 刷新即换代、归零并结束欠下的渲染(tally.rs);R1 渲染回调悬空、无在途消息的测试;R2 测试未证旧代归零;R3 PASS
- [x] M-03 干净标签接过磁盘文字后关闭仍问“是否保存” — 接过磁盘文字的各路(同文、干净重载、合并)都经 adopt_disk,标签与视图的保存基线同设;R1 合并采用未同步视图基线;R2 注释超长;R3 PASS
- [x] M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误 — 每份打印与导出各有编号与文件,作业按编号保留、互不取消;每份作业完成时记下编号与结果;R1 窗口测试只数日志;R2 PASS(窗口测试只在 macOS,本机未运行)
- [ ] M-05 点页面里的链接什么也不发生
- [x] M-05 点页面里的链接什么也不发生 — 页面拦下链接单击,地址取链接标记原文,预览单击、可视 Cmd/Ctrl 单击交应用(审查 4 轮)
- [ ] M-06 `on_app_quit` 无条件删恢复点与崩溃锁,AppKit 直接终止时丢未保存内容
- [ ] M-07 崩溃恢复的未命名副本按“第一个显示的未命名标签”配对,内容可能落错标签
- [ ] M-08 页面重载后悬着的渲染回调永不完成,导出既不成功也不报错
+3
View File
@@ -0,0 +1,3 @@
FAIL
- apps/ely-mark/src/view.rs:270: The fix handles only `Event::Leave`, but visual-mode links never produce it. Open `[web](https://example.com/)` or `[mail](mailto:test@example.com)` in visual mode and click the link: Chromium treats the anchor as editable text, so no navigation reaches `Requests::on_before_browse` and neither the browser nor mail client opens. Disallowed links likewise produce no refusal. A WSL probe built the current bundled page and used real Playwright clicks: HTTPS, mailto and FTP each produced zero `Page.frameRequestedNavigation` events in visual mode; all three produced `anchorClick` events in preview. Ctrl-click on the visual HTTPS link also produced no navigation. Handle explicit link activation in the visual editor and forward it through the host's allowed/refused link policy. Add regression coverage that clicks rendered links in both modes and verifies opening/refusal; the current Cargo suites contain no M-05 test and pass on WSL despite this failure.
+5
View File
@@ -0,0 +1,5 @@
FAIL
- apps/ely-mark/web/src/visual.js:82: Link activation uses the marks at a rounded caret position instead of the clicked anchor. Real Playwright clicks against freshly built pages reproduce this on native Windows and WSL: in `Go [website](https://example.com/) now.`, Ctrl-click 1 px inside the link's left edge sends no `link` query; in `[one](https://one.example/)[two](https://two.example/)`, the same click inside `two` sends `https://one.example/`. Clicking just after `website` also activates that link. Resolve the actual clicked anchor, including its descendants, rather than the insertion-position marks. Add regression clicks at both edges and between adjacent links; the current center-click test passes despite these failures.
- crates/ely-office/src/lib.rs:270: The allowed-scheme check is case-sensitive, while the new visual handler forwards the original Markdown address. For `[upper](HTTPS://example.com/)`, Ctrl-click sends `HTTPS://example.com/` and `MarkView::follow` emits the refusal message; preview click produces the normalized `https://example.com/` and opens outside. Both page paths were verified on Windows and WSL. HTTP, HTTPS and mailto schemes are case-insensitive, so these paths violate the required shared policy. Parse or normalize the scheme consistently before checking it, and test the host's allowed/refused outcome for mixed-case schemes as well as page event delivery.
+3
View File
@@ -0,0 +1,3 @@
FAIL
- apps/ely-mark/web/src/visual.js:82: Plain preview clicks still bypass the required refusal policy when Milkdown sanitizes a link's scheme. Open `[local](file:///outside/file.md)` or `[data](data:text/plain,hello)` in preview and click it: the rendered anchor has `href=""`, so Chromium navigates to the bundled `index.html` instead of sending a link query. That URL passes `Surface::navigable`, so `Requests::on_before_browse` never queues `Event::Leave` and `MarkView::follow` never displays the refusal. Fresh-page Playwright probes reproduced the empty href, same-page navigation and zero link queries on native Windows and WSL; a WSL CEF off-screen probe also confirmed a user-initiated navigation to the bundled index. Intercept preview link activation before the browser default, apply the host policy to sanitized/empty addresses too, and add preview regression cases for these schemes; the current FTP-only refusal delivery coverage misses them.
+1
View File
@@ -0,0 +1 @@
PASS
+76
View File
@@ -0,0 +1,76 @@
// ElyMark's page: which clicks on links reach the app (M-05).
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '../..')
const page = join(root, 'engines/content/ely-mark/index.html')
if (!existsSync(page)) {
console.log('SKIP: no page; run scripts/build-mark-content.sh')
process.exit(0)
}
let chromium
try {
;({ chromium } = createRequire(join(root, 'site/package.json'))('@playwright/test'))
} catch {
console.log('SKIP: no Playwright; run pnpm install in site')
process.exit(0)
}
const fail = (why) => {
console.error(`FAIL: ${why}`)
process.exit(1)
}
const browser = await chromium.launch()
const tab = await browser.newPage()
await tab.addInitScript(() => {
window.asked = []
window.elyQuery = ({ request, onSuccess }) => {
window.asked.push(JSON.parse(request))
onSuccess('')
}
})
const left = []
await tab.route('https://example.com/**', (route) => {
left.push(route.request().url())
route.abort()
})
await tab.goto(pathToFileURL(page).href)
const doc = 'Go [web](https://example.com/) or [ftp](ftp://x.example/) and [one](https://one.example/)[two](https://two.example/) end.\n\n' +
'[local](file:///outside/file.md) [data](data:text/plain,hello) [empty]()\n'
const links = async () => (await tab.evaluate(() => window.asked)).filter((a) => a.kind === 'link').map((a) => a.href)
const opener = (await tab.evaluate(() => navigator.platform)).startsWith('Mac') ? 'Meta' : 'Control'
const anchor = (text) => tab.locator('a', { hasText: new RegExp(`^${text}$`) })
// Clicks `dx` from the anchor's left edge, mid-height.
const at = async (text, dx, modifiers) => {
const box = await anchor(text).boundingBox()
await tab.mouse.move(box.x + dx, box.y + box.height / 2)
for (const key of modifiers) await tab.keyboard.down(key)
await tab.mouse.down()
await tab.mouse.up()
for (const key of modifiers) await tab.keyboard.up(key)
}
await tab.evaluate((doc) => window.ely.open(doc, 'visual', {}, 1, '', null), doc)
await anchor('web').click()
if ((await links()).length) fail('a plain click in the editor opened a link')
await at('web', 1, [opener])
await at('web', (await anchor('web').boundingBox()).width + 3, [opener])
await at('two', 1, [opener])
await anchor('ftp').click({ modifiers: [opener] })
const asked = await links()
const want = 'https://example.com/ https://two.example/ ftp://x.example/'
if (asked.join(' ') !== want) fail(`the editor asked ${JSON.stringify(asked)}, not ${want}`)
await tab.evaluate((doc) => window.ely.open(doc, 'preview', {}, 2, '', null), doc)
const before = (await links()).length
const here = tab.url()
for (const text of ['web', 'local', 'data', 'empty']) await anchor(text).click()
await tab.waitForTimeout(500)
const opened = (await links()).slice(before)
const sent = 'https://example.com/ file:///outside/file.md data:text/plain,hello '
if (opened.join(' ') !== sent) fail(`preview asked ${JSON.stringify(opened)}, not ${JSON.stringify(sent)}`)
if (left.length || tab.url() !== here) fail(`preview navigated: ${JSON.stringify(left)} ${tab.url()}`)
await browser.close()
console.log('mark-links: an editor click edits, the opener key asks the app, preview asks the app')