Skip to content

feat(muya): emit preview-image + heading-copy-link events (PG10/PG11) - #4414

Merged
Jocs merged 3 commits into
developfrom
feat/muya-emit-preview-heading-events
Jun 8, 2026
Merged

Jocs merged 3 commits into
developfrom
feat/muya-emit-preview-heading-events

Conversation

@Jocs

@Jocs Jocs commented Jun 8, 2026

Copy link
Copy Markdown
Member

Parity follow-up to #4406 (muyajs → @muyajs/core migration). Flips the parity scoreboard (#4407) gaps PG10 (preview-image) and PG11 (heading-copy-link) from it.fails to passing.

Both gaps were interaction events that the legacy packages/muyajs engine emitted but the new packages/muya engine never did, leaving dead desktop subscriptions. This restores the engine-side emit/affordance. Engine-only — no desktop changes; desktop re-subscribes heading-copy-link in wave 2.

PG10 — preview-image on Space for a selected image

  • Legacy: select an image + press Space → preview-image { data: src } → desktop opens SimpleImageViewer.
  • New engine: the image-selected keydown handler only acted on Backspace/Delete/Enter; Space fell through and inserted a literal space, and the desktop subscription was dead.
  • Fix: selection/index.ts now emits preview-image with payload { data: src } when an image is selected and Space is pressed (preventDefault stops the space insert). The src is resolved exactly like the surviving Cmd/Ctrl-click preview path (token src via getImageSrc, falling back to the rendered <img>'s src).

PG11 — heading hover-copy affordance + heading-copy-link

  • Legacy: each heading rendered an i.icon.ag-copy-header-link hover affordance; clicking it dispatched heading-copy-link { key } and the desktop copied the heading's GitHub slug/anchor (copyGithubSlug).
  • New engine: no affordance, no event; copyGithubSlug was unreachable.
  • Fix: new HeadingCopyLink attachment block (class mu-copy-header-link) appended to every atx/setext heading via appendAttachment (same pattern as the task-list checkbox). It carries no document state, so markdown/HTML round-trip is unchanged. On click it emits heading-copy-link with payload { key }.
    • The 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 now exported from state/getTOC (no new circular dep).
    • Engine-level hover-reveal CSS added to blockSyntax.css (left-gutter affordance, hidden until heading hover).

Event contract (for wave-2 desktop wiring)

event payload trigger
preview-image { data: src } (resolved, loadable URL) image selected + Space
heading-copy-link { key } (heading stable slug == getTOC().slug) click the heading copy affordance

preview-image already has a live (previously dead) desktop subscription — it starts working once this lands. heading-copy-link needs the wave-2 desktop subscription that calls editorStore.copyGithubSlug(key).

Verification

  • parityPreviewImage.spec.ts (PG10 ×2) and parityHeadingCopyLink.spec.ts (PG11 ×2): flipped it.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

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]>
Copilot AI review requested due to automatic review settings June 8, 2026 17:36
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]>
@Jocs
Jocs force-pushed the feat/muya-emit-preview-heading-events branch from b88adb8 to 2d5932f Compare June 8, 2026 17:38

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 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.

Comment on lines +529 to +533
const { key } = event;
const { selectedImage } = this;
if (!selectedImage)
return;

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 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.

Comment on lines +38 to +41
this.tagName = 'i';
this.classList = ['mu-icon', CLASS_NAMES.MU_COPY_HEADER_LINK];
this.attributes = { contenteditable: 'false' };
this.createDomNode();

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.

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.

Comment on lines +55 to +74
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),
);

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.

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]>
@Jocs
Jocs merged commit 0d6d28d into develop Jun 8, 2026
13 checks passed
Jocs added a commit that referenced this pull request Jun 8, 2026
… 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]>
Jocs added a commit that referenced this pull request Jun 9, 2026
…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]>
@Jocs
Jocs deleted the feat/muya-emit-preview-heading-events branch June 10, 2026 07:06
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