From 3b1265702e84e1eaad5468bb7e586efabe720551 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Thu, 24 Sep 2026 20:14:00 +0800 Subject: [PATCH 1/3] Start session logging only after instance lock and clean completed plans --- docs/client_onboarding_plan.md | 72 ----------------------- docs/office_plugin_typography_refactor.md | 4 +- docs/user_data_storage.md | 2 +- src/application/bootstrap.py | 2 + src/assets/i18n/latexsnipper_en_US.ts | 8 +-- src/runtime/runtime_logging.py | 18 ++++-- test/test_runtime_logging.py | 20 +++++++ tools/mathjax/README.md | 10 ++-- 8 files changed, 47 insertions(+), 89 deletions(-) delete mode 100644 docs/client_onboarding_plan.md diff --git a/docs/client_onboarding_plan.md b/docs/client_onboarding_plan.md deleted file mode 100644 index bd4a9ced..00000000 --- a/docs/client_onboarding_plan.md +++ /dev/null @@ -1,72 +0,0 @@ -# 客户端首次使用引导方案 - -日期:2026-09-23 -状态:方案已确认,待实现。本文描述目标行为,不代表功能已经上线。 -适用范围:客户端主窗口;不涉及 Office 插件编辑器。 - -## 1. 目标与入口 - -首次进入主窗口后,通过轻量高亮引导帮助用户认识截图识别、结果、导出、快捷键和模型准备状态。复用现有 PyQt6 / QFluentWidgets 界面、配置与模型生命周期,不另建初始化流程。 - -主窗口“截图识别”右侧新增紧凑的“ⓘ 快速入门”入口。截图识别保持主操作视觉层级并占据剩余宽度,引导按钮按内容确定宽度,不将原按钮机械地平分。空间不足时显示图标,保留提示文字和可访问名称。点击该入口始终可以重新查看引导。 - -欢迎和结束使用居中卡片,中间步骤通过柔和遮罩、高亮区域和说明卡片指向真实控件。步骤指示、上一页、下一页、跳过及关闭行为保持一致;Esc 可以退出。不通过启动全屏网页或独立安装向导替代主窗口交互。 - -## 2. 六步内容 - -| 步骤 | 标题与说明 | 操作与定位 | -| --- | --- | --- | -| 1 | 欢迎使用 LaTeXSnipper / 您的快速上手向导。 | 居中欢迎卡片;开始引导、跳过 | -| 2 | 从这里开始 / 识别、预览,再导出您的公式。 | 在同一步中突出截图识别、实时渲染预览区和导出入口,不要求用户必须识别成功才能继续 | -| 3 | 让操作更顺手 / 设置您习惯的截图快捷键。 | 显示当前实际快捷键;修改快捷键、下一步;打开现有配置界面,保存或取消后返回本步 | -| 4 | 关于模型准备 / 本地模型会在后台准备,首次使用可能需要下载。您可以在日志中查看详情。 | 固定说明与“查看日志”入口;复用现有忙碌状态显示不确定态 ProgressRing,不阻塞继续 | -| 5 | 一起让它变得更好 / 如果 LaTeXSnipper 对您有帮助,欢迎支持项目。 | Star、问题反馈、社区 / 支持项目;全部可选,可直接下一步 | -| 6 | 开始使用吧 / 欢迎,让公式处理更简单。 | 固定“开始使用”按钮,关闭引导并回到主窗口 | - -标题与说明接入现有国际化机制。文案保持简洁;不使用倒计时、强制赞助、强制打开网页或强制配置快捷键。 - -## 3. 模型准备说明 - -第四步统一使用说明性文案,介绍本地模型后台准备和日志入口。只复用主窗口已有的忙碌状态控制不确定态 ProgressRing,不新增下载进度、成功、失败、外部模型等引导专用状态分支,也不提供引导专用重试逻辑。具体运行状态、错误和配置继续由现有主窗口、日志及设置界面负责。 - -引导不重复启动下载或预热,不等待模型就绪,也不因关闭而取消后台任务。最后一步固定为“开始使用吧”和“开始使用”按钮,不订阅模型状态,不切换标题或操作。所有模型状态下均使用同一套六步流程。 - -## 4. 首次显示、退出与重新查看 - -- 主窗口完成首次布局且可交互后再显示;避免遮挡启动错误或依赖配置对话框。同一时刻只存在一个引导实例。 -- 自动引导完成或确认退出时,统一保存 `onboarding_auto_show: false`,不再自动弹出。跳过、关闭或按 Esc 时显示退出确认卡片;“继续引导”返回当前步骤,“退出引导”确认退出。确认卡片中再次按 Esc 或点击关闭则返回当前步骤,不修改偏好。 -- 需要重看时,点击主窗口“快速入门”。手动重看不修改自动显示偏好。 -- 直接关闭应用不修改偏好。再次自动显示时从欢迎页开始,不持久化步骤编号。 -- 配置只保存 `onboarding_auto_show` 一个布尔值,默认开启,普通版本升级不重置。 - -## 5. 焦点与辅助操作 - -打开运行日志、快捷键设置或其他配置界面时,暂停遮罩及步骤输入,让现有窗口正常交互;返回主窗口后恢复相应步骤。引导中不启动实际截图识别;用户结束引导后通过主窗口操作,不建立截图完成后恢复引导的回调。 - -步骤切换保持键盘焦点可见,支持 Tab 操作,窗口缩放或移动后重新计算高亮位置。目标控件暂不可见时采用可读的说明卡片或定位到当前有效入口,不出现悬空高亮。重复点击引导入口只聚焦已有引导,不叠加窗口。 - -## 6. 支持项目入口 - -- Star 打开项目主页,由用户自行操作。 -- 问题反馈打开项目 Issues。 -- “社区 / 支持项目”合并为一个入口,按当前界面语言打开中文 README 的“支持本项目”区域或英文 README 的“Support the Project”区域;不自动加载远程二维码,不把三个大二维码同时放入引导。 -- 链接复用现有项目地址定义;只有明确点击才打开外部浏览器。无法打开时显示简洁提示并允许继续。 -- 后续若需要内嵌二维码,再单独确定资源来源与更新策略,不作为本阶段前置条件。 - -## 7. 实现边界 - -使用一个引导控制器管理步骤、显示来源、暂停恢复和退出;步骤定义描述文案、目标控件和动作;共享高亮 / 卡片组件只负责显示。仅复用现有忙碌状态和设置入口,不复制模型状态机,不新增进度协议、轮询或结束页回调。 - -配置中只保留一个自动显示偏好。无需旧引导迁移、新版本强制重播、多个完成标志或重复状态存储。不要将本机依赖路径写入引导、构建配置或共享开发文档。 - -## 8. 分阶段实施与验收 - -1. **引导基础与主窗口入口:** 实现紧凑入口、六步框架、定位遮罩、键盘交互、首次显示、完成 / 退出偏好及手动重播。验证窗口缩放、重复打开、完成后重启和中途退出及直接关闭应用。 -2. **业务状态与操作接入:** 接通实际快捷键、日志、现有忙碌指示和支持链接。验证打开设置后返回,以及模型运行状态不改变引导流程、引导关闭不取消后台任务。 -3. **视觉与交互验收:** 检查明暗主题、中文 / 英文、不同 DPI 和窗口尺寸、焦点恢复;确认没有遮罩残留或重复预热,结束页文案与按钮始终一致。更新当前用户手册后再构建发布。 - -各阶段独立检查差异并验证当前行为;不添加只证明旧文件或旧接口不存在的墓碑测试。当前已实现六步引导、入口、偏好、快捷键与日志返回、支持链接和忙碌指示。行为测试覆盖完成、退出、重播、弹窗返回、焦点、缩放与固定结束页;真实主窗口及不同 DPI 的桌面验收仍待完成。 - -## 9. 后续工作接续 - -在客户端 worktree 中先检查分支和工作树,继续第 8 节第 3 项的真实桌面验收。入口参考 `src/ui/main_window_setup.py` 中的 `capture_button`;启动时机参考 `src/ui/main_window.py`;状态参考 `src/ui/model_runtime_controller.py`;配置继续使用现有配置管理器。 diff --git a/docs/office_plugin_typography_refactor.md b/docs/office_plugin_typography_refactor.md index 4fd53b4c..cf8cffe5 100644 --- a/docs/office_plugin_typography_refactor.md +++ b/docs/office_plugin_typography_refactor.md @@ -428,7 +428,7 @@ LaTeX 源码区域:CodeMirror 与诊断 - 共享编辑器入口为 `office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets`,构建源码与浏览器测试位于 `tools/office-editor`;两宿主页面位于各自的 `EditorAssets`。CodeMirror bundle 由构建生成,不手工编辑产物。 - 当前验证记录:22 项 Node、17 项 Edge 页面交互、net48 / net9.0 各 37 项排版契约测试通过;真实 WebView2 渲染通过 61 组 SVG / EMF 与 122 个 PNG 输出检查;托管解决方案零警告、零错误。继续修改按实际范围复验;操作说明见 `tools/office-editor/README.md`。 - **不要重新启动临时 COM 注册清理 / 修改来阻塞开发。** 用户已确认 Office OLE 激活、重编辑及双向转换留待最终安装包,由用户安装实测;未通过的检查仍标为待验收。 -- 工具链:Node 通过 `PATH` 查找;项目 Python 使用相对仓库根目录的 `tools/deps/python311/python.exe`;Visual Studio 工具链通过安装发现或显式配置定位。实际可用性在需要时检查。 +- 工具链:Node 通过 `PATH` 查找;Python 使用开发者自行选择的环境;Visual Studio 工具链通过安装发现或显式配置定位。实际可用性在需要时检查。 ### 11.11 第 6A 步实施记录(2026-09-20) @@ -502,7 +502,7 @@ LaTeX 源码区域:CodeMirror 与诊断 - 对新配置契约执行连续覆盖升级,确认用户默认值与预设保留;验证安装清理不触及用户配置目录。 - 发行包只包含最终选定的一代 MathJax;检查新增包只包含必要编辑器与字体资源,记录体积及冷启动 / 连续预览耗时。沿用已验证的裁剪运行资源,不重新引入完整组合包。 -验证使用现有 .NET / 原生测试和真实 Office E2E 入口。Python 辅助脚本一律通过 `.\tools\deps\python311\python.exe` 运行。第一阶段已执行的验证见运行时维护说明;本节中的字体重构验收仍待第二阶段完成。 +验证使用现有 .NET / 原生测试和真实 Office E2E 入口。Python 辅助脚本使用开发者自行选择的环境运行。第一阶段已执行的验证见运行时维护说明;本节中的字体重构验收仍待第二阶段完成。 ## 13. 客户端与插件统一升级、保留导出职责 diff --git a/docs/user_data_storage.md b/docs/user_data_storage.md index aab77e19..0d265307 100644 --- a/docs/user_data_storage.md +++ b/docs/user_data_storage.md @@ -75,7 +75,7 @@ can explicitly override the model root. | Screenshot CLI capture files | System temp files with `latexsnipper_cap_` / `latexsnipper_bg_` prefixes | Deleted immediately after use | | MathCraft worker input image | System temp PNG | Deleted after each request | | Automation API jobs | Memory only | Images released after execution; completed results expire by TTL/retention cap and all state is released on exit | -| Settings environment terminal scripts | System temp launcher files/directories | Short-lived helper launchers; currently best-effort OS temp cleanup | +| Settings environment terminal launchers and session state | System temp directory: `LaTeXSnipper/environment-terminal/` | Launchers are overwritten and retained; session records are cleared on normal POSIX exit or when detected as stale. Remaining files persist until external temp cleanup. | ## Directory Creation Summary diff --git a/src/application/bootstrap.py b/src/application/bootstrap.py index 7a3e4f9d..1546e439 100644 --- a/src/application/bootstrap.py +++ b/src/application/bootstrap.py @@ -14,6 +14,7 @@ from localization.manager import install_application_translators, translate as tr from preview.math_preview import configure_math_preview_runtime from runtime.app_paths import resource_path +from runtime.runtime_logging import start_runtime_log_session from runtime.native_runtime import configure_native_runtime_environment from application.python_runtime_resolver import ( APP_DIR, @@ -84,6 +85,7 @@ def _show_already_running_message(app: QApplication) -> None: def _ensure_single_instance(app: QApplication) -> None: if ensure_single_instance(): atexit.register(release_single_instance_lock) + start_runtime_log_session() return _show_already_running_message(app) raise SystemExit(0) diff --git a/src/assets/i18n/latexsnipper_en_US.ts b/src/assets/i18n/latexsnipper_en_US.ts index ad78eb39..a157fc54 100644 --- a/src/assets/i18n/latexsnipper_en_US.ts +++ b/src/assets/i18n/latexsnipper_en_US.ts @@ -284,7 +284,7 @@ LaTeXSnipper - Capture shortcut: {hotkey} | {display} - + LaTeXSnipper 已在运行。 LaTeXSnipper is already running. @@ -1353,8 +1353,8 @@ Restart and open Dependency Management now? Your preview will appear here after you write - - + + 准备运行环境... Preparing runtime environment... @@ -3673,7 +3673,7 @@ Select Download to initialize it before entering the main app. - + 检查依赖... Checking dependencies... diff --git a/src/runtime/runtime_logging.py b/src/runtime/runtime_logging.py index a97fc667..d32004f1 100644 --- a/src/runtime/runtime_logging.py +++ b/src/runtime/runtime_logging.py @@ -223,11 +223,6 @@ def init_app_logging() -> Path: sh.setFormatter(fmt) root.addHandler(sh) - hook_runtime_log_streams(tee=not getattr(sys, "frozen", False)) - _RUNTIME_SESSION_HANDLER = logging.StreamHandler(_RUNTIME_LOG_FH_OUT) - _RUNTIME_SESSION_HANDLER.setFormatter(fmt) - root.addHandler(_RUNTIME_SESSION_HANDLER) - global _ORIGINAL_PRINT, _PRINT_BRIDGE_INSTALLED if (not _PRINT_BRIDGE_INSTALLED) and (file_handler is not None): _ORIGINAL_PRINT = builtins.print @@ -265,6 +260,19 @@ def _print_bridge(*args, **kwargs): return active_log_path +def start_runtime_log_session() -> None: + """Start session capture only after acquiring the application instance lock.""" + global _RUNTIME_SESSION_HANDLER + if _RUNTIME_SESSION_HANDLER is not None: + return + hook_runtime_log_streams(tee=not getattr(sys, "frozen", False)) + handler = logging.StreamHandler(_RUNTIME_LOG_FH_OUT) + handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")) + logging.getLogger().addHandler(handler) + _RUNTIME_SESSION_HANDLER = handler + logging.info("LaTeXSnipper 会话开始 pid=%s", os.getpid()) + + def runtime_log_path() -> Path: global _RUNTIME_LOG_PATH if _RUNTIME_LOG_PATH is not None: diff --git a/test/test_runtime_logging.py b/test/test_runtime_logging.py index 71c7fb2d..55b5d0e5 100644 --- a/test/test_runtime_logging.py +++ b/test/test_runtime_logging.py @@ -41,6 +41,9 @@ def test_persistent_log_excludes_debug_and_strips_prefixes(tmp_path: Path) -> No runtime_logging.app_state_dir = lambda: target / "state" runtime_logging.runtime_log_path().write_text("previous-session", encoding="utf-8") runtime_logging.init_app_logging() +assert runtime_logging.runtime_log_path().read_text(encoding="utf-8") == "previous-session" +runtime_logging.start_runtime_log_session() +runtime_logging.start_runtime_log_session() print("[DEBUG] debug-only") print("[INFO] ready") logging.info("logging-record") @@ -109,3 +112,20 @@ def test_only_expired_pid_fallback_logs_are_removed(tmp_path: Path) -> None: assert not old_backup.exists() assert recent_log.exists() assert unrelated.exists() + + +def test_session_capture_requires_instance_lock(monkeypatch): + import pytest + from application import bootstrap + + events = [] + monkeypatch.setattr(bootstrap.atexit, "register", lambda callback: None) + monkeypatch.setattr(bootstrap, "start_runtime_log_session", lambda: events.append("session")) + monkeypatch.setattr(bootstrap, "_show_already_running_message", lambda app: None) + monkeypatch.setattr(bootstrap, "ensure_single_instance", lambda: False) + with pytest.raises(SystemExit): + bootstrap._ensure_single_instance(None) + assert events == [] + monkeypatch.setattr(bootstrap, "ensure_single_instance", lambda: True) + bootstrap._ensure_single_instance(None) + assert events == ["session"] diff --git a/tools/mathjax/README.md b/tools/mathjax/README.md index 87747884..f6c42378 100644 --- a/tools/mathjax/README.md +++ b/tools/mathjax/README.md @@ -21,8 +21,8 @@ 从 npm 获取清单指定的四个精确版本包(`mathjax`、`@mathjax/mathjax-tex-font`、`@mathjax/mathjax-stix2-font`、`@mathjax/mathjax-mhchem-font-extension`,均为 4.1.3),将 `.tgz` 放入临时目录,然后执行: ```powershell -& .\tools\deps\python311\python.exe -X utf8 .\tools\mathjax\prepare.py --archives <压缩包目录> -& .\tools\deps\python311\python.exe -X utf8 .\tools\mathjax\prepare.py --verify +python -X utf8 .\tools\mathjax\prepare.py --archives <压缩包目录> +python -X utf8 .\tools\mathjax\prepare.py --verify ``` 升级时修改清单及完整性校验值,再重建生成文件。验证会拒绝清单漂移、被修改的上游文件和多余资源;有意移除的旧资源须同步删除,不保留版本回退目录。Git 属性保留上游文件和生成清单的字节,避免 Windows 换行转换破坏校验。 @@ -40,8 +40,8 @@ 复验入口: ```powershell -& .\tools\deps\python311\python.exe -X utf8 -m pytest test/test_mathjax_runtime.py test/test_formula_export_matrix.py test/test_formula_omml_export.py test/test_content_preview.py test/test_handwriting_preview.py test/test_pandoc_export_formats.py -q -& .\tools\deps\python311\python.exe -X utf8 tools/mathjax/smoke_qt.py --cdn +python -X utf8 -m pytest test/test_mathjax_runtime.py test/test_formula_export_matrix.py test/test_formula_omml_export.py test/test_content_preview.py test/test_handwriting_preview.py test/test_pandoc_export_formats.py -q +python -X utf8 tools/mathjax/smoke_qt.py --cdn dotnet run --project office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke dotnet run --project office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke -- --typography dotnet test office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests @@ -56,5 +56,5 @@ dotnet test office_plugin/tests/LaTeXSnipper.OfficePlugin.MetadataSafety.Tests ## tools 目录的用途 - `tools/mathjax` 是长期维护工具:固定资源清单、可复现的生成脚本和被测试直接引用的回归入口;不属于应用运行环境,也不是一次性试验目录。此前直接复制整套 MathJax 3 资源,不需要此处的裁剪与校验工具。 -- `tools/deps/python311` 是项目约定的 Windows Python;`python311-linux-x86_64` 是现有跨平台打包脚本使用的 Python 环境,均与本次 MathJax 升级的资源构建无关。 +- Python 维护命令使用开发者自行选择的环境;发布包运行时由 GitHub Actions 准备。 - 本次验证曾在 `tools/deps/nuget` 下载 .NET 构建包,属于可重建缓存,不是新增运行依赖;收尾时已删除。后续正常 `dotnet restore` 使用开发者原有 NuGet 配置和缓存。 From f6c0294c431def7015995c161d637b57c6489d81 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Thu, 24 Sep 2026 20:19:54 +0800 Subject: [PATCH 2/3] Emit startup log once after acquiring instance lock --- src/runtime/runtime_logging.py | 6 +----- test/test_runtime_logging.py | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/runtime_logging.py b/src/runtime/runtime_logging.py index d32004f1..9ca3c547 100644 --- a/src/runtime/runtime_logging.py +++ b/src/runtime/runtime_logging.py @@ -252,10 +252,6 @@ def _print_bridge(*args, **kwargs): _PRINT_BRIDGE_INSTALLED = True APP_LOG_FILE = active_log_path - if not getattr(root, "_latexsnipper_session_logged", False): - logging.info("LaTeXSnipper 启动 pid=%s", os.getpid()) - setattr(root, "_latexsnipper_session_logged", True) - _APP_LOGGING_INITIALIZED = True return active_log_path @@ -270,7 +266,7 @@ def start_runtime_log_session() -> None: handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")) logging.getLogger().addHandler(handler) _RUNTIME_SESSION_HANDLER = handler - logging.info("LaTeXSnipper 会话开始 pid=%s", os.getpid()) + logging.info("LaTeXSnipper 启动 pid=%s", os.getpid()) def runtime_log_path() -> Path: diff --git a/test/test_runtime_logging.py b/test/test_runtime_logging.py index 55b5d0e5..b0760be7 100644 --- a/test/test_runtime_logging.py +++ b/test/test_runtime_logging.py @@ -71,6 +71,8 @@ def test_persistent_log_excludes_debug_and_strips_prefixes(tmp_path: Path) -> No assert text.count("ready") == 1 session = (tmp_path / "runtime-session.log").read_text(encoding="utf-8") assert "previous-session" not in session + assert session.count("LaTeXSnipper 启动 pid=") == 1 + assert text.count("LaTeXSnipper 启动 pid=") == 1 assert session.count("ready") == 1 assert session.count("logging-record") == 1 assert "stderr-record" in session From 4fefaa3ab314ac8e33ff1f9a3016e576772a7008 Mon Sep 17 00:00:00 2001 From: LaTeXSnipper Developer Date: Fri, 25 Sep 2026 13:26:27 +0800 Subject: [PATCH 3/3] Complete Office editor interaction refinements for phase 6C --- docs/office_plugin_typography_refactor.md | 30 ++++-- .../EditorAssets/editor.css | 4 +- .../EditorAssets/editor.js | 32 ++++-- .../EditorAssets/mathfield-input.mjs | 10 ++ .../EditorAssets/template-insertion.mjs | 53 ++++++++-- .../EditorAssets/typography-panel.mjs | 19 +++- tools/office-editor/README.md | 2 + tools/office-editor/tests/editor.spec.mjs | 100 ++++++++++++++++++ 8 files changed, 219 insertions(+), 31 deletions(-) diff --git a/docs/office_plugin_typography_refactor.md b/docs/office_plugin_typography_refactor.md index cf8cffe5..38c60a4d 100644 --- a/docs/office_plugin_typography_refactor.md +++ b/docs/office_plugin_typography_refactor.md @@ -2,7 +2,7 @@ 日期:2026-09-12 -状态:第 1—6B 步实现完成;下一步为 6C 编辑器交互收尾。第 4 步剩余 Office OLE 实机检查按用户确认并入第 8 步安装包发布验收(2026-09-23 更新)。 +状态:第 1—6C 步实现完成;下一步为第 7 步预设与用户配置。第 4 步剩余 Office OLE 实机检查按用户确认并入第 8 步安装包发布验收(2026-09-25 更新)。 适用范围:Word / PowerPoint Office 插件;客户端共享 MathJax 运行时升级与导出边界 @@ -260,7 +260,7 @@ Word 与 PowerPoint 使用共享预设仓库;各自保存选中的默认预设 | 5. 源码编辑与同步(已完成) | 共享 CodeMirror 编辑器、颜色主题、括号 / 环境辅助、文档修订与统一撤销、中文输入保护;不改设置存储,见第 11.8 节 | 复杂源码加载与提交、显式可视化回写、撤销重做、组合输入及过期同步回归通过;真实输入法及 Office 宿主交互留作安装包验收 | | 6A. 编辑器布局与磁贴库(已完成) | 共享布局、底部两行分类磁贴、统一搜索和模板插入,实施记录见第 11.11 节 | 两宿主的选区包裹、占位符跳转、独立撤销及锁定回归通过;真实宿主交互随第 8 步验收 | | 6B. 字体设置与最终预览(已完成) | 接入统一三类字体、数字 / 中文字号、颜色面板;编辑 / 最终预览切换采用同一草稿样式与渲染请求;OMML 使用参考预览 | 预览、提交采用同一源码与样式修订;取消和过期结果处理正确;无第二份字体或字号列表 | -| 6C. 编辑器交互收尾 | 完成键盘、焦点、窗口尺寸、明暗主题与输入法交互检查 | 磁贴滚动不干扰其他区域;布局无关键控件遮挡;输入法组合期间操作安全;Office 内实测项随第 8 步安装验收 | +| 6C. 编辑器交互收尾(已完成) | 完成键盘、焦点、窗口尺寸、明暗主题与输入法交互检查 | 磁贴滚动不干扰其他区域;布局无关键控件遮挡;输入法组合期间操作安全;Office 内实测项随第 8 步安装验收 | | 7. 预设与用户配置 | 在已稳定的样式契约上实现共享预设仓库、保存 / 加载 / 导入导出、默认值与宿主选项隔离;同步修正安装清理 | 文件原子保存,多宿主写入不互相覆盖;加载与应用的边界正确;预设被删除后公式仍能独立重绘;连续覆盖升级保留用户配置 | | 8. 发布回归与收尾 | 按第 12 节完整验收,验证实际 Word / PPT 文档和安装升级、x86 / x64,检查资源体积与离线运行;更新当前行为文档 | 构建与必要测试通过;发布包无旧协议或旧运行时分支、无临时验证文件;未通过的验收项明确列出,不记为完成 | @@ -420,13 +420,13 @@ LaTeX 源码区域:CodeMirror 与诊断 6A、6B、6C 分别完成实现、差异审查和对应行为验证,再进入下一项。随后实施第 7 步预设与用户配置,最后统一构建安装包并验收。 -### 11.10 新对话接续说明(2026-09-23 更新) +### 11.10 新对话接续说明(2026-09-25 更新) -- **当前工作目录:** 使用 `codex/office-typography` 对应的 Office 隔离 worktree;用 `git worktree list` 核实。主工作树保留 `main`,客户端在独立 worktree 开发。命令、依赖与路径以当前 worktree 根目录解析,不把修改写回主目录或客户端。 -- **下一项唯一实施起点:6C。** 6A / 6B 已完成,见第 11.11 / 11.12 节。先阅读第 11.2、11.8、11.9 节及仓库指令,再检查工作树。6C 完成交互收尾后才进入第 7 步预设,不展示尚未接通的空入口。 +- **当前工作目录:** 用户已合并 6B 到主线,并指定在主仓库目录的 `codex/office-typography` 分支继续;启动时用 `git status` 和 `git worktree list` 核实,不继续写入旧 Office worktree。命令与依赖路径相对当前仓库根目录解析,工具链通过 PATH / 标准环境配置发现。 +- **下一项唯一实施起点:第 7 步。** 6A / 6B / 6C 记录见第 11.11—11.13 节。6C 已获用户授权提交推送;后续工作先检查分支与工作树,保留现有实现。预设与用户配置按第 11.2 节单独推进,不提前把配置存储混入编辑交互。 - 用户选择 `34516e96e`(6A 与路径修复)为回退基线,6B 从该基线重新推进,不恢复后续废弃实现。本轮获授权提交推送 6B;后续提交仍遵循用户指令。禁止死逻辑、死函数、旧版本兼容分支和墓碑测试。 - 共享编辑器入口为 `office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets`,构建源码与浏览器测试位于 `tools/office-editor`;两宿主页面位于各自的 `EditorAssets`。CodeMirror bundle 由构建生成,不手工编辑产物。 -- 当前验证记录:22 项 Node、17 项 Edge 页面交互、net48 / net9.0 各 37 项排版契约测试通过;真实 WebView2 渲染通过 61 组 SVG / EMF 与 122 个 PNG 输出检查;托管解决方案零警告、零错误。继续修改按实际范围复验;操作说明见 `tools/office-editor/README.md`。 +- 当前验证记录:6C 的 22 项 Node 与 23 项 Edge 页面交互通过,托管解决方案零警告、零错误。6B 的 .NET 排版与真实 WebView2 渲染证据见第 11.12 节,本轮未修改渲染器,也未重跑该矩阵。继续修改按实际范围复验;操作说明见 `tools/office-editor/README.md`。 - **不要重新启动临时 COM 注册清理 / 修改来阻塞开发。** 用户已确认 Office OLE 激活、重编辑及双向转换留待最终安装包,由用户安装实测;未通过的检查仍标为待验收。 - 工具链:Node 通过 `PATH` 查找;Python 使用开发者自行选择的环境;Visual Studio 工具链通过安装发现或显式配置定位。实际可用性在需要时检查。 @@ -465,6 +465,22 @@ LaTeX 源码区域:CodeMirror 与诊断 **接续 6C:** 重点复验字体面板和预览切换前后的键盘与焦点、模板目标 / 选区、尺寸和主题、滚轮 / 触控板及输入法事件。保留当前草稿 / 修订规则,不增加另一套渲染、字体或持久化配置。真实 Windows 输入法、Office WebView2 与 OLE 激活 / 重编辑 / 双向转换仍由第 8 步安装包验收,不记为已通过。 +### 11.13 第 6C 步实施记录(2026-09-25) + +- 预览往返保留此前编辑目标与可视化选区;若在预览期间改动源码,则回到当前源码选区,不恢复过期可视化位置。新会话清理前一目标并聚焦源码。 +- 修复 MathLive 延迟焦点回调覆盖源码 / 工具栏焦点的问题:按实际鼠标、键盘进入意图及最近焦点所有者交接焦点;显式恢复可视化编辑通过原生元素焦点进入,不依赖定时重试。插入目标与当前工具栏焦点保持独立。 +- MathLive 支持 `Escape → Tab` / `Escape → Shift-Tab` 离开编辑器,通常的 Tab 占位符跳转保留;增加可访问说明。全局快捷键尊重已处理事件、组合输入及 IME 的 229 键码,避免截获源码编辑器的操作。 +- 字号组合输入与源码 / MathLive 组合输入共同阻止提交、预览切换、撤销和磁贴插入;未提交的中文字号不触发最终预览。字体面板在外部点击或键盘焦点离开时关闭,Escape 返回入口;小窗口限制面板高度并允许滚动。 +- 分隔线在短窗口中按可用空间限制两块编辑区域,ARIA 范围同步更新;紧凑布局减少可视化编辑区内边距与最小高度。磁贴保留像素 / 行 / 页滚轮处理,横向滚动与 Ctrl 滚轮继续交给浏览器。 + +**已执行验证:** + +- 22 项 Node 行为测试通过。23 项 Edge 页面交互通过:完整 22 项通过后,新增焦点测试及受影响的 IME / 小窗口测试定向通过。覆盖 Word / PPT、预览往返选区、源码更新、新会话、输入法事件、键盘退出与进入、延迟焦点保护及轮滚隔离。 +- 1180×780 / 980×680 / 640×480 既有明暗测试通过,新增 640×400 短窗口;已查看截图,并修正可视化公式裁切。真实触控板仅保留原生横向事件路径,未冒充物理设备实测。 +- 托管解决方案构建零警告、零错误,两个宿主各 16 个共享编辑资源与源码哈希一致;CodeMirror 构建源码未改动,未手改 bundle。 + +**后续边界:** 用户已授权提交推送本轮全部改动;下一项为第 7 步预设与用户配置。真实 Windows 输入法、Office WebView2、物理触控板及 Office OLE 激活 / 重编辑 / 转换仍留待第 8 步安装包验收,浏览器组合事件测试不能代替这些检查。 + ## 12. 验收矩阵 ### 12.1 公式与字体 @@ -550,4 +566,4 @@ Pandoc 的所有格式统一进入文件保存流程,包括 HTML 独立页(` `mhchem` 在升级前的客户端与插件中已经启用;4.1.3 的化学字体扩展是保留既有化学公式能力的配套资源,不是仅为编辑器新增的一项功能。 -插件重构已完成共享字体渲染、宿主与 schema 3 接入、源码编辑与同步以及 6A 编辑器布局 / 磁贴库、6B 字体设置 / 最终预览。下一步实施 6C 交互收尾,再实施独立预设;Office 内 OLE 实机检查、正式安装及跨平台矩阵随最终安装包验收完成。 +插件重构已完成共享字体渲染、宿主与 schema 3 接入、源码编辑与同步以及 6A 编辑器布局 / 磁贴库、6B 字体设置 / 最终预览。6C 交互收尾也已完成,下一步实施独立预设;Office 内 OLE 实机检查、正式安装及跨平台矩阵随最终安装包验收完成。 diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.css b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.css index b986fed9..f13c5eb3 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.css +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.css @@ -66,6 +66,8 @@ button:disabled { opacity: .45; cursor: default; } .library { padding-block: 2px; } .symbol-grid { grid-template-rows: repeat(2, 38px); } .symbol-tile { height: 38px; } + .mathfield-host { padding: 4px 12px; } + .mathfield-host math-field { min-height: 40px; font-size: 24px; } } @media (prefers-color-scheme: dark) { :root { @@ -78,7 +80,7 @@ button:disabled { opacity: .45; cursor: default; } .toolbar label { display: inline-flex; align-items: center; gap: 5px; } #fontSizePoints { width: 68px; padding: 4px; } #color { width: 32px; height: 28px; padding: 2px; } -.typography-panel { position: absolute; top: 100%; left: 8px; z-index: 10; max-width: calc(100vw - 32px); padding: 12px; display: grid; gap: 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 6px 18px #0003; } +.typography-panel { position: absolute; top: 100%; left: 8px; z-index: 10; max-width: calc(100vw - 32px); max-height: calc(100dvh - 100px); overflow-y: auto; padding: 12px; display: grid; gap: 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 6px 18px #0003; } .typography-panel label { display: grid; grid-template-columns: 90px minmax(0, 240px); } .typography-panel select { width: 100%; min-width: 0; padding: 4px; } .formula-stage { min-height: 0; min-width: 0; overflow: hidden; } diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.js b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.js index 6b39d848..be5272b0 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.js +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/editor.js @@ -67,17 +67,17 @@ function setSubmitting(value) { } function maximumSourcePaneHeight() { - return Math.max( - SOURCE_PANE_MIN_HEIGHT, - workspace.clientHeight - sourceResizeHandle.offsetHeight - FORMULA_PANE_MIN_HEIGHT, - ); + const available = Math.max(0, workspace.clientHeight - sourceResizeHandle.offsetHeight); + return available - Math.min(FORMULA_PANE_MIN_HEIGHT, Math.floor(available * 2 / 3)); } function setSourcePaneHeight(height) { const maximum = maximumSourcePaneHeight(); - sourcePaneHeight = Math.min(maximum, Math.max(SOURCE_PANE_MIN_HEIGHT, Math.round(height))); + const minimum = Math.min(SOURCE_PANE_MIN_HEIGHT, maximum); + sourcePaneHeight = Math.min(maximum, Math.max(minimum, Math.round(height))); workspace.style.setProperty("--source-pane-height", `${sourcePaneHeight}px`); sourceResizeHandle.setAttribute("aria-valuemax", String(maximum)); + sourceResizeHandle.setAttribute("aria-valuemin", String(minimum)); sourceResizeHandle.setAttribute("aria-valuenow", String(sourcePaneHeight)); } @@ -197,7 +197,7 @@ function scheduleCaretVisibility() { } function accept() { - if (submitting || sourceEditor.composing || sourceSync.visualComposing) { + if (submitting || sourceEditor.composing || sourceSync.visualComposing || typographyPanel.composing) { return; } sourceSync.visualInput(); @@ -228,6 +228,8 @@ function configureText() { setStatus(strings().ready); document.getElementById('undoButton').textContent = locale.startsWith('zh') ? '撤销' : 'Undo'; document.getElementById('redoButton').textContent = locale.startsWith('zh') ? '重做' : 'Redo'; + mathfield.setAttribute('aria-label', locale.startsWith('zh') ? '可视化公式编辑器' : 'Visual formula editor'); + mathfield.setAttribute('aria-description', locale.startsWith('zh') ? '按 Escape 后再按 Tab 离开编辑器。' : 'Press Escape then Tab to leave the editor.'); symbolPanel.configure(locale); } @@ -242,6 +244,7 @@ function applyInit(payload) { configureText(); setLatex(payload?.latex || ""); insertion.reset(); + sourceEditor.focus(); scheduleCaretVisibility(); } @@ -258,10 +261,11 @@ async function bootstrap() { commands: COMMANDS, completeTemplate: (entry, range) => insertion.insert(entry, {range}), onChange: (_value, change) => { sourceSync?.sourceChanged(change); preview?.update(); }, - onComposition: active => { sourceSync?.composition(active); preview?.setComposing(active || sourceSync.visualComposing); } + onComposition: active => { sourceSync?.composition(active); updatePreviewComposition(); } }); sourceSync = new SourceSync({source: sourceEditor, mathfield, readVisual: mathfieldLatex, onMode: setSourceMode}); - insertion = new TemplateInsertion({source: sourceEditor, sync: sourceSync, mathfield, sourceHost: latexSource, onInsert: scheduleCaretVisibility}); + insertion = new TemplateInsertion({source: sourceEditor, sync: sourceSync, mathfield, sourceHost: latexSource, + onInsert: scheduleCaretVisibility, isComposing: () => Boolean(typographyPanel?.composing)}); symbolPanel = new SymbolPanel(insertion); const image = document.getElementById('previewImage'); const previewStatus = document.getElementById('previewStatus'); @@ -287,9 +291,10 @@ async function bootstrap() { } }); typographyPanel = new TypographyPanel({onChange: () => preview.update(), + onComposition: updatePreviewComposition, blocked: () => insertion.blocked, onMode: active => { - sourceSync.visualInput(); insertion.reset(); sourceEditor.focus(); preview.setActive(active); + sourceSync.visualInput(); insertion.setPreview(active); preview.setActive(active); }}); const shortcuts = new Map(CATALOG.filter(entry => entry.shortcut).map(entry => [entry.shortcut, entry])); configureMathfield(mathfield, {onAccept: accept, insert: entry => insertion.insert(entry), shortcuts, @@ -302,8 +307,8 @@ async function bootstrap() { } mathfield.addEventListener("beforeinput", event => sourceSync.beforeVisualInput(event)); mathfield.addEventListener("input", () => { sourceSync.visualInput(); scheduleCaretVisibility(); }); - mathfield.addEventListener("compositionstart", () => { sourceSync.visualComposition(true); preview.setComposing(true); }); - mathfield.addEventListener("compositionend", () => { sourceSync.visualComposition(false); preview.setComposing(sourceEditor.composing); }); + mathfield.addEventListener("compositionstart", () => { sourceSync.visualComposition(true); updatePreviewComposition(); }); + mathfield.addEventListener("compositionend", () => { sourceSync.visualComposition(false); updatePreviewComposition(); }); mathfield.addEventListener("keydown", event => { if (!submitting && (event.ctrlKey || event.metaKey) && !event.altKey && !event.isComposing && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) { @@ -315,6 +320,7 @@ async function bootstrap() { window.addEventListener("pagehide", () => preview.stop()); acceptButton.addEventListener("click", accept); window.addEventListener("keydown", (event) => { + if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return; if (event.key === "Escape" && !event.isComposing) { event.preventDefault(); hideVirtualKeyboard(); @@ -335,6 +341,10 @@ async function bootstrap() { } } +function updatePreviewComposition() { + preview?.setComposing(Boolean(sourceEditor?.composing || sourceSync?.visualComposing || typographyPanel?.composing)); +} + window.LaTeXSnipperEditor = { init(payload) { pendingInit = payload; diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/mathfield-input.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/mathfield-input.mjs index 48897594..ed665b63 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/mathfield-input.mjs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/mathfield-input.mjs @@ -28,6 +28,8 @@ function addRow(mathfield) { } export function configureMathfield(mathfield, {onAccept, insert, shortcuts, performEdit}) { + let releaseTab = false; + mathfield.addEventListener('focusout', () => { releaseTab = false; }); mathfield.mathModeSpace = VISIBLE_MATH_SPACE; document.addEventListener("menu-select", (event) => { const command = MATRIX_MENU_COMMANDS[event.detail?.id]; @@ -40,6 +42,14 @@ export function configureMathfield(mathfield, {onAccept, insert, shortcuts, perf mathfield.focus(); }); mathfield.addEventListener("keydown", (event) => { + if (event.isComposing || event.keyCode === 229) return; + const leaveEditor = releaseTab && event.key === 'Tab'; + releaseTab = event.key === 'Escape'; + if (leaveEditor) { + // Like CodeMirror: Escape then Tab leaves the editor without inserting content. + event.stopImmediatePropagation(); + return; + } if (event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing && !mathfield.readOnly && mathfield.mode !== 'latex') { event.preventDefault(); diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/template-insertion.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/template-insertion.mjs index d1b3ba0a..e31df877 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/template-insertion.mjs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/template-insertion.mjs @@ -1,31 +1,67 @@ import {entryTemplate, templateParts} from './template-catalog.mjs'; export class TemplateInsertion { - constructor({source, sync, mathfield, sourceHost, onInsert}) { - Object.assign(this, {source, sync, mathfield, onInsert}); + constructor({source, sync, mathfield, sourceHost, onInsert, isComposing}) { + Object.assign(this, {source, sync, mathfield, onInsert, isComposing}); this.target = 'source'; this.visualSelection = null; + this.focusOwner = null; + this.keyboardEntry = false; + this.allowVisualFocus = false; + document.addEventListener('keydown', event => { this.keyboardEntry = event.key === 'Tab' && !event.isComposing; }, true); + document.addEventListener('pointerdown', () => { this.keyboardEntry = false; this.allowVisualFocus = false; }, true); + document.addEventListener('focusin', event => { + if (event.target !== mathfield) { + this.focusOwner = event.target; + this.allowVisualFocus = false; + this.keyboardEntry = false; + } else if (this.keyboardEntry) { + this.target = 'visual'; + this.allowVisualFocus = true; + this.keyboardEntry = false; + this.rememberVisual(); + } else if (!this.allowVisualFocus && this.focusOwner?.isConnected) { + // MathLive may deliver a delayed focus after the user has moved to source or a toolbar. + this.focusOwner.focus(); + } + }); sourceHost.addEventListener('focusin', () => { this.target = 'source'; }); // An already-focused MathLive field need not emit focusin after session reset. for (const event of ['pointerdown', 'keydown']) mathfield.addEventListener(event, () => { - this.target = 'visual'; this.rememberVisual(); + this.allowVisualFocus = true; this.target = 'visual'; this.rememberVisual(); }, true); - mathfield.addEventListener('focusin', () => { this.target = 'visual'; this.rememberVisual(); }); + mathfield.addEventListener('focusin', () => { if (this.target === 'visual') this.rememberVisual(); }); mathfield.addEventListener('selection-change', () => { if (mathfield.hasFocus()) this.rememberVisual(); }); mathfield.addEventListener('focusout', () => this.rememberVisual()); } - reset() { this.target = 'source'; this.visualSelection = null; } + reset() { this.target = 'source'; this.visualSelection = null; this.previewOrigin = null; } + setPreview(active) { + if (active) { + this.previewOrigin = {target: this.target, selection: this.visualSelection, revision: this.source.revision}; + this.target = 'source'; + this.source.focus(); + } else { + const origin = this.previewOrigin; + this.previewOrigin = null; + if (origin?.revision === this.source.revision) { + this.target = origin.target; + this.visualSelection = origin.selection; + } + this.restoreFocus(); + } + } rememberVisual() { this.visualSelection = {revision: this.source.revision, selection: structuredClone(this.mathfield.selection)}; } - get blocked() { return this.sync.locked || this.source.composing || this.sync.visualComposing; } + get blocked() { return this.sync.locked || this.source.composing || this.sync.visualComposing || this.isComposing(); } restoreFocus() { if (this.blocked) return; if (this.target === 'visual' && this.sync.visualEnabled) { const saved = this.visualSelection; - this.mathfield.focus(); + this.allowVisualFocus = true; + HTMLElement.prototype.focus.call(this.mathfield); this.visualSelection = saved; this.restoreVisualSelection(); } else this.source.focus(); @@ -38,7 +74,8 @@ export class TemplateInsertion { const template = entryTemplate(entry, rows, columns); if (!range && this.target === 'visual' && this.sync.performVisual(() => { const saved = this.visualSelection; - this.mathfield.focus(); + this.allowVisualFocus = true; + HTMLElement.prototype.focus.call(this.mathfield); this.visualSelection = saved; this.restoreVisualSelection(); const selected = this.mathfield.getValue(this.mathfield.selection, 'latex'); diff --git a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/typography-panel.mjs b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/typography-panel.mjs index 8e3cbc26..fd118d73 100644 --- a/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/typography-panel.mjs +++ b/office_plugin/src/LaTeXSnipper.OfficePlugin.Editor/EditorAssets/typography-panel.mjs @@ -1,7 +1,8 @@ // Catalogs and size limits come from the native typography contract. export class TypographyPanel { - constructor({onChange, onMode, blocked}) { - Object.assign(this, {onChange, onMode, blocked}); + constructor({onChange, onMode, onComposition, blocked}) { + Object.assign(this, {onChange, onMode, onComposition, blocked}); + this.composing = false; this.panel = document.getElementById('typographyPanel'); this.toggle = document.getElementById('typographyToggle'); this.preview = document.getElementById('previewToggle'); @@ -18,17 +19,27 @@ export class TypographyPanel { event.preventDefault(); event.stopPropagation(); this.close(); this.toggle.focus(); } }); + this.panel.addEventListener('focusout', () => queueMicrotask(() => { + if (!this.panel.contains(document.activeElement) && document.activeElement !== this.toggle) this.close(); + })); + document.addEventListener('pointerdown', event => { + if (!this.panel.contains(event.target) && !this.toggle.contains(event.target)) this.close(); + }); this.preview.addEventListener('click', () => { if (this.blocked()) return; this.close(); this.active = !this.active; this.renderMode(); this.onMode(this.active); }); - for (const input of Object.values(this.fields)) input.addEventListener('input', () => this.onChange()); + for (const input of Object.values(this.fields)) { + input.addEventListener('input', () => this.onChange()); + input.addEventListener('compositionstart', () => { this.composing = true; this.onComposition(); }); + input.addEventListener('compositionend', () => { this.composing = false; this.onComposition(); }); + } } close() { this.panel.hidden = true; this.toggle.setAttribute('aria-expanded', 'false'); } configure(payload) { this.catalog = payload.catalog; this.initial = payload.typography; this.zh = String(payload.locale).startsWith('zh'); this.reference = payload.referencePreview; - this.active = false; this.close(); + this.active = false; this.composing = false; this.close(); const labels = this.zh ? ['符号字体', '数字字体', '汉字字体', '默认字形', '字号', '颜色'] : ['Symbols', 'Numbers', 'CJK', 'Math style', 'Size', 'Color']; Object.entries(this.fields).forEach(([key, input], index) => { diff --git a/tools/office-editor/README.md b/tools/office-editor/README.md index 9e17673b..28f66bcf 100644 --- a/tools/office-editor/README.md +++ b/tools/office-editor/README.md @@ -31,3 +31,5 @@ npm run test:browser 参考:[CodeMirror API](https://codemirror.net/docs/ref/)、[MathLive 输入事件与静默更新](https://mathlive.io/mathfield/guides/interacting/)。 6B 回归覆盖字体 / 字号 / 颜色、预览和提交快照一致、失效响应、取消、新会话、渲染错误、提交失败恢复及面板小窗口布局。浏览器测试使用模拟桥接;真实渲染需另外执行 `office_plugin/tests/LaTeXSnipper.OfficePlugin.Rendering.Smoke` 的 `--typography` 检查。 + +6C:MathLive 与源码均可用 Escape 后再按 Tab 离开编辑器;预览往返在源码未改动时恢复原编辑目标与选区。字号组合输入纳入编辑保护,字体面板在焦点移出后关闭。新增回归覆盖延迟焦点、新会话、预览选区、字号 IME、键盘进出、滚轮单位与 640×400 短窗口;真实输入法与物理触控板仍待宿主验收。 diff --git a/tools/office-editor/tests/editor.spec.mjs b/tools/office-editor/tests/editor.spec.mjs index 507decaa..1cba12b5 100644 --- a/tools/office-editor/tests/editor.spec.mjs +++ b/tools/office-editor/tests/editor.spec.mjs @@ -66,6 +66,7 @@ test('source history includes visual edits; unfocused notifications cannot repla await expect.poll(() => source(page).innerText()).toContain('2'); await page.locator('#mathfieldHost math-field').press('Control+z'); expect(await submitted(page)).toBe('x+1'); + await page.locator('#mathfieldHost math-field').click(); await page.locator('#mathfieldHost math-field').press('Control+y'); expect(await submitted(page)).toBe('x+12'); await source(page).fill('newest'); @@ -177,6 +178,7 @@ for (const host of ['word', 'powerpoint']) { test(`${host}: visual selection survives search and matrix dimensions use the same insertion path`, async ({page}) => { const errors = await open(page, 'x+1', host); + await visual(page).click(); await visual(page).press('Control+a'); await searchTile(page, 'Fraction'); await tile(page, '分数').click(); @@ -422,3 +424,101 @@ test('typography panel and preview remain usable in small light and dark windows } expect(errors).toEqual([]); }); + + +for (const host of ['word', 'powerpoint']) { + test(`${host}: preview roundtrip preserves visual selection unless source changes`, async ({page}) => { + const errors = await open(page, 'a+b', host); + await visual(page).click(); + await visual(page).press('Control+a'); + await page.locator('#previewToggle').click(); + await expect(source(page)).toBeFocused(); + await page.locator('#previewToggle').click(); + await expect(visual(page)).toBeFocused(); + await searchTile(page, 'Fraction'); + await tile(page, '分数').click(); + await expect(source(page)).toHaveText('\\frac{a+b}{\\placeholder{}}'); + await page.locator('#previewToggle').click(); + await source(page).fill('newer'); + await page.locator('#previewToggle').click(); + await expect(source(page)).toBeFocused(); + expect(await submitted(page)).toBe('newer'); + expect(errors).toEqual([]); + }); +} + +test('toolbar IME blocks submission and mode changes until committed', async ({page}) => { + const errors = await open(page); + const size = page.locator('#fontSizePoints'); + await size.focus(); + await size.dispatchEvent('compositionstart'); + await size.fill('小四'); + await page.locator('#undoButton').click(); + await tile(page, 'α').click(); + await expect(source(page)).toHaveText('x+1'); + await page.locator('#acceptButton').click(); + expect(await page.evaluate(() => window.posted.some(m => m.type === 'accept'))).toBe(false); + await page.locator('#previewToggle').click(); + await expect(page.locator('#finalPreview')).toBeHidden(); + await size.dispatchEvent('compositionend'); + expect(await submitted(page)).toBe('x+1'); + expect(await page.evaluate(() => window.posted.findLast(m => m.type === 'accept').typography.fontSizePoints)).toBe(12); + expect(errors).toEqual([]); +}); + +test('Escape then Tab leaves either editor and font panel closes on keyboard departure', async ({page}) => { + const errors = await open(page); + await visual(page).click(); + await visual(page).press('Escape'); + await page.keyboard.press('Tab'); + await expect(page.locator('#sourceResizeHandle')).toBeFocused(); + await source(page).click(); + await source(page).press('Escape'); + await page.keyboard.press('Tab'); + await expect(source(page)).not.toBeFocused(); + await page.locator('#typographyToggle').click(); + await page.locator('#defaultMathStyle').focus(); + await page.keyboard.press('Tab'); + await expect(page.locator('#typographyPanel')).toBeHidden(); + expect(await submitted(page)).toBe('x+1'); + expect(errors).toEqual([]); +}); + +test('short windows retain both panes; wheel modes stay isolated', async ({page}) => { + const errors = await open(page); + await page.setViewportSize({width: 640, height: 400}); + const resize = page.locator('#sourceResizeHandle'); + await resize.press('End'); + const formula = await page.locator('.formula-stage').boundingBox(); + const sourceBox = await page.locator('#latexSource').boundingBox(); + expect(formula.height).toBeGreaterThan(20); + expect(sourceBox.height).toBeGreaterThan(20); + await expect(page.locator('#acceptButton')).toBeInViewport(); + await page.locator('[data-group="structures"]').click(); + const grid = page.locator('#symbolGrid'); + for (const deltaMode of [0, 1, 2]) { + await grid.evaluate((el, deltaMode) => { el.scrollLeft = 0; el.dispatchEvent(new WheelEvent('wheel', {deltaY: 10, deltaMode, cancelable: true})); }, deltaMode); + expect(await grid.evaluate(el => el.scrollLeft)).toBeGreaterThan(0); + } + const native = await grid.evaluate(el => ['horizontal', 'zoom'].map(mode => { + const event = new WheelEvent('wheel', {deltaX: mode === 'horizontal' ? 100 : 0, deltaY: 10, + ctrlKey: mode === 'zoom', cancelable: true}); el.dispatchEvent(event); return !event.defaultPrevented; + })); + expect(native).toEqual([true, true]); + await page.screenshot({path: join(tmpdir(), 'latexsnipper-6c-compact.png')}); + expect(errors).toEqual([]); +}); + + +test('keyboard entry into MathLive works while a late focus cannot steal a toolbar field', async ({page}) => { + const errors = await open(page); + await page.locator('#libraryToggle').focus(); + await page.keyboard.press('Tab'); + await expect(visual(page)).toBeFocused(); + await page.locator('#fontSizePoints').click(); + await page.evaluate(() => HTMLElement.prototype.focus.call(document.querySelector('math-field'))); + await expect(page.locator('#fontSizePoints')).toBeFocused(); + await page.locator('#fontSizePoints').fill('18'); + expect(await submitted(page)).toBe('x+1'); + expect(errors).toEqual([]); +});