Skip to content

feat(muya): expose block affiliation in selection-change (PG1) - #4410

Merged
Jocs merged 2 commits into
developfrom
feat/muya-selection-affiliation
Jun 8, 2026
Merged

Jocs merged 2 commits into
developfrom
feat/muya-selection-affiliation

Conversation

@Jocs

@Jocs Jocs commented Jun 8, 2026

Copy link
Copy Markdown
Member

What

Parity follow-up to #4406 (@muyajs/core migration). Flips parity scoreboard gap PG1 from the failing it.fails scoreboard (#4407) to real passing tests.

The engine selection-change payload only carried flat caret/range info (anchor, focus, anchorBlock, anchorPath, …, type, formats). The desktop application-menu state builder (createApplicationMenuState) was written against muyajs's richer selectionChange shape and needs:

  • an ancestor affiliation chain to light up the Paragraph-menu check marks and the Loose/Task-list toggles, and
  • per-endpoint block type + functionType to set isCodeFences / isCodeContent / isTable and disable the Format menu inside code.

The new engine exposes blockName (codeblock.content, bullet-list, …), not muyajs's markdown type / functionType, so none of that downstream state could be reconstructed — Paragraph check marks never lit, Loose-list was always disabled, and the Format menu was never disabled in code.

How

ENGINE-ONLY, in packages/muya. A focused selection/affiliation.ts helper re-derives the legacy block-context from the @muyajs/core block tree, and three fields are added to the selection-change payload (all existing fields untouched, new ones JSDoc'd):

  • affiliation — shared ancestor PARAGRAPH-type chain (outermost-first), each entry: { type, blockName, listType?, listItemType?, isLooseListItem? } where type is the markdown block type (p, h1…h6, ul, ol, li, pre, figure, blockquote). Cross-block selections are trimmed to the shared ancestor block instances (parity with muyajs's startParents.filter(p => endParents.includes(p))).
  • anchorBlockInfo / focusBlockInfo — per-endpoint content-leaf info { blockName, type, functionType? } where type is always span and functionType is codeContent / cellContent / languageInput / paragraphContent.

Tests

The two PG1 it.fails scoreboard tests are now real it() tests, plus two new tests pin the per-endpoint code-content info and the list affiliation context. Verified locally: lint (0 errors), lint:types, check-circular, test (516 pass / 18 expected-fail remaining for other PGs), test:spec (1347 pass). lint:css has a pre-existing no-descending-specificity error on develop (no CSS touched here).

Desktop adapter is wave 2

The desktop editor.vue adaptSelectionChange is intentionally not touched here. A separate wave-2 desktop PR will consume the new fields: map affiliation → createApplicationMenuState's affiliation, and anchorBlockInfo / focusBlockInfo → start.type / start.block.functionType (and the same for end).

🤖 Generated with Claude Code

The `selection-change` payload only carried flat caret/range info, so the
desktop Paragraph/Format menu state builder (`createApplicationMenuState`)
could not reconstruct block context: no ancestor `affiliation` chain, and
the engine blocks expose `blockName` rather than muyajs's `type` /
`functionType`. As a result the Paragraph-menu check marks, Loose/Task-list
toggles, table / code-fence detection and the in-code Format-menu disable
were all dead.

Re-derive the legacy `selectionChange` block-context from the @muyajs/core
block tree in a focused `selection/affiliation.ts` helper and add three
fields to the `selection-change` payload (existing fields untouched):

- `affiliation` — shared ancestor PARAGRAPH-type chain (outermost-first),
  each entry carrying the markdown `type` (`p`, `h1`…`h6`, `ul`, `ol`, `li`,
  `pre`, `figure`, `blockquote`) plus list context (`listType`,
  `listItemType`, `isLooseListItem`).
- `anchorBlockInfo` / `focusBlockInfo` — per-endpoint content-leaf info:
  `type` (always `span`) + `functionType` (`codeContent`, `cellContent`,
  `languageInput`, `paragraphContent`).

Flips parity scoreboard PG1: the two `it.fails` PG1 tests are now real
passing tests, plus two new tests pin the per-endpoint code-content info
and list affiliation context. Desktop adapter consumption is wave 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings June 8, 2026 17:28

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 closes parity gap PG1 by extending the muya engine’s selection-change event payload with legacy-compatible block-context information (ancestor affiliation chain + per-endpoint leaf info), enabling downstream desktop menu-state reconstruction in a follow-up PR.

Changes:

  • Adds affiliation, anchorBlockInfo, and focusBlockInfo to the selection-change payload.
  • Introduces a new selection/affiliation.ts helper to derive legacy-like affiliation/endpoint metadata from the @muyajs/core block tree.
  • Flips PG1 parity tests from it.fails to passing it() and adds targeted assertions for the new fields.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/muya/src/selection/index.ts Emits new selection payload fields (affiliation, anchorBlockInfo, focusBlockInfo).
packages/muya/src/selection/affiliation.ts Derives legacy-style affiliation chain + per-endpoint content-leaf info from the block tree.
packages/muya/src/selection/tests/paritySelectionChange.spec.ts Updates PG1 tests to assert the new payload shape and behaviors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +125 to +129
// Lists carry `meta.loose`; a loose list item is one inside a loose list.
const meta = (block as Parent & { meta?: { loose?: boolean } }).meta;

return Boolean(meta?.loose);
}

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 — verified: list-item / task-list-item carry no meta, so meta.loose was always undefined and isLooseListItem was stuck at false for li entries. Fixed in d8cbbc4: li entries now read meta.loose from their enclosing list block (_parentListOf), and a new test asserts isLooseListItem === true on both the ul and li entries of a loose list.

Comment on lines +134 to +140
const listType = LIST_TYPE_BY_NAME[block.blockName];
if (listType) {
if (type === 'li')
entry.listItemType = listType;
else
entry.listType = listType;
}

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.

Correct — both bullet and ordered lists share the list-item block, so mapping list-item → bullet misclassified ordered-list items. Fixed in d8cbbc4: an li's listItemType is now derived from its parent list block (bullet-list/order-list/task-list → bullet/order/task), with a new test asserting an ordered item reports listItemType === 'order'.

…(PG1)

Address Copilot review on #4410:

- `isLooseListItem` was read from a list-item block's own `meta.loose`, but
  `list-item` / `task-list-item` carry no `meta` — the loose/tight flag lives
  on the parent list (`bullet-list` / `order-list` / `task-list`). It was
  therefore always `false` for `li` entries, so the desktop "Loose list item"
  state could never enable.
- `listItemType` for an `li` was mapped from the item's own `blockName`
  (`list-item` → `bullet`), but both bullet and ordered lists use the same
  `list-item` block, so ordered-list items were misclassified as `bullet`.

Walk from a list-item up to its enclosing list block and read both the
discriminator (`bullet` | `order` | `task`) and `meta.loose` from there. Add
tests pinning ordered-list-item classification and loose-list detection on
both the list and item entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@Jocs
Jocs merged commit a437b79 into develop Jun 8, 2026
11 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
Jocs deleted the feat/muya-selection-affiliation 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