Repository navigation
feat(desktop): wire @muyajs/core parity APIs (menu state, copyAsRich, heading-link, source cursor, saved indicator) - #4415
Conversation
The legacy "Copy as Rich Text" command was remapped to copyAsHtml, which blanks text/html and puts the HTML source into text/plain, so pasting into Word/email yielded raw HTML markup as literal text. #4411 added a real Muya.copyAsRich() that writes rendered HTML to text/html and plain text to text/plain; point COPY_PASTE_METHOD_MAP.copyAsRich at it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
#4414 made the engine attach a hover-to-copy affordance to every heading and emit heading-copy-link { key } (key == the heading's stable slug) on click. Re-subscribe in editor.vue and forward the key to editorStore.copyGithubSlug, which copies `#<githubSlug>` to the clipboard — restoring the heading-anchor copy affordance that was a documented gap after the @muyajs/core migration. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
editor.vue now exports via @muyajs/core (#4406), and #4412 injects github-compatible heading ids onto exported headings (deduped in document order with a `-N` suffix). getHtmlToc still slugged via the legacy muyajs Slugger, so `href="#slug"` targets no longer matched the injected ids and TOC / [TOC] links were dead. Swap to @muyajs/core's generateGithubSlug and replicate the engine's whole-document `-N` dedup so the anchors resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
adaptSelectionChange hardcoded affiliation:[] and copied changes.type
('Caret'/'Range') into start.type/end.type, so createApplicationMenuState's
`start.type === 'span'` and functionType guards never fired — the native
Paragraph-menu check marks, loose/task-list toggles, table/code-fence
detection, and Format-disable-in-code all went dead after the @muyajs/core
migration.
#4410 added an `affiliation` chain (outermost-first) plus per-endpoint
`anchorBlockInfo`/`focusBlockInfo` (`type: 'span'` + `functionType`) to the
selection-change payload. Map them onto the legacy shape:
- start/end `.type` and `.block.functionType` from the leaf block info,
- affiliation passed through, with a derived `functionType` surfaced on
`pre`/`figure` containers so table / code-fence detection lights up.
Also fix the consumer's loose-list read: the engine affiliation entry carries
`isLooseListItem` on the list block directly, not via a `children` chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
makeSyntheticHistory minted an ever-incrementing editSeq id on every json-change (including undo/redo), so after edit-then-undo-to-saved the id never matched lastSavedHistoryId and the tab stayed marked dirty even when its content matched disk. Derive the synthetic id from the engine undo-stack DEPTH instead — a stable position marker that returns to its saved value when an edit is undone back to the baseline. Seed lastSavedHistoryId to 0 (the engine's post-setContent baseline depth, since setContent clears history) so a freshly-loaded, never-saved document clears its dirty indicator when undone back to disk content, mirroring the legacy history-index behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The source-code -> WYSIWYG handoff carries only a CodeMirror {line, ch} index
cursor; @muyajs/core had no index-offset -> block-path conversion, so the
WYSIWYG caret was lost. Add Muya#setCursorByOffset, reproducing the legacy
muyajs approach: inject sentinel strings into the current markdown at the
line/ch offsets, rebuild the tree (sentinels embed as literal text), find the
content blocks they landed in, then rebuild the clean document and set the
cursor by the resolved block paths + offsets. Both setContent calls run
synchronously so no intermediate paint occurs, and the method is a no-op for
stale/unresolvable cursors.
Engine helper only — desktop wiring lands in a separate commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
handleFileChange dropped the saved muyaIndexCursor on the source-code ->
WYSIWYG handoff, so the caret was lost. Consume the new engine
Muya#setCursorByOffset: when the tab has no key-based cursor but carries a
CodeMirror {line, ch} index cursor, map it onto a block-key cursor so the
caret lands where the source-mode cursor was. Restore the per-tab engine
history afterwards (setCursorByOffset re-runs setContent internally, which
clears history).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Remove the `test.fail()` markers on the PG1 (Paragraph-menu check mark), PG2 (source-mode caret restore), and PG15 (saved indicator on undo-to-disk) parity e2e tests now that the desktop wiring lands — all three genuinely pass. PG14 (first undo after source mode reverts the bulk edit in one step) stays `test.fail()`: recording the source-mode change as a single undo boundary needs a general whole-document json1 diff through Editor.updateContents' pick/drop walker, which only handles specific op shapes and would risk corrupting the document. Deferred with an explanatory note here and in handleFileChange. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Update the Status column to the true post-merge state: all seven Wave-1 engine PRs (#4408-#4414) plus the Wave-2 desktop wiring close PG1-PG13 and PG15. Fix the "Gaps remaining" count (11 -> 1), add the PG2 setCursorByOffset engine spec, and document why PG14 is accept-deferred (single-undo-boundary across the source-mode handoff needs a whole-document json1 diff the op walker can't safely apply). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR completes the “wave-2” desktop-side wiring for remaining @muyajs/core parity gaps after the engine migration, including restoring menu state fidelity, source→WYSIWYG cursor restoration, rich-text copy, PDF TOC slug parity, and saved/dirty indicator correctness, while updating the parity scoreboard and e2e expectations (leaving PG14 as an accepted xfail).
Changes:
- Added engine support for restoring a WYSIWYG cursor from a source-mode
{line, ch}cursor via sentinel injection + path resolution (setCursorByOffset) and covered it with unit tests. - Updated desktop editor integration to consume new engine selection metadata (affiliation + endpoint block info), wire
copyAsRich, restore cursor after source edits, and stabilize save/dirty tracking using undo-stack depth. - Aligned PDF TOC slugging with engine heading-id generation and updated parity docs/tests to reflect closed gaps (PG14 remains deferred).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/muya/src/selection/offsetCursor.ts | Implements sentinel-based {line,ch} → {path,offset} cursor resolution helpers. |
| packages/muya/src/muya.ts | Adds Muya#setCursorByOffset public API to restore caret from source-mode offsets. |
| packages/muya/src/tests/setCursorByOffset.spec.ts | Adds unit coverage for setCursorByOffset and resolver helpers. |
| packages/desktop/test/PARITY_SCOREBOARD.md | Updates parity scoreboard narrative/status (PG14 remains xfail). |
| packages/desktop/test/e2e/parity-source-undo-saved.spec.ts | Flips PG2/PG15 to passing; documents PG14 accept-defer rationale. |
| packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts | Flips PG1 menu-state test to passing with new selection payload wiring. |
| packages/desktop/src/renderer/src/util/pdf.ts | Replaces legacy Slugger with generateGithubSlug + whole-doc dedup to match engine export ids. |
| packages/desktop/src/renderer/src/store/help.ts | Seeds lastSavedHistoryId to baseline depth (0) for correct saved/dirty behavior. |
| packages/desktop/src/renderer/src/store/editor.ts | Adjusts loose-list detection to match new affiliation payload shape. |
| packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue | Wires selection affiliation/endpoint info, copyAsRich, cursor restore on source-mode exit, and stable synthetic history ids. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| start: { | ||
| key: anchorPath.join('/'), | ||
| offset: (changes.anchor?.offset ?? 0) as number, | ||
| block: anchorBlock, | ||
| type: changes.type as string | undefined | ||
| block: { functionType: anchorInfo?.functionType }, | ||
| type: anchorInfo?.type |
There was a problem hiding this comment.
Good catch — fixed in 5fe91bd. adaptSelectionChange now carries block.text from the live anchorBlock/focusBlock again, so SELECTION_CHANGE's start.block.text.substring(...) (search prefill) keeps working. Verified by find-replace + selection-change e2e.
| const isIndexCursor = ( | ||
| cursor: unknown | ||
| ): cursor is { anchor: { line: number; ch: number }; focus: { line: number; ch: number } } => { | ||
| const c = cursor as { anchor?: { line?: unknown }; focus?: { line?: unknown } } | null | ||
| return ( | ||
| !!c && | ||
| !!c.anchor && | ||
| !!c.focus && | ||
| typeof c.anchor.line === 'number' && | ||
| typeof c.focus.line === 'number' | ||
| ) | ||
| } |
There was a problem hiding this comment.
Fixed in 5fe91bd. Factored out isIndexPosition and isIndexCursor now requires both line AND ch to be numbers, so a missing/invalid ch no longer falls through to the column-0 clamp.
| /** | ||
| * Restore the WYSIWYG caret from a source-mode (CodeMirror) `{ line, ch }` | ||
| * index cursor (PG2 parity). The block tree has no source-line mapping, so | ||
| * the offsets are resolved the way legacy muyajs did: inject sentinel | ||
| * strings into the current markdown at the line/ch positions, rebuild the | ||
| * tree (sentinels embed as literal text), find which content blocks they | ||
| * landed in, then rebuild the clean document and set the cursor by the | ||
| * resolved block paths + offsets. The sentinel-bearing tree is transient — | ||
| * both `setContent` calls run synchronously within this task, so no | ||
| * intermediate paint happens. No-op (returns `false`) when the cursor is | ||
| * stale / unresolvable, letting the caller fall back to its default. | ||
| */ |
There was a problem hiding this comment.
Fixed in 5fe91bd. setCursorByOffset now snapshots getHistory() and restores it after the internal setContent rebuild, so it is a caret-only operation that preserves the undo stack. Documented in the JSDoc and covered by a new history-preservation test.
|
Build artifacts for PR #4415: Run: https://github.com/marktext/marktext/actions/runs/27159879433
|
- editor.vue adaptSelectionChange: restore start/end block.text from the live
anchorBlock/focusBlock so SELECTION_CHANGE can still slice the selected text
(search prefill); the previous {functionType}-only block dropped it.
- editor.vue isIndexCursor: validate both line AND ch are numbers (factored out
isIndexPosition) so a missing ch no longer silently clamps to column 0.
- muya setCursorByOffset: snapshot getHistory() and restore it after the
internal setContent rebuild so the public API is caret-only and does not clear
the undo stack; document the behaviour and cover it with a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Summary
Final wave of the muyajs → @muyajs/core parity follow-ups to #4406. The seven Wave-1 engine PRs (#4408–#4414) landed the engine halves; this PR wires the desktop consumers and flips the remaining desktop scoreboard tests. 14 of the 15 confirmed gaps are now closed; PG14 is accept-deferred.
Per-gap outcome
adaptSelectionChangeconsumes the newaffiliationchain +anchorBlockInfo/focusBlockInfo(engine #4410); fixes the consumer's loose-list read. Native Paragraph/Format menu check-marks, loose/task-list toggles, table/code detection, and Format-disable-in-code work again.Muya#setCursorByOffset+ desktophandleFileChangemaps the saved CodeMirror{line, ch}muyaIndexCursoronto a block-key cursor, restoring the WYSIWYG caret after a source-mode edit.util/pdf.ts::getHtmlTocswaps the legacy muyajsSluggerfor@muyajs/core'sgenerateGithubSlug, replicating the engine's whole-document-Ndedup (#4412) so exported-TOC anchors match the injected heading ids.COPY_PASTE_METHOD_MAP.copyAsRich→ the realMuya#copyAsRich(engine #4411). "Copy as Rich Text" now yields formatted rich text, not HTML source.preview-imagesubscription already present; engine now emits it (#4414). Verified, no change.editor.on('heading-copy-link', ({ key }) => editorStore.copyGithubSlug(key))(engine emits it, #4414).makeSyntheticHistoryid is now the engine undo-stack depth (a stable position marker, not an ever-incrementing counter), and a freshly-loaded tab seedslastSavedHistoryIdto the baseline depth (0). Undo-to-disk restores the saved/clean indicator.PG3/PG12 (#4409), PG4 (#4413), PG5/PG6 (#4411), PG7 (#4412), PG13 (#4408) are engine-only and needed no desktop change.
Engine helper added
packages/muya/src/selection/offsetCursor.ts+Muya#setCursorByOffset(indexCursor)(separate commitfeat(muya): add setCursorByOffset…). It reproduces the legacy muyajs index→block-key conversion: inject sentinel strings into the current markdown at the line/ch offsets, rebuild the tree, find the content blocks they landed in, then rebuild the clean document and set the cursor by the resolved paths + offsets (bothsetContentcalls run synchronously, so no intermediate paint). Covered bysetCursorByOffset.spec.ts(5PG2:cases).PG14 — accept-defer rationale
On source-mode exit,
handleFileChangerebuilds the doc viasetContent(whichhistory.clear()s) then restores the pre-source op stack, so the bulk change is not a single undo boundary. Recording it as one boundary would require computing a general whole-documentot-json1diff and feeding it throughEditor.updateContents' pick/drop walker, which only handles a fixed set of op shapes (block insert at index, text edit,checked/meta); an arbitrary diff (removes/moves/nested replaces) risks corrupting the document. The risk outweighs the benefit (narrow first-undo-granularity edge case; undo still works, prior stack intact), so PG14 is lefttest.fail()with explanatory notes inhandleFileChangeand the spec. A clean revival needs a dedicated engine "record a state replacement as one op" API.Tests
test.fail()→ real on PG1 (parity-pg1-menu-state.spec.ts), PG2 + PG15 (parity-source-undo-saved.spec.ts); all genuinely pass.test.fail().packages/muya/src/__tests__/setCursorByOffset.spec.ts.packages/desktop/test/PARITY_SCOREBOARD.mdto the true state (gaps remaining 11 → 1).Verification
pnpm typecheck(only the pre-existingelectron.vite.config.tsvite/rolldown typing error remains),pnpm lint(0 errors),pnpm test(560 pass),pnpm build:unpack(clean), the parity e2e suite (4 pass incl. PG14 xfail) + core e2e (launch/editor-input/tabs/selection-change/inline-format/paragraph-blocks, 36 pass), and the full muya engine gauntlet (lint/types/circular/553 unit/1347 spec — all green).Parity follow-up to #4406.
🤖 Generated with Claude Code