Repository navigation
feat(muya): emit preview-image + heading-copy-link events (PG10/PG11) - #4414
Conversation
Legacy muyajs dispatched `preview-image` { data: src } from keyboard.js
when an image was selected and the user pressed Space; the desktop
renderer opened the full-screen SimpleImageViewer. @muyajs/core never
emitted it — the image-selected keydown handler only acted on
Backspace/Delete/Enter, so Space fell through to native handling
(inserting a literal space) and the desktop subscription was dead.
Restore the emit: when an image is selected and Space is pressed, the
selection keydown handler resolves the image src the same way the
Cmd/Ctrl-click preview path does (token src via getImageSrc, falling
back to the rendered <img>'s src) and emits `preview-image` { data: src },
calling preventDefault so no space is inserted.
Extracts the image keydown handler and the preview resolution into
private methods to keep _listenSelectActions under the max-lines gate.
Flips the PG10 parity scoreboard specs (it.fails -> it).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Legacy muyajs rendered a hover affordance (i.icon.ag-copy-header-link)
on each heading and dispatched `heading-copy-link` { key } on click; the
desktop renderer copied the heading's GitHub slug/anchor to the
clipboard (copyGithubSlug, which looks the key up against listToc by
slug). @muyajs/core rendered no such affordance and never emitted the
event, leaving copyGithubSlug unreachable.
Restore both:
- New HeadingCopyLink attachment block (mu-copy-header-link), appended to
every atx/setext heading via appendAttachment (same mechanism as the
task-list checkbox). It carries no document state, so markdown/HTML
round-trip is unaffected. On click it emits
`heading-copy-link` { key } and stops propagation.
- The emitted key is the heading's stable slug — the SAME value getTOC()
exposes as ITocItem.slug — so the host can resolve it back to a TOC
entry. stableSlug is exported from state/getTOC for reuse (no new
circular dep: getTOC only type-imports blocks).
- Engine-level hover-reveal CSS in blockSyntax.css: the affordance sits
in the left gutter, hidden by default and revealed on heading hover.
Flips the PG11 parity scoreboard specs (it.fails -> it).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
b88adb8 to
2d5932f
Compare
There was a problem hiding this comment.
Pull request overview
This PR restores two legacy engine-emitted interaction events in @muyajs/core to close parity gaps after the muyajs → muya migration: PG10 (preview-image) and PG11 (heading-copy-link). It adds engine-side affordances/handlers so existing (or upcoming) desktop subscriptions can work again without changing document serialization.
Changes:
- Emit
preview-image { data: src }when an image is selected and the user presses Space, resolving the image source similarly to the existing Cmd/Ctrl-click path. - Add a heading “copy link” hover affordance via a new attachment block on headings and emit
heading-copy-link { key }using the heading’s stable slug (getTOC().slug). - Flip parity tests for PG10/PG11 from expected-fail to passing and add styling/constants needed for the new affordance.
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/state/getTOC.ts | Exports stableSlug so other engine modules can emit heading keys consistent with getTOC(). |
| packages/muya/src/selection/index.ts | Adds Space-handling for selected images and emits preview-image with resolved src. |
| packages/muya/src/selection/tests/parityPreviewImage.spec.ts | Flips PG10 parity specs from it.fails to it. |
| packages/muya/src/config/index.ts | Adds MU_COPY_HEADER_LINK class name constant. |
| packages/muya/src/block/index.ts | Registers the new heading-copy-link attachment block. |
| packages/muya/src/block/commonMark/setextHeading/index.ts | Appends the copy-link attachment to Setext headings. |
| packages/muya/src/block/commonMark/headingCopyLink/index.ts | Implements the new heading copy-link attachment block and event emit. |
| packages/muya/src/block/commonMark/atxHeading/index.ts | Appends the copy-link attachment to ATX headings. |
| packages/muya/src/assets/styles/blockSyntax.css | Adds hover-reveal + positioning styles for the heading copy-link affordance. |
| packages/muya/src/tests/parityHeadingCopyLink.spec.ts | Flips PG11 parity specs from it.fails to it. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { key } = event; | ||
| const { selectedImage } = this; | ||
| if (!selectedImage) | ||
| return; | ||
|
|
There was a problem hiding this comment.
Good catch to raise, but a focus-in-editor gate would break the feature: selecting an image intentionally blurs the contenteditable (activeContentBlock = null + cleared range), so document.activeElement becomes <body> — gating on muya.domNode.contains(activeElement) would make Space-preview (and the pre-existing Delete/Backspace-on-image) never fire. I verified this in happy-dom (activeElement is BODY after image select). The actual gate is selectedImage, which is ONLY set by an in-editor image click (_handleClickInlineImage) and cleared on ANY document click (docHandlerClick) plus after each delete/preview here — so the handler is inert unless an image is actively selected in this editor, matching the long-standing legacy muyajs scope. Clarified with a comment in 5dd614f.
| this.tagName = 'i'; | ||
| this.classList = ['mu-icon', CLASS_NAMES.MU_COPY_HEADER_LINK]; | ||
| this.attributes = { contenteditable: 'false' }; | ||
| this.createDomNode(); |
There was a problem hiding this comment.
Addressed in 5dd614f. The affordance is now a proper button: role="button", tabindex="0", and an i18n aria-label/title. The decorative icon <img> also gets alt="" — this resolves the critical: image-alt violation that was failing the muya e2e a11y host scan. Verified via axe-core (image-alt rule) over a booted heading.
| const clickHandler = (event: Event) => { | ||
| // The handler is bound to a `click` DOM event on the affordance, so | ||
| // it is inherently a pointer interaction — no mouse-specific | ||
| // properties are read, so no `MouseEvent` narrowing is needed. | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
|
|
||
| // At click time the attachment's parent is the heading block. | ||
| const heading = this.parent; | ||
| if (!heading) | ||
| return; | ||
|
|
||
| eventCenter.emit('heading-copy-link', { | ||
| key: stableSlug(heading), | ||
| }); | ||
| }; | ||
|
|
||
| this._eventIds.push( | ||
| eventCenter.attachDOMEvent(domNode!, 'click', clickHandler), | ||
| ); |
There was a problem hiding this comment.
Addressed in 5dd614f. Added a keydown handler on the affordance that activates on Enter/Space (preventDefault + stopPropagation) and emits the same heading-copy-link as click, via a shared _activate(). Covered by new PG11 specs (it.each(['Enter', ' '])).
Addresses the e2e a11y scan failure (critical image-alt) and Copilot review on the heading copy-link affordance: - The decorative icon <img> now carries alt="" — resolves the axe-core `image-alt` critical violation that failed the muya e2e a11y host scan. - The affordance is now a proper button: role="button", tabindex="0", and an i18n aria-label/title, so it is discoverable and focusable by assistive tech. - Keyboard activation (Enter / Space) mirrors click, so the focusable control is operable without a pointer. - Clarify (no behavior change) that the document-level image keydown handler is gated solely by `selectedImage`, which is only ever set by an in-editor image click and cleared on any document click — matching the legacy muyajs scope. A focus-in-editor gate was rejected because a selected image intentionally blurs the contenteditable (activeElement becomes <body>), so it would defeat the Space-preview feature. Adds PG11 spec coverage for the button semantics + Enter/Space activation. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… heading-link, source cursor, saved indicator) (#4415) * fix(desktop): map copyAsRich to the engine copyAsRich method (PG9) 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]> * feat(desktop): wire heading-copy-link to copyGithubSlug (PG11) #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]> * fix(desktop): match exported-TOC anchors to engine heading ids (PG8) 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]> * fix(desktop): consume selection-change affiliation for menu state (PG1) 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]> * fix(desktop): restore saved indicator on undo-to-disk (PG15) 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]> * feat(muya): add setCursorByOffset for source-mode cursor restore (PG2) 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]> * fix(desktop): restore WYSIWYG caret after source-mode edit (PG2) 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]> * test(desktop): flip parity e2e for PG1/PG2/PG15, defer PG14 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]> * docs(test): reconcile parity scoreboard after wave-2 (14/15 fixed) 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]> * fix: address Copilot review on parity wave-2 - 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]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…cale key (#4424) * fix(muya): guard I18n.t against a missing key in a non-en locale t() fell through to `resources.en[key]`, but `resources.en` is undefined whenever the loaded locale is not named `en` (the constructor stores only `{ [name]: resource }`). A key missing from the current locale therefore threw `Cannot read properties of undefined` instead of falling back. Use optional chaining so a missing key is always safe. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(muya): add 'Copy anchor link to this heading' to all locales The HeadingCopyLink affordance (PG11, #4414) used this i18n key but it was never added to the locale resources, so typing `#` to create a heading crashed in a non-en locale (and showed the raw key in en). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(muya): optional-chain resources in the en fallback too (review) Keeps I18n.t fully crash-safe and consistent with the primary lookup if `resources` is ever unset. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Parity follow-up to #4406 (muyajs → @muyajs/core migration). Flips the parity scoreboard (#4407) gaps PG10 (
preview-image) and PG11 (heading-copy-link) fromit.failsto passing.Both gaps were interaction events that the legacy
packages/muyajsengine emitted but the newpackages/muyaengine never did, leaving dead desktop subscriptions. This restores the engine-side emit/affordance. Engine-only — no desktop changes; desktop re-subscribesheading-copy-linkin wave 2.PG10 —
preview-imageon Space for a selected imagepreview-image { data: src }→ desktop opensSimpleImageViewer.selection/index.tsnow emitspreview-imagewith payload{ data: src }when an image is selected and Space is pressed (preventDefaultstops the space insert). The src is resolved exactly like the surviving Cmd/Ctrl-click preview path (token src viagetImageSrc, falling back to the rendered<img>'ssrc).PG11 — heading hover-copy affordance +
heading-copy-linki.icon.ag-copy-header-linkhover affordance; clicking it dispatchedheading-copy-link { key }and the desktop copied the heading's GitHub slug/anchor (copyGithubSlug).copyGithubSlugwas unreachable.HeadingCopyLinkattachment block (classmu-copy-header-link) appended to every atx/setext heading viaappendAttachment(same pattern as the task-list checkbox). It carries no document state, so markdown/HTML round-trip is unchanged. On click it emitsheading-copy-linkwith payload{ key }.keyis the heading's stable slug — the same valuegetTOC()exposes asITocItem.slug— so the host can resolve it back to a TOC entry.stableSlugis now exported fromstate/getTOC(no new circular dep).blockSyntax.css(left-gutter affordance, hidden until heading hover).Event contract (for wave-2 desktop wiring)
preview-image{ data: src }(resolved, loadable URL)heading-copy-link{ key }(heading stable slug ==getTOC().slug)preview-imagealready has a live (previously dead) desktop subscription — it starts working once this lands.heading-copy-linkneeds the wave-2 desktop subscription that callseditorStore.copyGithubSlug(key).Verification
parityPreviewImage.spec.ts(PG10 ×2) andparityHeadingCopyLink.spec.ts(PG11 ×2): flippedit.fails→it, all pass.pnpm -C packages/muya lint(0 errors),lint:types,check-circular(no new cycles),test(516 pass),test:spec(1347 pass),build— all green.🤖 Generated with Claude Code