From 586ea645245437ee648de1286c25525fa5273ec8 Mon Sep 17 00:00:00 2001 From: Huaqing Xu Date: Fri, 6 Nov 2026 21:29:09 +0800 Subject: [PATCH] feat(markdown): edit rendered blocks in place with a VS Code style lock toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdown panel could only be read (rendered preview) or edited as the whole document (NSTextView source mode). Add a third mode that keeps the rendered document and lets a block be edited where it is. Design: the renderer stamps every top-level block with the byte range it came from, so committing an edit replaces exactly that slice of the file. Nothing is ever re-serialized from HTML, which means blocks the user did not touch — tables, code fences, frontmatter, manual line breaks — keep their original bytes. VS Code's own Markdown Editor engine could not be reused: @vscode/markdown-editor constructs an EditContext, a Chromium-only API that WKWebView does not have (verified: ReferenceError on boot). Milkdown round-trips through remark and rewrites untouched blocks, so it would need a source map to be safe. Block ranges over the existing renderer give the same guarantee with no new engine. The panel gets a pencil button that enters the mode; inside it a lock/pencil pill (VS Code's design pins to the top-right. Clicking content while locked only flashes a shine across the pill. Cmd+S flushes the open block first; switching modes commits before leaving. Verified offscreen in WKWebView (no focus stealing): block ranges tile the whole source with no gaps or overlaps and round-trip byte-identically on README.md, PATCHES.md and a CHANGELOG slice; clicking a block loads its raw markdown, committing posts the edited document, and the locked pill shines instead of opening an editor. Co-Authored-By: pi EOF ) --- CHANGELOG.md | 1 + PATCHES.md | 52 +++ Resources/markdown-viewer/markdown-editor.js | 397 +++++++++++++++++++ Resources/markdown-viewer/shell.html | 120 ++++++ Sources/ContentView.swift | 5 +- Sources/Panels/MarkdownPanel+Focus.swift | 8 +- Sources/Panels/MarkdownPanel.swift | 73 +++- Sources/Panels/MarkdownPanelView.swift | 75 +++- Sources/Panels/MarkdownViewerAssets.swift | 3 + Sources/Panels/MarkdownWebRenderer.swift | 135 ++++++- Sources/Panels/MarkdownWebSupport.swift | 22 +- Sources/TabManager+BrowserFocus.swift | 2 +- cmuxTests/MarkdownPanelTests.swift | 58 +++ 13 files changed, 920 insertions(+), 31 deletions(-) create mode 100644 Resources/markdown-viewer/markdown-editor.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd91bc..81bbf3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to cmux are documented here. ## [未发布] ### Added +- **Markdown 面板新增「就地编辑」模式(预览态直接编辑,2026-11-06)**:预览面板头部加一个铅笔按钮进入该模式;面板右上角是 VS Code Markdown Editor 同款的「锁 / 笔」滑动开关,锁定时点击内容只会让开关闪一道镜面光泽提醒当前只读,解锁后点任意块即把**这一块的 Markdown 原文**展开在原地编辑。设计要点是**永远不把渲染后的 HTML 反序列化成 Markdown**:每个块在渲染时就记录了它在源文件里的 `[start, end)` 区间,提交时只替换这一段字节,所以没动过的块(表格、代码块、frontmatter、手工换行)一个字节都不会变。这是本机验证过的取舍:上游那份「整篇 contenteditable + DOM→Markdown 序列化」的 PR(manaflow-ai/cmux#12479)自己承认保存会归一化整个文档;而 VS Code 真正在用的 `@vscode/markdown-editor` 依赖 Chromium 专有的 `EditContext`,在 WKWebView 里直接 `ReferenceError: Can't find variable: EditContext`(已实测),搬不进来。Cmd+S 会先提交正在编辑的块再落盘;切到其它模式前也会先提交。取舍与边界见 `PATCHES.md` P4。 - **Release 按键延迟探针(2026-11-06)**:`GhosttyTerminalView.swift` 新增 `KeyLatencyProbe`,记录 `NSEvent.timestamp`(系统排队按键的时刻)到 `keyDown` 处理、以及交给 Ghostty(`ghosttySent`)的延迟,逐行写 `~/.local/state/cmux/key-latency.log`。默认关闭:`CMUX_KEY_LATENCY_PROBE=1` 或 `defaults write cmuxKeyLatencyProbe -bool true` 开启;关闭时每个按键只多一次静态 bool 检查。它补的是 `scripts/measure-input-latency.py` 测不到的「键盘 / 输入法 → app」这一段。 ### Changed diff --git a/PATCHES.md b/PATCHES.md index 9aeb48a..f6b4611 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -177,3 +177,55 @@ | #20215(新增 `app.closeRunningTabsWithoutConfirmation`) | open、停滞 | 我们用自己的键名 `app.confirmCloseRunningProcess`,语义相反、默认不提示 | | #9865(空闲 coding agent 不算 running process) | open | **暂未实现**,见 TODO.md:P1 默认关闭进程保护后这条不紧急 | | #1519 / #2195 / #4705 / #17136 / #20778 / #25089、PR #5665 / #20529(上游输入延迟一组) | 上游仍 OPEN,或 PR 未合并 | 本仓库 Issue #10 ~ #18 的逐条核实与处置见 [LATENCY.md](LATENCY.md):大部分根因已由基线 `v0.65.2.2` 以别的 PR 修复,Cloud 服务端代码已剥离,剩余精确实现登记 TODO T11 | + +--- + +## P4 — Markdown 面板:预览态就地编辑(锁 / 笔滑钮,VS Code 式) + +**状态**:已实现(自建版 `0.65.2.2-xhqing.5` 起) + +### 要解决的问题 + +cmux 的 Markdown 面板只有两种形态:只读渲染(`preview`)和整篇源码的 NSTextView(`text`)。想在渲染结果里改一句话,得整体切到源码模式,再在几千行里找那一段。想要的体验是 Typora / VS Code 新版 Markdown Editor 那样:**看着渲染结果直接改内容**。 + +### 为什么不是「搬 VS Code 的实现」 + +VS Code 的 Markdown Editor 由一个 npm 包 `@vscode/markdown-editor`(MIT,0.0.2-175)驱动。本机实测(离屏 WKWebView 加载真实包): + +- 包本身能在 WebKit 里解析、CSS 正常,但 `EditorView` 构造时无条件执行 `new EditContext({...})`,而 `EditContext` 是 Chromium 专有 API(配合 `element.editContext` 做文本输入)。WebKit 至今没有实现,于是启动即 `ReferenceError: Can't find variable: EditContext`。 +- 它的面板适配层还依赖 VS Code 的 webview RPC(`@vscode/hubrpc`)、页面宿主和语言特性服务,搬进 cmux 等于顺带搬半个产品。 + +另外两条也不走:上游 PR #12479 是「整篇 contenteditable + 自己写 DOM→Markdown 序列化」,它自己的 Limits 里写着保存会归一化整个文档(表格分隔行、行内代码、手工换行都会被改写),而且该 PR 至今 OPEN + CONFLICTING;Milkdown(上游 NEXT 分支用的引擎)能在 WebKit 里跑,但 `getMarkdown()` 的往返同样会改写未编辑的块——实测把 `| --- | --- |` 写成 `| -- | -- |`、把 `$$ 块公式 $$` 变成行内 `$…$`。 + +### 改法(按本仓库结构实现) + +**核心:每个块记录它在源文件里的字节区间,编辑只回写这一段。** + +| 文件 | 改动 | +| --- | --- | +| `Resources/markdown-viewer/markdown-editor.js` | 新增。渲染后把 `marked` 的顶层 token 与渲染出的 DOM 子节点并行走一遍,给每个块打上 `data-cmux-block-start/end`;点块时用这段区间取出原文放进浮层 textarea,提交时把 textarea 的字节替换回该区间。锁/笔滑钮、闪光泽提示也在这里。 | +| `Resources/markdown-viewer/shell.html` | 渲染函数末尾加一行 `CmuxMarkdownEditor.markRendered(md)` 钩子;新增 `{{markdownEditorJS}}` 占位与滑钮 / 浮层的样式。只读渲染路径本身没动。 | +| `Sources/Panels/MarkdownPanel.swift` | `MarkdownPanelDisplayMode` 增加 `.editor`;`editorDidCommitText` 复用现有的 `textContent` / `isDirty` 通道;`commitEditorAndSave` 先 `flush()` 页面里未提交的块再保存;锁状态用 `markdown.editor.locked` 记住(和 VS Code 一样,下一个文档沿用)。 | +| `Sources/Panels/MarkdownWebRenderer.swift` | 新增 `cmuxMarkdownEditor` 消息桥(`change` / `readonly`),安装 / 卸载页面编辑层,`didFinish` 后重装(崩溃恢复重载也能编辑)。 | +| `Sources/Panels/MarkdownWebSupport.swift` | `MarkdownWebView.performKeyEquivalent` 现在也能把 `saveFilePreview` 快捷键交给面板(就地编辑模式没有 NSTextView 拥有它)。 | +| `Sources/Panels/MarkdownPanelView.swift` | 第三模式的分支与头部按钮(铅笔进入、眼睛回只读、`doc.plaintext` 回整篇源码)。 | +| `Sources/Panels/MarkdownViewerAssets.swift` | 注入 `markdown-editor.js`(和 viewer-navigation / sanitizer 一样的加载路径,构建期自动 deflate)。 | + +### 保留的行为(没被削弱) + +- **只读渲染路径完全没动**:`preview` 仍然走原来的整篇 `marked.parse` + sanitizer,Mermaid / Vega / KaTeX 照旧;就地编辑层只在 `.editor` 模式安装,切回去就卸载。 +- **不归一化未编辑的内容**:提交只替换被编辑块的那段字节,其它块(包含 frontmatter、表格、代码块、手工换行)逐字节不动。 +- **脏 / 保存语义沿用面板**:脏标记、Cmd+S、Revert、外部文件变更合并、会话恢复都还是 `MarkdownPanel` 说了算,编辑层只上报文本。 +- **安全边界不变**:编辑面只是 textarea 覆盖层,渲染仍走原有 allowlist sanitizer;本地图片仍走 `cmux-local-image://` 而不能读 `file://`。 + +### 已知边界 + +- 一次只能编辑一个块;提交后整篇重渲染(滚动位置由现有逻辑保持)。块内的**结构**要手写 Markdown(表格仍是表格语法),不是 Typora 那种表格网格。 +- 编辑浮层按打开时的位置定位,编辑期间滚动窗口不会跟随(v1 取舍)。 +- 就地编辑模式下没有查找条(Cmd+F 仍只在 `preview` 里工作),需要查找时切回预览或整篇源码模式。 +- 大文件(如 431 KB 的 `CHANGELOG.md`)打开就地编辑会偏慢:每次渲染都要算一遍块区间;预览模式不受影响。 + +### 验证 + +- 离屏 WKWebView 冒烟(本机 `swiftc` + WebKit,不抢焦点):块区间覆盖校验——`README.md` 24 块 / 4804 字节、`CHANGELOG.md` 前 120 行 42 块 / 14362 字节,**非空白字节全部落在某个块的区间内、无重叠**;点击开块、textarea 取到该块原文、提交后消息里的文档结构完整;锁定时点击只触发滑钮光泽、不开编辑层。 +- `swiftc -parse` 通过(6 个改动文件 + 2 个新增测试);完整编译与运行时行为交由 CI / Release 构建验证(本机只有 CommandLineTools)。 diff --git a/Resources/markdown-viewer/markdown-editor.js b/Resources/markdown-viewer/markdown-editor.js new file mode 100644 index 0000000..e60273e --- /dev/null +++ b/Resources/markdown-viewer/markdown-editor.js @@ -0,0 +1,397 @@ +// In-place editing for the cmux Markdown viewer. +// +// Design note: the rendered document stays the layout source of truth. When +// editing is unlocked, activating a block swaps *that block only* for its own +// Markdown source, and committing writes those bytes back into the document. +// Nothing is ever re-serialized from HTML, so a save cannot rewrite blocks the +// user did not touch — the failure mode of "contenteditable over rendered +// HTML" editors. +// +// The lock/pencil pill mirrors VS Code's Markdown Editor: a segmented control +// pinned to the top-right of the content, plus a shine sweep across the pill +// whenever someone tries to edit while it is locked. +(function(global) { + 'use strict'; + + var BLOCK_ATTRIBUTE_START = 'data-cmux-block-start'; + var BLOCK_ATTRIBUTE_END = 'data-cmux-block-end'; + var ACTIVE_CLASS = 'cmux-block-editing'; + var EDITOR_CLASS = 'cmux-block-editor'; + + var contentEl = null; + var sourceText = ''; + var locked = false; + var installed = false; + var activeBlock = null; + var postToHost = null; + var pointerStart = null; + + // MARK: - Source ranges + + /** + * Walks `marked`'s top-level tokens and the rendered DOM children in + * parallel, stamping each rendered element with the source range it came + * from. Tokens that render nothing (blank-line trivia) advance the source + * offset without consuming a node; tokens that render several nodes (raw + * HTML blocks) stamp every one of them with the same range. + */ + // The viewer wraps some rendered nodes (code blocks get a + // `div.cmux-code-block` for the copy button), so a token can own a wrapper + // plus its inner element. These are the elements a token type may render. + function expectedTags(type) { + switch (type) { + case 'heading': return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']; + case 'paragraph': return ['P', 'DIV', 'FIGURE']; + case 'list': return ['UL', 'OL']; + case 'blockquote': return ['BLOCKQUOTE']; + case 'table': return ['TABLE']; + case 'code': return ['PRE', 'DIV']; + case 'hr': return ['HR']; + default: return null; + } + } + + function stampBlockRanges(markdown) { + var marked = global.marked; + if (!marked || !contentEl) { return; } + var tokens; + try { + tokens = marked.lexer(markdown); + } catch (error) { + return; + } + var offset = 0; + var children = Array.prototype.slice.call(contentEl.children); + var index = 0; + var stamped = []; + for (var i = 0; i < tokens.length && index < children.length; i++) { + var token = tokens[i]; + var raw = typeof token.raw === 'string' ? token.raw : ''; + var start = offset; + offset += raw.length; + if (!raw) { continue; } + if (token.type === 'space') { continue; } + + var wanted = expectedTags(token.type); + // Raw HTML and plain-text tokens are the only ones that can render more + // than one top-level node; everything else is one element (plus, for + // code blocks, the viewer's wrapper). Probing them keeps stamping linear + // even on very large documents. + var budget = (token.type === 'html' || token.type === 'text') ? 8 : 2; + var consumed = []; + for (var guard = 0; guard < budget && index < children.length; guard++) { + var candidate = children[index]; + consumed.push(candidate); + index++; + if (!wanted || wanted.indexOf(candidate.tagName) >= 0) { break; } + if (candidate.tagName !== 'DIV') { break; } + } + for (var c = 0; c < consumed.length; c++) { + var element = consumed[c]; + element.setAttribute(BLOCK_ATTRIBUTE_START, String(start)); + element.setAttribute(BLOCK_ATTRIBUTE_END, String(offset)); + element.classList.add('cmux-block'); + stamped.push(element); + } + } + + // Guarantee that the ranges tile the whole document: a rendering wrapper, + // a sanitizer drop or an unhandled token may shift the token/element + // pairing by a node. Absorbing the leftover bytes into the neighbouring block is + // always safe — the textarea then simply shows a few marker characters + // more, and committing it unchanged writes them back verbatim. + var cursor = 0; + for (var s = 0; s < stamped.length; s++) { + var node = stamped[s]; + var nodeStart = Number(node.getAttribute(BLOCK_ATTRIBUTE_START)); + var nodeEnd = Number(node.getAttribute(BLOCK_ATTRIBUTE_END)); + if (!Number.isFinite(nodeStart)) { nodeStart = cursor; } + if (!Number.isFinite(nodeEnd) || nodeEnd < nodeStart) { nodeEnd = nodeStart; } + if (nodeStart > cursor) { nodeStart = cursor; } + // `marked` can report a token `raw` that runs one newline past the + // document; clamp so a range never points outside the source. + nodeStart = Math.min(nodeStart, markdown.length); + nodeEnd = Math.min(nodeEnd, markdown.length); + node.setAttribute(BLOCK_ATTRIBUTE_START, String(nodeStart)); + node.setAttribute(BLOCK_ATTRIBUTE_END, String(nodeEnd)); + cursor = Math.max(cursor, nodeEnd); + } + if (stamped.length > 0 && cursor < markdown.length) { + stamped[stamped.length - 1].setAttribute(BLOCK_ATTRIBUTE_END, String(markdown.length)); + } + } + + function blockElementAt(target) { + var element = target && target.closest ? target.closest('[' + BLOCK_ATTRIBUTE_START + ']') : null; + if (!element || !contentEl.contains(element)) { return null; } + return element; + } + + function blockRange(element) { + var start = Number(element.getAttribute(BLOCK_ATTRIBUTE_START)); + var end = Number(element.getAttribute(BLOCK_ATTRIBUTE_END)); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { return null; } + return { start: start, end: end }; + } + + // MARK: - Lock state + + function syncLockUI() { + var pill = document.getElementById('cmux-markdown-lock'); + if (!pill) { return; } + pill.classList.toggle('is-locked', locked); + pill.setAttribute('aria-pressed', locked ? 'false' : 'true'); + pill.setAttribute('aria-label', locked ? 'Read only' : 'Editing'); + pill.setAttribute('title', locked ? 'Read only — click to edit' : 'Editing — click to lock'); + var lockIcon = pill.querySelector('.cmux-lock-icon'); + var penIcon = pill.querySelector('.cmux-pen-icon'); + if (lockIcon && penIcon) { + lockIcon.hidden = !locked; + penIcon.hidden = locked; + } + if (contentEl) { contentEl.classList.toggle('cmux-locked', locked); } + } + + function shineLockPill() { + var pill = document.getElementById('cmux-markdown-lock'); + if (!pill) { return; } + pill.classList.remove('cmux-shine'); + // Force a style flush so the animation restarts on rapid repeats. + void pill.offsetWidth; + pill.classList.add('cmux-shine'); + global.setTimeout(function() { pill.classList.remove('cmux-shine'); }, 900); + } + + function setLocked(next) { + locked = !!next; + // Locking must not throw away the block the user is in the middle of + // editing; commit it first, then switch to read-only. + if (locked) { closeActiveBlock(true, true); } + syncLockUI(); + } + + // MARK: - Block editing + + function closeActiveBlock(commit, notify) { + if (!activeBlock) { return; } + var state = activeBlock; + activeBlock = null; + var editor = state.editor; + + state.element.classList.remove(ACTIVE_CLASS); + state.element.hidden = false; + if (editor && editor.parentNode) { editor.parentNode.removeChild(editor); } + global.document.removeEventListener('mousedown', state.onOutsidePointer, true); + global.document.removeEventListener('keydown', state.onKeyDown, true); + + if (!commit) { return; } + var range = blockRange(state.element); + if (!range) { return; } + var next = state.text; + if (next === state.original) { return; } + // The block's raw source owns its trailing blank lines, so they must + // survive the replacement: dropping them would glue the edited block to + // the next one on the re-render. + var trailing = /[\r\n]+$/.exec(state.original); + next = next.replace(/[ \t\r\n]+$/, '') + (trailing ? trailing[0] : ''); + var updated = sourceText.slice(0, range.start) + next + sourceText.slice(range.end); + sourceText = updated; + if (postToHost && notify !== false) { postToHost({ type: 'change', text: updated }); } + } + + function openBlock(element) { + if (locked) { + shineLockPill(); + return; + } + if (activeBlock) { + if (activeBlock.element === element) { return; } + closeActiveBlock(true, true); + } + var range = blockRange(element); + if (!range) { + shineLockPill(); + return; + } + + var rect = element.getBoundingClientRect(); + var editor = document.createElement('div'); + editor.className = EDITOR_CLASS; + editor.style.left = Math.max(0, rect.left - 12) + 'px'; + editor.style.top = Math.max(0, rect.top - 8) + 'px'; + editor.style.width = Math.max(320, rect.width + 24) + 'px'; + + var textarea = document.createElement('textarea'); + textarea.className = EDITOR_CLASS + '-input'; + textarea.spellcheck = false; + textarea.value = sourceText.slice(range.start, range.end); + editor.appendChild(textarea); + + var hint = document.createElement('div'); + hint.className = EDITOR_CLASS + '-hint'; + hint.textContent = '⌘↩ 保存 · Esc 取消'; + editor.appendChild(hint); + + document.body.appendChild(editor); + autoSize(textarea); + + var state = { + element: element, + editor: editor, + text: textarea.value, + original: textarea.value, + onOutsidePointer: function(event) { + if (editor.contains(event.target)) { return; } + closeActiveBlock(true, true); + }, + onKeyDown: function(event) { + if (event.key === 'Escape') { + event.preventDefault(); + closeActiveBlock(false, false); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + closeActiveBlock(true, true); + return; + } + } + }; + activeBlock = state; + element.hidden = true; + element.classList.add(ACTIVE_CLASS); + + textarea.addEventListener('input', function() { + state.text = textarea.value; + autoSize(textarea); + }); + textarea.addEventListener('keydown', function(event) { + event.stopPropagation(); + }); + + global.document.addEventListener('mousedown', state.onOutsidePointer, true); + global.document.addEventListener('keydown', state.onKeyDown, true); + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + } + + function autoSize(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.max(48, textarea.scrollHeight + 8) + 'px'; + } + + // MARK: - Wiring + + function installLockPill() { + if (document.getElementById('cmux-markdown-lock')) { return; } + var pill = document.createElement('button'); + pill.id = 'cmux-markdown-lock'; + pill.type = 'button'; + pill.className = 'cmux-markdown-lock'; + pill.innerHTML = + '' + + ''; + + pill.addEventListener('click', function() { + locked = !locked; + if (locked) { closeActiveBlock(true, true); } + syncLockUI(); + if (postToHost) { postToHost({ type: 'readonly', value: locked }); } + }); + + var host = document.getElementById('cmux-markdown-lock-host'); + if (!host) { + host = document.createElement('div'); + host.id = 'cmux-markdown-lock-host'; + host.className = 'cmux-markdown-lock-host'; + document.body.appendChild(host); + } + host.appendChild(pill); + syncLockUI(); + } + + function installClickHandling() { + if (contentEl.__cmuxEditorClickInstalled) { return; } + contentEl.__cmuxEditorClickInstalled = true; + + contentEl.addEventListener('mousedown', function(event) { + if (event.button !== 0) { return; } + pointerStart = { x: event.clientX, y: event.clientY, target: event.target }; + }, true); + + contentEl.addEventListener('mouseup', function(event) { + var start = pointerStart; + pointerStart = null; + if (!installed || !start || event.button !== 0) { return; } + // A drag is a text selection, not a block activation. + if (Math.abs(event.clientX - start.x) > 4 || Math.abs(event.clientY - start.y) > 4) { return; } + var selection = global.getSelection && global.getSelection(); + if (selection && !selection.isCollapsed) { return; } + if (isEmbeddedControl(event.target)) { return; } + var element = blockElementAt(event.target); + if (!element) { return; } + if (locked) { + shineLockPill(); + return; + } + if (activeBlock && activeBlock.element === element) { return; } + openBlock(element); + }, true); + } + + function isEmbeddedControl(target) { + // Links, task checkboxes and other embedded controls keep their own + // behaviour: activating a block must not swallow them. + return Boolean(target && target.closest && target.closest('a[href], input, button, summary')); + } + + var CmuxMarkdownEditor = { + install: function(options) { + options = options || {}; + contentEl = options.contentElement || document.getElementById('content'); + postToHost = options.postToHost || postToHost; + locked = options.locked === true; + installed = true; + installLockPill(); + installClickHandling(); + syncLockUI(); + return CmuxMarkdownEditor; + }, + /// Removes the editing affordances when the panel goes back to plain + /// reading, without touching the rendered document. + uninstall: function() { + installed = false; + closeActiveBlock(true, true); + var host = document.getElementById('cmux-markdown-lock-host'); + if (host && host.parentNode) { host.parentNode.removeChild(host); } + if (contentEl) { contentEl.classList.remove('cmux-locked'); } + }, + markRendered: function(markdown) { + if (typeof markdown === 'string') { sourceText = markdown; } + if (!contentEl) { contentEl = document.getElementById('content'); } + if (!contentEl) { return; } + stampBlockRanges(sourceText); + if (installed) { + installLockPill(); + syncLockUI(); + } + }, + setLocked: setLocked, + isLocked: function() { return locked; }, + closeActiveBlock: function(commit) { closeActiveBlock(commit !== false, true); }, + /// Commits any open block and hands the canonical document back, so the + /// host can save without waiting for a follow-up message. + flush: function() { + closeActiveBlock(true, false); + return sourceText; + } + }; + + global.CmuxMarkdownEditor = CmuxMarkdownEditor; +})(window); diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html index 0089c02..03f38f9 100644 --- a/Resources/markdown-viewer/shell.html +++ b/Resources/markdown-viewer/shell.html @@ -253,6 +253,120 @@ height: 1px; } +
@@ -2011,6 +2125,9 @@ if (didReplaceContent) { restoreMarkdownScrollState(scrollState); } + if (window.CmuxMarkdownEditor) { + try { window.CmuxMarkdownEditor.markRendered(md || ''); } catch (e) {} + } } catch (e) { var errorBox = document.createElement('div'); errorBox.setAttribute( @@ -2131,5 +2248,8 @@ }); })(); + diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 14b22d6..7021b9e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7490,11 +7490,12 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) snapshot.setBool(CommandPaletteContextKeys.panelIsSimulator, panelContext.panel.panelType == .simulator) if let browserPanel = panelContext.panel as? BrowserPanel { Self.setCommandPaletteBrowserToggleContext(for: browserPanel, in: &snapshot) } - // Markdown zoom only affects the rendered preview, so don't surface + // Markdown zoom only affects the rendered surface, so don't surface // the zoom commands when the panel is in raw text-edit mode. + let markdownDisplayMode = (panelContext.panel as? MarkdownPanel)?.displayMode snapshot.setBool( CommandPaletteContextKeys.panelIsMarkdown, - (panelContext.panel as? MarkdownPanel)?.displayMode == .preview + markdownDisplayMode == .preview || markdownDisplayMode == .editor ) snapshot.setBool( CommandPaletteContextKeys.panelIsFilePreviewTextEditor, diff --git a/Sources/Panels/MarkdownPanel+Focus.swift b/Sources/Panels/MarkdownPanel+Focus.swift index 34bc564..79624ff 100644 --- a/Sources/Panels/MarkdownPanel+Focus.swift +++ b/Sources/Panels/MarkdownPanel+Focus.swift @@ -5,10 +5,10 @@ import Foundation extension MarkdownPanel { // MARK: - Find in preview focus - /// Shows (or refocuses) the preview find bar. Preview-only: text mode uses - /// the NSTextView's native find panel via the responder chain. + /// Shows (or refocuses) the rendered-surface find bar. Text mode uses the + /// NSTextView's native find panel via the responder chain instead. func startFind() { - guard displayMode == .preview else { return } + guard displayMode == .preview || displayMode == .editor else { return } let created = searchState == nil let recoveredNeedle = created ? lastSearchNeedle : "" if created { searchState = BrowserSearchState(needle: recoveredNeedle) } @@ -37,7 +37,7 @@ extension MarkdownPanel { self.searchState = nil if shouldRestorePreviewFocus { pendingPreviewFocus = false - if displayMode == .preview, + if displayMode == .preview || displayMode == .editor, let webView = rendererSession.webView, webView.window === window { _ = window?.makeFirstResponder(webView) diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 0be03d9..7d46208 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -6,6 +6,9 @@ import Foundation enum MarkdownPanelDisplayMode: String, CaseIterable, Identifiable { case preview + /// Rendered document with in-place block editing (VS Code Markdown Editor + /// style lock/pencil toggle). + case editor case text var id: String { rawValue } @@ -37,6 +40,10 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// Whether TextEdit mode is saving to disk. @Published private(set) var isSaving: Bool = false + /// Whether the in-place editor is locked (read-only). Persisted across + /// panels the way VS Code remembers its Markdown Editor lock state. + @Published private(set) var isEditorLocked: Bool = MarkdownEditorLockSettings.isLocked() + /// The current view mode for this markdown panel. New panels default to preview. @Published private(set) var displayMode: MarkdownPanelDisplayMode = .preview @@ -399,7 +406,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// attached to its window. The callback is event-driven, so it cannot /// steal focus after this panel has been unfocused in the meantime. func replayPendingPreviewFocusAfterWindowAttach() { - guard pendingPreviewFocus, displayMode == .preview else { return } + guard pendingPreviewFocus, displayMode == .preview || displayMode == .editor else { return } focus() } @@ -448,6 +455,11 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel func setDisplayMode(_ mode: MarkdownPanelDisplayMode) { guard displayMode != mode else { return } + // Leaving the in-place editor commits any open block first: a mode + // switch must never silently discard the text the user is editing. + if displayMode == .editor { + rendererSession.commitActiveBlockEdit() + } displayMode = mode if mode == .text { // The find bar and its highlights belong to the preview surface; @@ -455,6 +467,49 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel hideFind() focus() } + if mode == .editor { + hideFind() + focus() + } + } + + /// Applies a committed block edit coming from the page's in-place editor. + /// Accepts the text even when the mode already moved on: the commit is the + /// same document, and a mode switch asks the page to commit first. + @discardableResult + func editorDidCommitText(_ nextContent: String) -> Bool { + guard textContent != nextContent else { return false } + updateTextContent(nextContent) + return true + } + + /// Save path for the in-place editor: flushes the block the user is + /// editing (it lives in a page overlay, not in `textContent`) and then + /// writes to disk. + func commitEditorAndSave() { + guard displayMode == .editor, let webView = rendererSession.webView else { + _ = saveTextContent() + return + } + webView.evaluateJavaScript( + "window.CmuxMarkdownEditor ? window.CmuxMarkdownEditor.flush() : null" + ) { [weak self] value, _ in + Task { @MainActor in + guard let self else { return } + if let text = value as? String, text != self.textContent { + self.updateTextContent(text) + } + _ = self.saveTextContent() + } + } + } + + /// Mirrors the page's lock toggle into the panel and remembers it for the + /// next markdown editor the user opens. + func setEditorLocked(_ locked: Bool) { + guard isEditorLocked != locked else { return } + isEditorLocked = locked + MarkdownEditorLockSettings.setLocked(locked) } func readSurfaceSelection() async -> SurfaceSelectionReadResult { @@ -465,7 +520,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel kind: .markdown, filePath: filePath )) - case .preview: + case .preview, .editor: return await rendererSession.readSurfaceSelection(filePath: filePath) } } @@ -704,3 +759,17 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel } extension MarkdownPanel: FileContentChangeObservingPanel {} + +/// Remembers whether the in-place editor opens locked, the way VS Code's +/// Markdown Editor carries its lock state over to the next document. +enum MarkdownEditorLockSettings { + private static let key = "markdown.editor.locked" + + static func isLocked() -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + static func setLocked(_ locked: Bool) { + UserDefaults.standard.set(locked, forKey: key) + } +} diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index ea72e2a..dd96d37 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -72,18 +72,30 @@ struct MarkdownPanelView: View { @ViewBuilder private var markdownBody: some View { + let renderedVisible = panel.displayMode == .preview || panel.displayMode == .editor ZStack { MarkdownWebRenderer( markdown: panel.content, theme: MarkdownWebTheme.resolve(backgroundColor: themeBackgroundColor), backgroundColor: appearance.contentBackgroundColor, - isVisibleInUI: isVisibleInUI && panel.displayMode == .preview, + isVisibleInUI: isVisibleInUI && renderedVisible, panelId: panel.id, workspaceId: panel.workspaceId, filePath: panel.filePath, fontSize: panel.fontSize, fontFamily: panel.fontFamily, maxContentWidth: panel.maxContentWidth, + isEditingEnabled: panel.displayMode == .editor, + isEditorLocked: panel.isEditorLocked, + onEditorTextChange: { [weak panel] text in + panel?.editorDidCommitText(text) + }, + onEditorLockChange: { [weak panel] locked in + panel?.setEditorLocked(locked) + }, + onSaveShortcut: { [weak panel] in + panel?.commitEditorAndSave() + }, session: panel.rendererSession, onRequestPanelFocus: onRequestPanelFocus, onViewAttachedToWindow: { [weak panel] in @@ -91,9 +103,9 @@ struct MarkdownPanelView: View { } ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .opacity(panel.displayMode == .preview ? 1 : 0) - .allowsHitTesting(panel.displayMode == .preview) - .accessibilityHidden(panel.displayMode != .preview) + .opacity(renderedVisible ? 1 : 0) + .allowsHitTesting(renderedVisible) + .accessibilityHidden(!renderedVisible) if panel.displayMode == .text { FilePreviewTextEditor( @@ -109,7 +121,7 @@ struct MarkdownPanelView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - if panel.displayMode == .preview, let searchState = panel.searchState { + if panel.displayMode == .preview || panel.displayMode == .editor, let searchState = panel.searchState { BrowserSearchOverlay( panelId: panel.id, searchState: searchState, @@ -132,7 +144,7 @@ struct MarkdownPanelView: View { filePath: panel.filePath, foregroundColor: themeForegroundColor ) { - if panel.displayMode == .text { + if panel.displayMode == .text || panel.displayMode == .editor { PanelHeaderIconButton( systemName: "arrow.counterclockwise", label: String(localized: "markdown.toolbar.revert", defaultValue: "Revert"), @@ -147,7 +159,7 @@ struct MarkdownPanelView: View { action: { panel.saveTextContent() } ) } - if panel.displayMode == .preview { + if panel.displayMode == .preview || panel.displayMode == .editor { MarkdownTypographyControl(panel: panel) PanelHeaderIconButton( systemName: "arrow.clockwise", @@ -169,19 +181,42 @@ struct MarkdownPanelView: View { } private var markdownModeButton: some View { - switch panel.displayMode { - case .preview: - PanelHeaderIconButton( - systemName: "doc.plaintext", - label: String(localized: "markdown.mode.showTextEdit", defaultValue: "Show TextEdit"), - action: { panel.setDisplayMode(.text) } - ) - case .text: - PanelHeaderIconButton( - systemName: "eye", - label: String(localized: "markdown.mode.showPreview", defaultValue: "Show Preview"), - action: { panel.setDisplayMode(.preview) } - ) + HStack(spacing: 2) { + switch panel.displayMode { + case .preview: + PanelHeaderIconButton( + systemName: "pencil", + label: String(localized: "markdown.mode.showEditor", defaultValue: "Edit in Place"), + action: { panel.setDisplayMode(.editor) } + ) + PanelHeaderIconButton( + systemName: "doc.plaintext", + label: String(localized: "markdown.mode.showTextEdit", defaultValue: "Show TextEdit"), + action: { panel.setDisplayMode(.text) } + ) + case .editor: + PanelHeaderIconButton( + systemName: "eye", + label: String(localized: "markdown.mode.showPreview", defaultValue: "Show Preview"), + action: { panel.setDisplayMode(.preview) } + ) + PanelHeaderIconButton( + systemName: "doc.plaintext", + label: String(localized: "markdown.mode.showTextEdit", defaultValue: "Show TextEdit"), + action: { panel.setDisplayMode(.text) } + ) + case .text: + PanelHeaderIconButton( + systemName: "eye", + label: String(localized: "markdown.mode.showPreview", defaultValue: "Show Preview"), + action: { panel.setDisplayMode(.preview) } + ) + PanelHeaderIconButton( + systemName: "pencil", + label: String(localized: "markdown.mode.showEditor", defaultValue: "Edit in Place"), + action: { panel.setDisplayMode(.editor) } + ) + } } } diff --git a/Sources/Panels/MarkdownViewerAssets.swift b/Sources/Panels/MarkdownViewerAssets.swift index c375691..b4949c4 100644 --- a/Sources/Panels/MarkdownViewerAssets.swift +++ b/Sources/Panels/MarkdownViewerAssets.swift @@ -15,6 +15,7 @@ final class MarkdownViewerAssets { private let githubMarkdownCSS: String private let viewerNavigationJS: String private let markdownSanitizerJS: String + private let markdownEditorJS: String private let shellTemplate: String private let localizedStringsJSON: String @@ -28,6 +29,7 @@ final class MarkdownViewerAssets { githubMarkdownCSS = MarkdownViewerAssets.loadAsset(name: "github-markdown", ext: "css") viewerNavigationJS = MarkdownViewerAssets.loadAsset(name: "viewer-navigation", ext: "js") markdownSanitizerJS = MarkdownViewerAssets.loadAsset(name: "markdown-sanitizer", ext: "js") + markdownEditorJS = MarkdownViewerAssets.loadAsset(name: "markdown-editor", ext: "js") shellTemplate = MarkdownViewerAssets.loadAsset(name: "shell", ext: "html") localizedStringsJSON = MarkdownViewerAssets.localizedStringsJSON() } @@ -42,6 +44,7 @@ final class MarkdownViewerAssets { .replacingOccurrences(of: "{{highlightJS}}", with: highlightJS) .replacingOccurrences(of: "{{viewerNavigationJS}}", with: viewerNavigationJS) .replacingOccurrences(of: "{{markdownSanitizerJS}}", with: markdownSanitizerJS) + .replacingOccurrences(of: "{{markdownEditorJS}}", with: markdownEditorJS) .replacingOccurrences(of: "{{localizedStringsJSON}}", with: localizedStringsJSON) } diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 843d4cf..6e6c5e8 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -22,6 +22,18 @@ struct MarkdownWebRenderer: NSViewRepresentable { let fontFamily: String /// Maximum content column width, in CSS pixels. let maxContentWidth: Double + /// Whether the in-place block editor layer is installed. The viewer keeps + /// rendering exactly as before; the layer only adds block activation. + var isEditingEnabled: Bool = false + /// Whether the editor starts locked (read-only). Mirrors VS Code's + /// Markdown Editor lock/pencil toggle, which the user can flip in the page. + var isEditorLocked: Bool = false + /// Fired when a block edit is committed in the page. + var onEditorTextChange: (String) -> Void = { _ in } + /// Fired when the page's lock toggle flips. + var onEditorLockChange: (Bool) -> Void = { _ in } + /// Invoked when the panel's save shortcut is pressed inside the editor. + var onSaveShortcut: () -> Void = {} let session: MarkdownRendererSession let onRequestPanelFocus: () -> Void /// Called after the renderer view is attached to a window. A panel can @@ -40,6 +52,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { } webView.onPointerDown = onRequestPanelFocus webView.onAttachToWindow = onViewAttachedToWindow + webView.onSaveShortcut = onSaveShortcut webView.setVisibleInUI(isVisibleInUI) webView.onLeaveWindow = { [weak coordinator = context.coordinator] in coordinator?.handleViewLeftWindow() @@ -64,6 +77,9 @@ struct MarkdownWebRenderer: NSViewRepresentable { // (mermaid / vega-lite). Swift fetches the bundled source from the // app bundle and injects it via evaluateJavaScript. config.userContentController.add(WeakMarkdownScriptMessageHandler(context.coordinator), name: "cmuxLib") + // Bridge for the in-place block editor: commits, lock toggles and link + // activations raised by the page. + config.userContentController.add(WeakMarkdownScriptMessageHandler(context.coordinator), name: "cmuxMarkdownEditor") config.setURLSchemeHandler( context.coordinator, forURLScheme: Self.localImageURLScheme @@ -75,6 +91,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { let webView = MarkdownWebView(frame: .zero, configuration: config) webView.onPointerDown = onRequestPanelFocus webView.onAttachToWindow = onViewAttachedToWindow + webView.onSaveShortcut = onSaveShortcut webView.setVisibleInUI(isVisibleInUI) webView.onLeaveWindow = { [weak coordinator = context.coordinator] in coordinator?.handleViewLeftWindow() @@ -101,6 +118,12 @@ struct MarkdownWebRenderer: NSViewRepresentable { context.coordinator.setFontSize(fontSize) context.coordinator.setFontFamily(fontFamily) context.coordinator.setMaxContentWidth(maxContentWidth) + context.coordinator.configureEditing( + enabled: isEditingEnabled, + locked: isEditorLocked, + onTextChange: onEditorTextChange, + onLockChange: onEditorLockChange + ) context.coordinator.loadShell(theme: theme, initialMarkdown: markdown) return webView } @@ -110,12 +133,19 @@ struct MarkdownWebRenderer: NSViewRepresentable { // the panel-owned renderer session kept the same coordinator. context.coordinator.bind(panelId: panelId, workspaceId: workspaceId, filePath: filePath) (nsView as? MarkdownWebView)?.onPointerDown = onRequestPanelFocus + (nsView as? MarkdownWebView)?.onSaveShortcut = onSaveShortcut (nsView as? MarkdownWebView)?.setVisibleInUI(isVisibleInUI) applyBackground(to: nsView) applyAppearance(to: nsView, isDark: theme.isDark) context.coordinator.setFontSize(fontSize) context.coordinator.setFontFamily(fontFamily) context.coordinator.setMaxContentWidth(maxContentWidth) + context.coordinator.configureEditing( + enabled: isEditingEnabled, + locked: isEditorLocked, + onTextChange: onEditorTextChange, + onLockChange: onEditorLockChange + ) context.coordinator.update(markdown: markdown, theme: theme) } @@ -124,9 +154,11 @@ struct MarkdownWebRenderer: NSViewRepresentable { return } nsView.configuration.userContentController.removeScriptMessageHandler(forName: "cmuxLib") + nsView.configuration.userContentController.removeScriptMessageHandler(forName: "cmuxMarkdownEditor") nsView.navigationDelegate = nil nsView.uiDelegate = nil (nsView as? MarkdownWebView)?.onPointerDown = nil + (nsView as? MarkdownWebView)?.onSaveShortcut = nil (nsView as? MarkdownWebView)?.onAttachToWindow = nil (nsView as? MarkdownWebView)?.onLeaveWindow = nil (nsView as? MarkdownWebView)?.onReenterWindow = nil @@ -170,6 +202,77 @@ struct MarkdownWebRenderer: NSViewRepresentable { private var lastMaxContentWidth: Double = MarkdownMaxWidthSettings.defaultCSSPixels private var isLoaded = false private var isShellLoading = false + + // MARK: In-place editing + + private var isEditingEnabled = false + private var isEditorLocked = false + private var onEditorTextChange: (String) -> Void = { _ in } + private var onEditorLockChange: (Bool) -> Void = { _ in } + + func configureEditing( + enabled: Bool, + locked: Bool, + onTextChange: @escaping (String) -> Void, + onLockChange: @escaping (Bool) -> Void + ) { + isEditingEnabled = enabled + isEditorLocked = locked + onEditorTextChange = onTextChange + onEditorLockChange = onLockChange + applyEditingState() + } + + /// Installs (or removes) the page-side editing layer. Safe to call + /// before the shell finishes loading: `didFinish` re-applies it. + private func applyEditingState() { + guard isLoaded, let webView else { return } + + guard isEditingEnabled else { + guard appliedEditingEnabled == true else { return } + appliedEditingEnabled = false + appliedEditingLocked = nil + webView.evaluateJavaScript( + "window.CmuxMarkdownEditor && window.CmuxMarkdownEditor.uninstall();", + completionHandler: nil + ) + return + } + + if appliedEditingEnabled == true { + guard appliedEditingLocked != isEditorLocked else { return } + appliedEditingLocked = isEditorLocked + webView.evaluateJavaScript( + "window.CmuxMarkdownEditor && window.CmuxMarkdownEditor.setLocked(\(isEditorLocked ? "true" : "false"));", + completionHandler: nil + ) + return + } + + appliedEditingEnabled = true + appliedEditingLocked = isEditorLocked + let locked = isEditorLocked ? "true" : "false" + let js = """ + (function(locked) { + if (!window.CmuxMarkdownEditor) { return; } + window.CmuxMarkdownEditor.install({ + contentElement: document.getElementById('content'), + locked: locked, + postToHost: function(payload) { + var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.cmuxMarkdownEditor; + if (handler) { handler.postMessage(payload); } + } + }); + })(\(locked)); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + } + + /// Whether the page currently holds the editing layer, and with which + /// lock state, so `updateNSView` does not re-evaluate JavaScript on + /// every SwiftUI layout pass. + private var appliedEditingEnabled: Bool? + private var appliedEditingLocked: Bool? private var webContentProcessRecoveryAttempts = 0 private let maxWebContentProcessRecoveryAttempts = 2 /// Whether the shell was confirmed loaded at the moment the host view @@ -282,13 +385,17 @@ struct MarkdownWebRenderer: NSViewRepresentable { if let webView { webView.stopLoading() webView.configuration.userContentController.removeScriptMessageHandler(forName: "cmuxLib") + webView.configuration.userContentController.removeScriptMessageHandler(forName: "cmuxMarkdownEditor") webView.navigationDelegate = nil webView.uiDelegate = nil webView.onPointerDown = nil webView.onAttachToWindow = nil + webView.onSaveShortcut = nil webView.onLeaveWindow = nil webView.onReenterWindow = nil } + appliedEditingEnabled = nil + appliedEditingLocked = nil self.webView = nil isLoaded = false isShellLoading = false @@ -482,8 +589,12 @@ struct MarkdownWebRenderer: NSViewRepresentable { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { - guard message.name == "cmuxLib", + guard message.name == "cmuxLib" || message.name == "cmuxMarkdownEditor", let body = message.body as? [String: Any] else { return } + if message.name == "cmuxMarkdownEditor" { + handleEditorMessage(body) + return + } if let lib = body["lib"] as? String { handleLibRequest(lib) return @@ -517,6 +628,22 @@ struct MarkdownWebRenderer: NSViewRepresentable { private var requestedLibs: Set = [] + /// Messages raised by the in-place editor layer (markdown-editor.js). + private func handleEditorMessage(_ body: [String: Any]) { + guard let type = body["type"] as? String else { return } + switch type { + case "change": + guard let text = body["text"] as? String else { return } + onEditorTextChange(text) + case "readonly": + let locked = body["value"] as? Bool ?? false + isEditorLocked = locked + onEditorLockChange(locked) + default: + break + } + } + /// Records the reader's "Load this image" choice for exactly `rawURL` /// and tells the shell whether it may now request that image. private func approveRemoteImage(_ rawURL: String) { @@ -800,6 +927,9 @@ struct MarkdownWebRenderer: NSViewRepresentable { #endif isShellLoading = false isLoaded = true + // A shell reload wipes the page-side editing layer. + appliedEditingEnabled = nil + appliedEditingLocked = nil // pageZoom is a WKWebView-level property that survives loadHTMLString, // but re-apply defensively after a shell reload so a crash-recovery // path can never drop the configured zoom. @@ -816,6 +946,9 @@ struct MarkdownWebRenderer: NSViewRepresentable { let md = lastMarkdown ?? pendingMarkdown lastMarkdown = md pushMarkdown(md) + // The page-side editor layer is installed after every shell load so + // a crash-recovery reload comes back editable. + applyEditingState() } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift index 6308ae6..4297382 100644 --- a/Sources/Panels/MarkdownWebSupport.swift +++ b/Sources/Panels/MarkdownWebSupport.swift @@ -191,6 +191,9 @@ final class MarkdownWebView: CmuxUndoableWebView { /// out of the window (e.g. a pane drag re-parented the hosting views). var onReenterWindow: (() -> Void)? + /// Invoked when the panel's save shortcut is pressed while this view is + /// the first responder (the in-place editor has no NSTextView to own it). + var onSaveShortcut: (() -> Void)? /// The coordinator is the only owner of deferred WebKit lifecycle work. private var renderingCoordinator: MarkdownWebRenderingCoordinator? private var editableFocusStateConfirmed = false @@ -273,7 +276,14 @@ final class MarkdownWebView: CmuxUndoableWebView { } override func performKeyEquivalent(with event: NSEvent) -> Bool { - handleViewerNavigationKey(event) || super.performKeyEquivalent(with: event) + if let onSaveShortcut { + let shortcut = KeyboardShortcutSettings.shortcut(for: .saveFilePreview) + if !shortcut.isUnbound, shortcut.matches(event: event) { + onSaveShortcut() + return true + } + } + return handleViewerNavigationKey(event) || super.performKeyEquivalent(with: event) } override func keyDown(with event: NSEvent) { @@ -471,6 +481,16 @@ final class MarkdownRendererSession { func readSurfaceSelection(filePath: String) async -> SurfaceSelectionReadResult { await ownedCoordinator.readSurfaceSelection(filePath: filePath) } + + /// Commits an in-progress block edit in the page before the panel switches + /// away from the editor mode, so switching to raw text never drops work. + func commitActiveBlockEdit() { + guard let webView = ownedCoordinator.webView else { return } + webView.evaluateJavaScript( + "window.CmuxMarkdownEditor && window.CmuxMarkdownEditor.closeActiveBlock(true);", + completionHandler: nil + ) + } } extension NSColor { diff --git a/Sources/TabManager+BrowserFocus.swift b/Sources/TabManager+BrowserFocus.swift index 9bb09b5..d8296a8 100644 --- a/Sources/TabManager+BrowserFocus.swift +++ b/Sources/TabManager+BrowserFocus.swift @@ -50,7 +50,7 @@ extension TabManager { guard let tab = selectedWorkspace, let panelId = tab.focusedPanelId, let panel = tab.panels[panelId] as? MarkdownPanel, - panel.displayMode == .preview else { return nil } + panel.displayMode == .preview || panel.displayMode == .editor else { return nil } return panel } diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift index 0ca03cb..446b07d 100644 --- a/cmuxTests/MarkdownPanelTests.swift +++ b/cmuxTests/MarkdownPanelTests.swift @@ -415,6 +415,64 @@ final class MarkdownPanelTests: XCTestCase { XCTAssertFalse(panel.isDirty) } + func testMarkdownEditorModeAcceptsCommittedBlockText() async throws { + let fileManager = FileManager.default + let directoryURL = fileManager.temporaryDirectory + .appendingPathComponent("cmux-markdown-editor-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directoryURL) } + + let fileURL = directoryURL.appendingPathComponent("notes.md") + let originalContent = "# Title\n\nFirst paragraph.\n" + try originalContent.write(to: fileURL, atomically: true, encoding: .utf8) + + let panel = MarkdownPanel(workspaceId: UUID(), filePath: fileURL.path) + defer { panel.close() } + if let initialLoad = panel.loadTextContent() { + await initialLoad.value + } + + XCTAssertEqual(panel.displayMode, .preview) + XCTAssertFalse(panel.isDirty) + + panel.setDisplayMode(.editor) + XCTAssertEqual(panel.displayMode, .editor) + + // A committed block replaces that block's bytes and marks the panel dirty. + let edited = "# Title\n\nFirst paragraph, edited.\n" + XCTAssertTrue(panel.editorDidCommitText(edited)) + XCTAssertEqual(panel.textContent, edited) + XCTAssertEqual(panel.content, edited) + XCTAssertTrue(panel.isDirty) + + // Committing the same text again is a no-op, not a second dirtying edit. + XCTAssertFalse(panel.editorDidCommitText(edited)) + XCTAssertTrue(panel.isDirty) + } + + func testMarkdownEditorLockStatePersistsAcrossPanels() throws { + let saved = UserDefaults.standard.object(forKey: "markdown.editor.locked") + defer { + if let saved { + UserDefaults.standard.set(saved, forKey: "markdown.editor.locked") + } else { + UserDefaults.standard.removeObject(forKey: "markdown.editor.locked") + } + } + + MarkdownEditorLockSettings.setLocked(false) + let unlockedPanel = MarkdownPanel(workspaceId: UUID(), filePath: "/tmp/does-not-matter.md") + defer { unlockedPanel.close() } + XCTAssertFalse(unlockedPanel.isEditorLocked) + + unlockedPanel.setEditorLocked(true) + XCTAssertTrue(unlockedPanel.isEditorLocked) + + let nextPanel = MarkdownPanel(workspaceId: UUID(), filePath: "/tmp/does-not-matter.md") + defer { nextPanel.close() } + XCTAssertTrue(nextPanel.isEditorLocked, "the next markdown editor opens in the last used lock state") + } + func testMarkdownRendererSessionReusesCoordinatorAcrossViewRecreation() { let session = MarkdownRendererSession() let panelId = UUID()