Skip to content

feat(desktop): wire @muyajs/core parity APIs (menu state, copyAsRich, heading-link, source cursor, saved indicator) - #4415

Merged
Jocs merged 10 commits into
developfrom
feat/desktop-parity-wave2
Jun 8, 2026
Merged

Jocs merged 10 commits into
developfrom
feat/desktop-parity-wave2

Conversation

@Jocs

@Jocs Jocs commented Jun 8, 2026

Copy link
Copy Markdown
Member

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

Gap What this PR wires Status
PG1 adaptSelectionChange consumes the new affiliation chain + 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. ✅ fixed
PG2 New engine helper Muya#setCursorByOffset + desktop handleFileChange maps the saved CodeMirror {line, ch} muyaIndexCursor onto a block-key cursor, restoring the WYSIWYG caret after a source-mode edit. ✅ fixed (engine helper added)
PG8 util/pdf.ts::getHtmlToc swaps the legacy muyajs Slugger for @muyajs/core's generateGithubSlug, replicating the engine's whole-document -N dedup (#4412) so exported-TOC anchors match the injected heading ids. ✅ fixed
PG9 COPY_PASTE_METHOD_MAP.copyAsRich → the real Muya#copyAsRich (engine #4411). "Copy as Rich Text" now yields formatted rich text, not HTML source. ✅ fixed
PG10 preview-image subscription already present; engine now emits it (#4414). Verified, no change. ✅ fixed
PG11 Re-added editor.on('heading-copy-link', ({ key }) => editorStore.copyGithubSlug(key)) (engine emits it, #4414). ✅ fixed
PG15 makeSyntheticHistory id is now the engine undo-stack depth (a stable position marker, not an ever-incrementing counter), and a freshly-loaded tab seeds lastSavedHistoryId to the baseline depth (0). Undo-to-disk restores the saved/clean indicator. ✅ fixed
PG14 First undo after source mode reverting the bulk edit in one step. Accept-deferred — see below. ⏸️ deferred

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 commit feat(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 (both setContent calls run synchronously, so no intermediate paint). Covered by setCursorByOffset.spec.ts (5 PG2: cases).

PG14 — accept-defer rationale

On source-mode exit, handleFileChange rebuilds the doc via setContent (which history.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-document ot-json1 diff and feeding it through Editor.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 left test.fail() with explanatory notes in handleFileChange and the spec. A clean revival needs a dedicated engine "record a state replacement as one op" API.

Tests

  • Flipped test.fail() → real on PG1 (parity-pg1-menu-state.spec.ts), PG2 + PG15 (parity-source-undo-saved.spec.ts); all genuinely pass.
  • PG14 stays test.fail().
  • Added packages/muya/src/__tests__/setCursorByOffset.spec.ts.
  • Reconciled packages/desktop/test/PARITY_SCOREBOARD.md to the true state (gaps remaining 11 → 1).

Verification

pnpm typecheck (only the pre-existing electron.vite.config.ts vite/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

Jocs and others added 9 commits June 9, 2026 02:03
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]>
Copilot AI review requested due to automatic review settings June 8, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 368 to +372
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1311 to +1322
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'
)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/muya/src/muya.ts
Comment on lines +722 to +733
/**
* 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.
*/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

github-actions Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Build artifacts for PR #4415:

Run: https://github.com/marktext/marktext/actions/runs/27159879433

Artifact Size Link
marktext-windows-arm64 256.2 MB Download
marktext-windows-x64 257.5 MB Download
marktext-linux 556.4 MB Download
marktext-macos-x64 256.8 MB Download
marktext-macos-arm64 246.6 MB Download

- 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]>
@Jocs
Jocs merged commit 0736252 into develop Jun 8, 2026
22 checks passed
@Jocs
Jocs deleted the feat/desktop-parity-wave2 branch June 10, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants