diff --git a/agents.md b/agents.md index bda1a07..95007d9 100644 --- a/agents.md +++ b/agents.md @@ -293,6 +293,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。 - 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。 - 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0,R4.11 在真窗口核对。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点:Windows 写键名(`ctrl-shift-[`),macOS 与 Linux 写字符(`cmd-{`),见 `blocks::shifted`。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。 -- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。 +- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。 - 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。 - 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。 diff --git a/apps/ely-mark/src/blocks/context.rs b/apps/ely-mark/src/blocks/context.rs index ac403e5..0b759f0 100644 --- a/apps/ely-mark/src/blocks/context.rs +++ b/apps/ely-mark/src/blocks/context.rs @@ -161,3 +161,35 @@ fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) { "{html}" ); } + +#[gpui::test] +fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) { + let (_desk, cx, _files) = opened("alpha *it*\n", cx); + cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into())); + // The context's dispatch parks; this one does not. + cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx)); + assert_eq!(clipboard(cx).as_deref(), Some("before"), "not on the window thread"); + cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx)); + cx.run_until_parked(); + assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n")); +} + +#[gpui::test] +fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) { + let (desk, cx, files) = opened("alpha\n", cx); + right_on(0, 1, cx); + assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened"); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut"); + let other = files[1].path().join("b.md"); + std::fs::write(&other, "beta\n").expect("b"); + crate::desk::tests::open(&desk, &other, cx); + let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id); + desk.update(cx, |desk, cx| { + desk.core.active = Some(first); + cx.notify(); + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed"); +} diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index b941677..14291d2 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -56,6 +56,8 @@ pub struct Desk { asked_print: Option, /// The find box, while open. pub(crate) finding: Option, + /// The copy being converted; a newer one drops it. + pub(crate) copying: Option>, } impl Host for Desk { @@ -205,6 +207,7 @@ pub(crate) fn open_in( asked: None, asked_print: None, finding: None, + copying: None, } }); let menus = crate::keys::menus(&look, cx); diff --git a/apps/ely-mark/src/export/copy.rs b/apps/ely-mark/src/export/copy.rs index f60af73..f4163c4 100644 --- a/apps/ely-mark/src/export/copy.rs +++ b/apps/ely-mark/src/export/copy.rs @@ -9,15 +9,30 @@ use ely_ui::i18n::text; use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions}; use crate::desk::Desk; -use crate::output::{self, Pictures}; +use crate::output::{self, Page, Pictures}; actions!(mark, [CopyRich, CopyHtml, CopyPlain]); +/// What a copy turns the Markdown into. +#[derive(Clone, Copy)] +enum As { + Rich, + Html, + Plain, +} + +/// A converted copy, ready for the clipboard. +enum Copied { + Rich(Clip, Page), + Html(String, Page), + Plain(String), +} + impl Desk { pub(super) fn copy_actions(root: Div, cx: &mut Context) -> Div { - root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx))) - .on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy_html(cx))) - .on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy_plain(cx))) + root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx))) + .on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx))) + .on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx))) } /// The selection or whole text, and the file's folder. @@ -44,52 +59,64 @@ impl Desk { self.toast(said, Severity::Danger, cx); } - /// HTML for pasting, the Markdown beside it. - fn copy_rich(&mut self, cx: &mut Context) { + /// Converts in the background, then writes the clipboard. + fn copy(&mut self, kind: As, cx: &mut Context) { let Some((markdown, beside)) = self.copied_text(cx) else { return; }; - let copied = - output::body(&markdown, beside.as_deref(), &Pictures::Inline).and_then(|mut page| { - let clip = Clip::Html { - html: std::mem::take(&mut page.html), - text: markdown, - }; - clipboard::write(&clip) - .map(|()| page) - .map_err(|error| std::io::Error::other(format!("{error:#}"))) + self.copying = Some(cx.spawn(async move |desk, cx| { + let converted = cx + .background_executor() + .spawn(async move { convert(kind, markdown, beside) }) + .await; + let landed = desk.update(cx, |desk, cx| { + desk.copying = None; + desk.copied(converted, cx); }); - match copied { - Ok(page) => self.left_out(&page, cx), - Err(error) => self.copy_failed(&error, cx), - } - } - - /// The HTML as text; pictures keep their addresses. - fn copy_html(&mut self, cx: &mut Context) { - let Some((markdown, beside)) = self.copied_text(cx) else { - return; - }; - match output::body(&markdown, beside.as_deref(), &Pictures::Written) { - Ok(mut page) => { - log::info!("mark: {} bytes copied as HTML code", page.html.len()); - cx.write_to_clipboard(ClipboardItem::new_string(std::mem::take(&mut page.html))); - self.left_out(&page, cx); + if landed.is_err() { + log::warn!("mark: a copy finished after its window closed"); } - Err(error) => self.copy_failed(&error, cx), - } + })); } - fn copy_plain(&mut self, cx: &mut Context) { - let Some((markdown, _)) = self.copied_text(cx) else { - return; - }; - match output::plain(&markdown) { - Ok(plain) => { + fn copied(&mut self, converted: std::io::Result, cx: &mut Context) { + let written = converted.and_then(|copied| match copied { + Copied::Rich(clip, page) => clipboard::write(&clip) + .map(|()| Some(page)) + .map_err(|error| std::io::Error::other(format!("{error:#}"))), + Copied::Html(html, page) => { + log::info!("mark: {} bytes copied as HTML code", html.len()); + cx.write_to_clipboard(ClipboardItem::new_string(html)); + Ok(Some(page)) + } + Copied::Plain(plain) => { log::info!("mark: {} bytes copied as plain text", plain.len()); cx.write_to_clipboard(ClipboardItem::new_string(plain)); + Ok(None) } + }); + match written { + Ok(Some(page)) => self.left_out(&page, cx), + Ok(None) => {} Err(error) => self.copy_failed(&error, cx), } } } + +/// The copy as `kind`, converted off the window thread. +fn convert(kind: As, markdown: String, beside: Option) -> std::io::Result { + let beside = beside.as_deref(); + match kind { + As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| { + let html = std::mem::take(&mut page.html); + let clip = Clip::Html { + html, + text: markdown, + }; + Copied::Rich(clip, page) + }), + As::Html => output::body(&markdown, beside, &Pictures::Written) + .map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)), + As::Plain => output::plain(&markdown).map(Copied::Plain), + } +} diff --git a/apps/ely-mark/src/export/tests.rs b/apps/ely-mark/src/export/tests.rs index e4f8d48..87d32d3 100644 --- a/apps/ely-mark/src/export/tests.rs +++ b/apps/ely-mark/src/export/tests.rs @@ -74,6 +74,7 @@ fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) { .update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx)) }); cx.dispatch_action(crate::export::CopyRich); + cx.run_until_parked(); let clip = ely_platform::clipboard::read() .expect("reads") .expect("a clip"); diff --git a/crates/ely-office/src/menu.rs b/crates/ely-office/src/menu.rs index 23b6b49..e4271f9 100644 --- a/crates/ely-office/src/menu.rs +++ b/crates/ely-office/src/menu.rs @@ -1,6 +1,6 @@ //! Ely's right-click menus, gated by the kernel's (DOC-02). -use std::io; +use std::{cell::Cell, io}; use ely_gpui_component::{ menus::{ContextMenu, Menu, MenuItem}, @@ -15,18 +15,21 @@ pub struct Offer { pub what: W, /// Bumps for each menu, so each opens once. request: (u64, Point), + /// Handed to a host; a remounted host must not reopen. + served: Cell, } impl Offer { /// The next menu after `last`. pub fn new(last: Option<&Offer>, tab: u64, what: W, at: Point) -> Self { let number = last.map_or(1, |last| last.request.0 + 1); - Offer { tab, what, request: (number, at) } + Offer { tab, what, request: (number, at), served: Cell::new(false) } } - /// Its host, opened at the point. + /// Its host; only the first render opens it. pub fn host(&self, id: &'static str, menu: Menu) -> ContextMenu { - ContextMenu::new(id, menu).manual(Some(self.request)) + let request = (!self.served.replace(true)).then_some(self.request); + ContextMenu::new(id, menu).manual(request) } } diff --git a/tasks/TASKS.md b/tasks/TASKS.md index e18d481..d033b00 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -242,7 +242,7 @@ - [~] R4.8 组件库:表格行列上下左右移动、整理表格源码;代码块取代码内容;与已有的增删行列同一套拒绝。组件 bcee87c 至 6b033f3(已推送):`TableEdit` 加上下移行(表头与首行互换,超出表头列数的行不作表头,到边拒绝为 `TableRefusal::Edge`)、左右移列、整理;表格按显示宽度补齐(`unicode-width`,Tab 记 4、控制字符记 0,照 Typora 的 `wcswidth`),写回表格自身的换行;表格编辑并入后台执行器;右键代码块时 `DocumentTarget` 带出代码文字。审查三轮未过(`tasks/reviews/R4.8-r1..r3.md`):r1 两项(纯 CR 文本里整理表格把前文当作容器前缀、行内 Tab 宽度不合 Typora);r2 一项(End 后插入表格拆开 CRLF);r3 一项(r2 的修法让行辅助函数返回光标之前的位置,End 后的块命令崩溃)。r3 已修:编辑器只按 LF 分行,光标可落在 CR 与 LF 之间;执行器与插入表格在入口把选区移到 CR 前,行辅助函数改为断言不收这种位置;两项回归在 r3 的源码上失败(`.work/R4.8-fix3/red.md`);轮数用尽,未再审。遗留:编辑器自身的 CRLF 模型不变,End 仍落在 CR 之后;表情宽度按 `unicode-width` 记 2 列(Typora 记 1) - [x] R4.9 ElyMark:“段落”“格式”菜单与 Typora 同项同键(Ctrl+1–6、Ctrl+0、Ctrl+=/-、Ctrl+B/I/U、Ctrl+Shift+`、Alt+Shift+5、Ctrl+K、Ctrl+Shift+I、Ctrl+\、Ctrl+T、Ctrl+Shift+K/M/Q/[/]);源码模式 Ctrl+/;字号 Ctrl+Shift+=/-/0;专注 F8、打字机 F9;表格菜单加移动行列;复制为 Markdown Ctrl+Shift+C、粘贴为纯文本 Ctrl+Shift+V。ElyMark 5392062、f84f990、09fcb0b,组件 6d90351、24ffc45、6c2d28c(已推送),pin 6c2d28c:键与菜单项取自 Typora 字节码与其中文菜单;表格为“段落”子菜单,加删除表格;拒绝成提示;菜单借打开的块的焦点,块与选区留着;复制为 Markdown、粘贴为纯文本只作用于文档。R1 FAIL 六项、R2 FAIL 四项已修,R3 PASS - [x] R4.13 全部应用:中文显示(用户报,2026-10-14):Windows 上同一词里字形粗细不一(gpui 以 en-US 排版,汉字按日文回退);Ely 用到的组件里写死的英文改走中英目录;先于 R4.10 的修复做。第 1 轮未过(三项:排版语言不随 Ely 的中文设置、查找框英文、排序筛选英文),已修,见 `.work/R4.13-fix1`;第 2 轮通过。 -- [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类 +- [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类。第 1 轮未过(五项:保留选区时目标取选区起点、打开块里 HTML 链接读错、切回标签重开已关的菜单、复制为 HTML 与纯文本卡界面、长注释),已修,见 `.work/R4.10-fix1` - [x] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做。第 1 轮 4 项:令牌被拒时连接重置使重试失效、慢速来者占住门、主窗口关后仍回执、ElyMail 与 ElyAgent 不到前台;已修,各有回归;第 2 轮通过 - [ ] R4.11 验收:真窗口逐项操作,每项功能可用(不求与 Typora 一一相同);帮助、PRD、`agents.md` 随之;逐条核对表