From d2f0028efd1fc2d15442042ba28c7be770c20362 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Tue, 9 Jun 2026 01:14:12 +0800 Subject: [PATCH 1/9] =?UTF-8?q?test(parity):=20failing-test=20scoreboard?= =?UTF-8?q?=20for=20#4406=20muyajs=E2=86=92@muyajs/core=20gaps=20(#4407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(parity): muya engine xfail tests for #4406 gaps PG1,3,5,6,7,8,9,10,11,12,13 Encode 10 of the 15 confirmed muyajs→@muyajs/core functional-parity gaps as vitest `it.fails` regression tests in the engine. Each asserts the desired pre-migration behaviour and fails on develop today (counted as a pass by `it.fails`); when a fix lands the test passes and `it.fails` errors, forcing the fixer to drop the marker. Every test name is prefixed with its gap id (`PGn:`) so fix PRs can grep + flip it. - PG1 selection-change lacks block affiliation / ancestor type - PG3 autoCheck preference not consumed (task-list cascade lost) - PG5 binary/bitmap clipboard image paste not persisted via imageAction - PG6 pasted image FILE bypasses imageAction (insert preference ignored) - PG7 export links core CSS from CDN instead of inlining it - PG8 exported headings carry no slug id (dead TOC anchors) - PG9 copyAsRich writes HTML source not rich text (no copyAsRich path) - PG10 preview-image not emitted (Space on a selected image) - PG11 heading-copy-link not emitted (hover-to-copy affordance gone) - PG12 hideLinkPopup not consumed (link hover popover not gated) - PG13 insertParagraph anchors to outermost not immediate nested block Allow the uppercase `PG` test-title prefix through antfu's `test/prefer-lowercase-title` rule (scoped to spec files) so the greppable gap-id naming coexists with the lint rule; all other test titles still must start lowercase. Suite stays GREEN: 512 pass + 20 expected-fail. Co-Authored-By: Claude Opus 4.8 (1M context) * test(parity): desktop e2e xfail tests for #4406 gaps PG1,2,14,15 Add Playwright `test.fail()` regression specs for the desktop-side parity gaps. Each runs headless and currently fails (the gap), which `test.fail()` counts as a pass; removing `test.fail()` flips the entry green once fixed. Test names are prefixed with the gap id (`PGn:`) for grep-and-flip. - PG1 Paragraph menu does not check the current block type (affiliation gap; caret placed directly in an H1 content span — the engine-unit half lives in paritySelectionChange.spec.ts) - PG2 WYSIWYG caret not restored after a source-mode edit (handleFileChange drops muyaIndexCursor; no index→path cursor conversion in the engine) - PG14 first undo after exiting source mode does not revert the bulk source-mode change as one step - PG15 undo back to on-disk content leaves the tab marked unsaved (synthetic history id never re-matches the saved id) Undo is driven through the same `mt::editor-edit-action` IPC the Edit › Undo menu uses. Suite stays GREEN (4 expected-fail). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(parity): scoreboard index + manual-QA checklist for #4406 gaps Add the visible "how many gaps remain" board (PARITY_SCOREBOARD.md): a table of all 15 muyajs→@muyajs/core parity gaps → severity → test location(s) → status (all xfail now), plus the flip-to-green workflow for fix PRs. Add PARITY_QA.md: precise manual-QA checklists (exact steps + expected vs current result) for the two gaps that cannot be driven headless — PG4 drag-drop image insertion and the OS-clipboard/screenshot half of PG5 binary image paste. Co-Authored-By: Claude Opus 4.8 (1M context) * test(parity): destroy() muya instances in afterEach to detach DOM listeners Address Copilot review: the parity engine specs' cleanup only removed the host DOM node, never calling `muya.destroy()`, so the `document`-level keydown/click listeners selection registers during init leaked across tests — a flake / order- dependence risk as the suite grows. Track booted Muya instances and `destroy()` each in afterEach (detaches all DOM events + removes the node). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/desktop/test/PARITY_QA.md | 83 +++++++++ packages/desktop/test/PARITY_SCOREBOARD.md | 82 ++++++++ .../test/e2e/parity-pg1-menu-state.spec.ts | 87 +++++++++ .../test/e2e/parity-source-undo-saved.spec.ts | 126 +++++++++++++ packages/muya/eslint.config.mjs | 6 + .../__tests__/parityHeadingCopyLink.spec.ts | 92 +++++++++ .../parityInsertParagraphNested.spec.ts | 130 +++++++++++++ .../__tests__/parityAutoCheck.spec.ts | 141 ++++++++++++++ .../__tests__/parityCopyAsRich.spec.ts | 97 ++++++++++ .../__tests__/parityImagePaste.spec.ts | 176 ++++++++++++++++++ .../__tests__/parityHideLinkPopup.spec.ts | 103 ++++++++++ .../__tests__/parityPreviewImage.spec.ts | 129 +++++++++++++ .../__tests__/paritySelectionChange.spec.ts | 104 +++++++++++ .../state/__tests__/parityExportHtml.spec.ts | 84 +++++++++ 14 files changed, 1440 insertions(+) create mode 100644 packages/desktop/test/PARITY_QA.md create mode 100644 packages/desktop/test/PARITY_SCOREBOARD.md create mode 100644 packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts create mode 100644 packages/desktop/test/e2e/parity-source-undo-saved.spec.ts create mode 100644 packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts create mode 100644 packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts create mode 100644 packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts create mode 100644 packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts create mode 100644 packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts create mode 100644 packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts create mode 100644 packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts create mode 100644 packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts create mode 100644 packages/muya/src/state/__tests__/parityExportHtml.spec.ts diff --git a/packages/desktop/test/PARITY_QA.md b/packages/desktop/test/PARITY_QA.md new file mode 100644 index 0000000000..f1008985fd --- /dev/null +++ b/packages/desktop/test/PARITY_QA.md @@ -0,0 +1,83 @@ +# Parity manual-QA checklist (muyajs → @muyajs/core) + +Some parity gaps from the desktop migration to `@muyajs/core` (PR #4406) cannot +be exercised reliably in a headless / xvfb CI run — they need a real OS +clipboard with bitmap data, a real drag-and-drop gesture, or the native +screenshot tool. They are tracked here as precise manual checklists instead of +automated tests. + +Run these on a packaged or `pnpm run dev` build. Each entry maps to a row in +[`PARITY_SCOREBOARD.md`](./PARITY_SCOREBOARD.md). When a fix lands, perform the +steps; the entry passes when the **Expected (after fix)** result is observed. + +> Every entry currently FAILS on `develop` (the gap is present). That is the +> point of the scoreboard — these are the regressions the fix PRs must close. + +--- + +## PG4 — Drag-and-drop image insertion (local file + web link) + +**Why manual:** drag-and-drop needs a real `DataTransfer` with `files` / +`text/uri-list` and a genuine drop gesture over the editor; Playwright/Electron +cannot synthesize an OS-level file drop into the contenteditable reliably. + +### Steps — local image file +1. Open a document (ideally a saved `.md` so assets-folder behaviour applies). +2. From the OS file manager, drag a `.png` / `.jpg` file over the editor body + and drop it inside a paragraph. + +**Expected (after fix):** a loading placeholder appears, then an inline image +renders. With `Preferences → Image → insert action = "copy to folder"` the file +is copied into the document's assets folder and the link points there (not the +original absolute path). + +**Current (gap):** nothing is inserted — the drop is a no-op. + +### Steps — web-link image +1. In a browser, drag an image (or its URL) over the editor and drop it. + +**Expected (after fix):** `![]()` is inserted and the image renders. + +**Current (gap):** nothing is inserted. + +--- + +## PG5 — Binary/bitmap clipboard image paste (screenshot, browser "Copy Image") + +**Why manual:** this needs a real bitmap on the OS clipboard (no file path). The +engine-unit half — that a synthetic `clipboardData.files` PNG is persisted via +`imageAction` — is covered in +`packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (PG5). The full +OS-clipboard + macOS `screencapture` integration can only be verified by hand. + +### Steps — browser "Copy Image" +1. In a browser, right-click an image → **Copy Image** (puts a bitmap, not a + file path, on the clipboard). +2. Focus the editor and paste (Cmd/Ctrl+V). + +**Expected (after fix):** the bitmap is inserted as an inline image and +persisted per the insert-action preference. + +**Current (gap):** nothing is inserted. + +### Steps — macOS screenshot integration +1. macOS only. Trigger the in-app screenshot capture (Function/menu that runs + `screencapture -i -c`), select a region. +2. The captured bitmap lands on the clipboard and the app auto-pastes it. + +**Expected (after fix):** the screenshot is inserted as an inline image. + +**Current (gap):** nothing is inserted — the screenshot-and-insert feature is +silently dead. + +--- + +## Notes for fixers + +- After closing PG4 / PG5, consider adding a Playwright spec that drives the + engine paste/drop handler with a synthetic `DataTransfer` where the platform + allows it, and keep this manual entry only for the OS-integration parts that + remain un-automatable. +- PG5 already has an engine-unit regression test; closing the engine half flips + that `it.fails` to passing. This manual entry covers the desktop OS-clipboard + delivery the unit test cannot reach. diff --git a/packages/desktop/test/PARITY_SCOREBOARD.md b/packages/desktop/test/PARITY_SCOREBOARD.md new file mode 100644 index 0000000000..d4f1ec050b --- /dev/null +++ b/packages/desktop/test/PARITY_SCOREBOARD.md @@ -0,0 +1,82 @@ +# Parity scoreboard — muyajs → @muyajs/core (PR #4406 follow-ups) + +This is a **failing-test scoreboard**. The desktop app migrated from the legacy +`packages/muyajs` engine to `@muyajs/core` (`packages/muya`) in PR #4406. That +migration left **15 confirmed functional-parity gaps**. This board encodes each +one as a regression test that **fails on `develop` today** (proving the gap) but +is marked as an *expected failure* so the test suites stay GREEN. + +## How it works + +- **muya engine unit tests** (`packages/muya/src/**/__tests__/parity*.spec.ts`) + use vitest `it.fails(...)`: the assertion describes the correct + (pre-migration) behaviour and fails today, which vitest counts as a *pass*. + When a fix lands and the behaviour becomes correct, the test starts passing + and `it.fails` then **errors** — forcing the fixer to delete `.fails`. +- **desktop e2e tests** (`packages/desktop/test/e2e/parity-*.spec.ts`) use + Playwright `test.fail()`: the test runs headless and currently fails, which + Playwright counts as a *pass*. When the fix lands, remove `test.fail()`. +- **manual-QA** entries (`packages/desktop/test/PARITY_QA.md`) cover gaps that + cannot be driven headless (real OS clipboard bitmaps, drag-and-drop gestures). + +**Every test name starts with its gap id** (`PG3: …`) so a fix PR can +`grep -rn "PG3:"` to find and flip its entry. + +## Flipping a gap to green (for fix PRs) + +1. Implement the fix. +2. `grep -rn "PGn:"` to locate the test(s). +3. Remove the `it.fails` → `it` (muya) or delete `test.fail()` (desktop e2e), + or run + check off the manual-QA entry. +4. Confirm the test now PASSES, update the **Status** column here to ✅. + +## Scoreboard + +> **Gaps remaining: 15 / 15.** All entries are xfail (failing as expected). + +| Gap | Severity | Behaviour lost | Test location(s) | Mechanism | Status | +|-----|----------|----------------|------------------|-----------|--------| +| **PG1** | major | `selection-change` lacks block affiliation / ancestor type → native Paragraph & Format menu state is dead | `packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts` (`PG1:` ×2) · `packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts` (`PG1:`) | `it.fails` + `test.fail()` | ❌ xfail | +| **PG2** | major | source-mode → WYSIWYG caret not restored (`handleFileChange` drops `muyaIndexCursor`) | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG2:`) | `test.fail()` | ❌ xfail | +| **PG3** | major | `autoCheck` preference not consumed (task-list checkbox cascade lost) | `packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts` (`PG3:` ×2) | `it.fails` | ❌ xfail | +| **PG4** | major | drag-drop image insertion (local file + web link) absent | `packages/desktop/test/PARITY_QA.md` § PG4 | manual-QA | ❌ xfail | +| **PG5** | major | binary/bitmap clipboard image paste lost (screenshot, browser "Copy Image") | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG5:`) · `packages/desktop/test/PARITY_QA.md` § PG5 | `it.fails` + manual-QA | ❌ xfail | +| **PG6** | major | pasted image FILE bypasses `imageAction` (copy-to-assets / upload preference ignored) | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG6:` ×2) | `it.fails` | ❌ xfail | +| **PG7** | major | export loads core CSS from CDN instead of inlining it (unstyled offline) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG7:` ×2) | `it.fails` | ❌ xfail | +| **PG8** | major | exported headings carry no `id` (dead TOC / `[TOC]` anchors) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG8:` ×2) | `it.fails` | ❌ xfail | +| **PG9** | major | "Copy as Rich Text" pastes HTML *source* not rich text (no `copyAsRich` path) | `packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts` (`PG9:` ×2) | `it.fails` | ❌ xfail | +| **PG10** | minor | `preview-image` never emitted — select-image + Space full-screen preview lost | `packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts` (`PG10:` ×2) | `it.fails` | ❌ xfail | +| **PG11** | minor | `heading-copy-link` never emitted — hover-to-copy-anchor affordance gone | `packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts` (`PG11:` ×2) | `it.fails` | ❌ xfail | +| **PG12** | minor | `hideLinkPopup` preference not consumed — link hover popover not gated | `packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts` (`PG12:`) | `it.fails` (+ control) | ❌ xfail | +| **PG13** | minor | `insertParagraph` anchors to outermost not immediate block in nested structures | `packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts` (`PG13:` ×2) | `it.fails` | ❌ xfail | +| **PG14** | minor | first undo after source-mode doesn't revert the edit as one step | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG14:`) | `test.fail()` | ❌ xfail | +| **PG15** | minor | undo back to on-disk content doesn't restore the saved/clean indicator | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG15:`) | `test.fail()` | ❌ xfail | + +### Severity tally + +- **major:** PG1, PG2, PG3, PG4, PG5, PG6, PG7, PG8, PG9 (9) +- **minor:** PG10, PG11, PG12, PG13, PG14, PG15 (6) + +## Running the suites + +```bash +# muya engine xfail tests (suite must stay GREEN: it.fails entries count as pass) +pnpm -C packages/muya test + +# a single gap's engine tests +pnpm -C packages/muya exec vitest run src/state/__tests__/parityExportHtml.spec.ts + +# desktop parity e2e (needs `pnpm run build:unpack` first; suite stays GREEN) +pnpm -C packages/desktop exec playwright test \ + test/e2e/parity-pg1-menu-state.spec.ts \ + test/e2e/parity-source-undo-saved.spec.ts \ + --config test/e2e/playwright.config.ts +``` + +## Provenance + +Gap analysis: the adversarially-verified `d2-parity-review` of PR #4406 +(`PG01..PG16` + `PG-COPYRICH`). After de-duplication there are 15 distinct +gaps (the legacy "Space preview" and "insert-paragraph anchor" gaps each +appeared twice; `copyAsRich` is counted as one of the 15). The `PGn` numbering +on this board is the canonical 1–15 list, not the raw review ids. diff --git a/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts b/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts new file mode 100644 index 0000000000..a722d90ecc --- /dev/null +++ b/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchWithMarkdown, setSourceMarkdown, waitForMenuReady } from './helpers' + +// PARITY SCOREBOARD — gap PG1 (file PG01), desktop e2e half. +// +// The engine-unit half lives in +// packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts. +// +// Legacy muyajs `selectionChange` carried the ancestor block `affiliation` +// chain + block markdown types, which `createApplicationMenuState` +// (store/editor.ts) turned into Paragraph-menu check marks. With @muyajs/core +// the `selection-change` payload has no affiliation chain, so the affiliation +// map the store builds stays empty and the Paragraph-menu check marks never +// light up. Here we read the live application-menu `checked` state after +// placing the caret in a heading. +// +// This RUNS but currently fails (the menu item never gets checked), so it is +// marked `test.fail()`. When the engine restores affiliation and the store +// lights the check mark, remove the `test.fail()`. + +const headingMenuChecked = async(app: ElectronApplication, id: string): Promise => { + return await app.evaluate(({ Menu }, menuId) => { + const menu = Menu.getApplicationMenu() + if (!menu) return false + const item = menu.getMenuItemById(menuId) + return !!(item && item.checked) + }, id) +} + +// A heading renders `span.mu-atxheading-content`, not `mu-paragraph-content`, +// so the paragraph-only `placeCaretInEditor` helper would not place the caret +// here. Collapse the selection inside the heading's content span and nudge the +// engine to recompute its active block (same keyup trick the helper uses). +const placeCaretInHeading = async(page: Page): Promise => { + const ok = await page.evaluate(() => { + const root = document.querySelector('.editor-component') as HTMLElement | null + if (!root) return false + const span = root.querySelector('h1 span.mu-content') as HTMLElement | null + if (!span) return false + root.focus() + const range = document.createRange() + range.selectNodeContents(span) + range.collapse(false) + const sel = window.getSelection() + if (!sel) return false + sel.removeAllRanges() + sel.addRange(range) + document.dispatchEvent(new Event('selectionchange')) + root.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true, cancelable: true })) + return true + }) + await page.waitForTimeout(150) + return ok +} + +test.describe('Parity PG1 — Paragraph menu reflects the current block', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('seed\n') + app = launched.app + page = launched.page + await waitForMenuReady(app) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test.fail() + test('PG1: placing the caret in an H1 checks heading1MenuItem in the Paragraph menu', async() => { + await setSourceMarkdown(page, app, '# A heading\n') + // Sanity: the heading rendered and the caret landed inside it (so a `false` + // result below is the affiliation gap, not a missed caret placement). + await expect(page.locator('.editor-component h1')).toBeVisible() + const placed = await placeCaretInHeading(page) + expect(placed).toBe(true) + // Give the selection-change → menu-state IPC round-trip time to settle. + await page.waitForTimeout(400) + + const checked = await headingMenuChecked(app, 'heading1MenuItem') + // Desired: the Paragraph menu shows H1 as the active block type. + expect(checked).toBe(true) + }) +}) diff --git a/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts new file mode 100644 index 0000000000..25cae7ae66 --- /dev/null +++ b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test' +import { + launchWithMarkdown, + waitForMenuReady, + enterSourceMode, + exitSourceMode, + setSourceMarkdown, + sendIpcToRenderer, + getMarkdownContent, + typeIntoEditor, + placeCaretInEditor +} from './helpers' + +// PARITY SCOREBOARD — desktop gaps PG2 (file PG02), PG14 (file PG15), +// PG15 (file PG16). Each RUNS headless but currently fails, so each is marked +// `test.fail()`. When the corresponding fix lands, remove the `test.fail()`. + +// Trigger an editor undo through the same IPC channel the Edit › Undo menu item +// uses (`mt::editor-edit-action` → bus `undo` → editor.undo()). More reliable +// than synthesizing the Cmd/Ctrl+Z keystroke against the contenteditable. +const undo = async(app: Parameters[0]): Promise => { + await sendIpcToRenderer(app, 'mt::editor-edit-action', 'undo') +} + +test.describe('Parity PG2 — WYSIWYG caret restored after a source-mode edit', () => { + // handleFileChange drops `muyaIndexCursor`/`blocks` and the engine has no + // index→path cursor conversion, so the source-mode editing position is lost + // on the handoff back to WYSIWYG and no meaningful caret is restored. + test.fail() + test('PG2: the caret lands in the block the source-mode cursor was on', async() => { + const { app, page } = await launchWithMarkdown( + 'first para\n\nsecond para\n\nthird para here\n' + ) + await waitForMenuReady(app) + + await enterSourceMode(page, app) + await page.evaluate(() => { + const cm = ( + document.querySelector('.source-code .CodeMirror') as Element & { + CodeMirror: { setCursor(p: { line: number; ch: number }): void; focus(): void } + } + ).CodeMirror + // Line 4 = "third para here"; place the source cursor inside it. + cm.setCursor({ line: 4, ch: 6 }) + cm.focus() + }) + await page.waitForTimeout(200) + await exitSourceMode(page, app) + await page.waitForTimeout(500) + + const enclosingText = await page.evaluate(() => { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return '' + let node: Node | null = sel.getRangeAt(0).startContainer + while (node && node !== document.body) { + if (node instanceof HTMLElement && node.matches('p, h1, h2, h3, li')) { + return node.textContent || '' + } + node = node.parentNode + } + return '' + }) + + // Desired: the caret is restored into the "third para here" block. + expect(enclosingText).toContain('third para') + await app.close() + }) +}) + +test.describe('Parity PG14 — first undo after source mode reverts the edit in one step', () => { + // On source-mode exit the engine rebuilds the document via setContent (which + // does NOT record an undo op) then restores the pre-source op stack, so the + // bulk source-mode change is not a single undo boundary. + test.fail() + test('PG14: one undo after exiting source mode reverts the source-mode change', async() => { + const { app, page } = await launchWithMarkdown('base\n') + await waitForMenuReady(app) + + // Bulk source-mode edit. + await setSourceMarkdown(page, app, 'base\n\nSOURCE ADDED LINE\n') + await page.waitForTimeout(500) + expect((await getMarkdownContent(page, app)).trim()).toContain('SOURCE ADDED LINE') + + // First undo after the source-mode handoff. + await undo(app) + await page.waitForTimeout(600) + + // Desired: the document reverts to the exact pre-source-mode content in a + // single undo step. + expect((await getMarkdownContent(page, app)).trim()).toBe('base') + await app.close() + }) +}) + +test.describe('Parity PG15 — undo back to on-disk content restores the saved indicator', () => { + // The desktop feeds the store a synthetic history whose id is regenerated on + // every json-change (including undo), so the saved-id comparison never + // matches again and the tab stays marked dirty even when content == disk. + test.fail() + test('PG15: undoing an edit back to disk content clears the unsaved indicator', async() => { + const { app, page } = await launchWithMarkdown('hello world\n') + await waitForMenuReady(app) + + await placeCaretInEditor(page) + await typeIntoEditor(page, ' EXTRA') + await page.waitForTimeout(500) + + // Sanity: the edit dirtied the tab and changed the content. + expect(await page.evaluate(() => !!document.querySelector('.editor-tabs li.unsaved'))).toBe(true) + expect((await getMarkdownContent(page, app)).trim()).toContain('EXTRA') + + // Undo back to the on-disk content. + await undo(app) + await page.waitForTimeout(600) + // Content is restored to disk... + expect((await getMarkdownContent(page, app)).trim()).toBe('hello world') + + // Desired: ...and the saved/clean indicator comes back (tab no longer + // marked unsaved). Today the tab stays dirty. + const stillUnsaved = await page.evaluate( + () => !!document.querySelector('.editor-tabs li.unsaved') + ) + expect(stillUnsaved).toBe(false) + await app.close() + }) +}) diff --git a/packages/muya/eslint.config.mjs b/packages/muya/eslint.config.mjs index aaad306f4c..0ac8fbdca8 100644 --- a/packages/muya/eslint.config.mjs +++ b/packages/muya/eslint.config.mjs @@ -56,6 +56,11 @@ function typescriptPreset() { // classes (`fake as unknown as Table`); policing the double-cast pattern // there adds noise without safety. Disable only `no-restricted-syntax` — // `ts/no-explicit-any` and `ts/naming-convention` stay on for tests. +// +// The parity-scoreboard specs name every test after its gap id +// (`PG3: …`) so fix PRs can grep + flip the `it.fails` marker. Allow that +// uppercase `PG` prefix through `prefer-lowercase-title`; all other test +// titles still have to start lowercase. function testFileDoubleCastOverride() { return { files: [ @@ -66,6 +71,7 @@ function testFileDoubleCastOverride() { ], rules: { 'no-restricted-syntax': 'off', + 'test/prefer-lowercase-title': ['error', { allowedPrefixes: ['PG'] }], }, }; } diff --git a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts new file mode 100644 index 0000000000..9b5cb82bf2 --- /dev/null +++ b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../muya'; + +// PARITY SCOREBOARD — gap PG11 (file PG10, "heading-copy-link"). +// +// Legacy `packages/muyajs` rendered a hover affordance +// (`i.icon.ag-copy-header-link`) on each heading and dispatched +// `heading-copy-link` { key } when clicked; the desktop renderer copied the +// heading's GitHub slug/anchor to the clipboard (`copyGithubSlug`). +// +// `@muyajs/core` renders no copy-anchor affordance on headings and never emits +// `heading-copy-link`; the desktop subscription was removed and documented as +// a gap. `copyGithubSlug` is now unreachable dead code. +// +// This asserts the DESIRED hover-copy affordance + emit and is expected to +// FAIL today (the affordance element isn't rendered, so the event can't fire). +// When the engine restores the affordance + emit, drop the `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level handlers registered during init — and removes the host + // node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// The legacy affordance class was `ag-copy-header-link`; the rewrite would use +// the `mu-` prefix. Match either so this test survives the exact class choice. +const COPY_LINK_SELECTOR + = '.ag-copy-header-link, .mu-copy-header-link, [class*="copy-header-link"]'; + +describe('parity PG11: heading hover-to-copy-anchor affordance', () => { + it.fails( + 'PG11: a heading renders a copy-link affordance', + () => { + const muya = bootMuya('# Getting Started\n'); + const affordance = muya.domNode.querySelector(COPY_LINK_SELECTOR); + + // Desired: the heading exposes a hover-copy-anchor affordance. + expect(affordance).toBeTruthy(); + }, + ); + + it.fails( + 'PG11: activating the heading copy affordance emits heading-copy-link with the block key', + () => { + const muya = bootMuya('# Getting Started\n'); + + const handler = vi.fn(); + muya.on('heading-copy-link', handler); + + const affordance = muya.domNode.querySelector(COPY_LINK_SELECTOR); + // The affordance must exist to drive the click; its absence is the + // gap. Guard so the assertion below fails with a clear message + // rather than a null-deref. + affordance?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + + // Desired: clicking the affordance emits heading-copy-link carrying + // the heading's block key so the host can copy its anchor/slug. + expect(handler).toHaveBeenCalledTimes(1); + const payload = handler.mock.calls[0]?.[0]; + expect(payload?.key).toBeTruthy(); + }, + ); +}); diff --git a/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts new file mode 100644 index 0000000000..2a9b626bec --- /dev/null +++ b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts @@ -0,0 +1,130 @@ +// @vitest-environment happy-dom + +import type Content from '../block/base/content'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../muya'; + +// PARITY SCOREBOARD — gap PG13 (file PG11/PG13, "insert-paragraph anchor"). +// +// Legacy `packages/muyajs` `insertParagraph(location, text, outMost=false)` +// chose the insertion anchor via `getAnchor(block)` (the IMMEDIATE enclosing +// block) for the context-menu / Paragraph-menu Insert-Paragraph path, and only +// used `findOutMostBlock` for the explicit "Create Paragraph Below" action. So +// inserting a paragraph while the cursor sat inside a list item / blockquote +// landed the new paragraph as an inner sibling, INSIDE the structure. +// +// `@muyajs/core`'s `insertParagraph(location, text)` ALWAYS resolves the target +// via `_outmostBlockAtCursor()` → `outMostBlock` (the OUTERMOST container). So +// in a nested list/blockquote the new paragraph lands AFTER the entire outer +// block (at document root) instead of as an inner sibling. +// +// This asserts the DESIRED immediate-anchor behaviour and is expected to FAIL +// today. When the engine restores the immediate-anchor path, drop the `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level handlers registered during init — and removes the host + // node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// Find the leaf content block whose text matches `text` and place the cursor on +// it (the way a click sets `activeContentBlock`). +function placeCursorOn(muya: Muya, text: string): Content { + let target: Content | null = null; + const visit = (block: { text?: string; constructor: { blockName?: string }; children?: { forEach: (cb: (b: unknown) => void) => void } }) => { + if ( + (block.constructor as { blockName?: string }).blockName?.endsWith('.content') + && block.text === text + ) { + target = block as unknown as Content; + } + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + if (!target) + throw new Error(`content block with text "${text}" not found`); + muya.editor.activeContentBlock = target; + return target; +} + +interface StateNode { name: string; text?: string; children?: StateNode[] } + +// Does any block at the TOP level of the document carry this text directly? +function topLevelHasParagraph(muya: Muya, text: string): boolean { + return (muya.getState() as unknown as StateNode[]).some( + node => node.name === 'paragraph' && node.text === text, + ); +} + +// Is the text present anywhere in the (nested) document state? +function existsAnywhere(muya: Muya, text: string): boolean { + return JSON.stringify(muya.getState()).includes(text); +} + +describe('parity PG13: insertParagraph anchors to the immediate block in nested structures', () => { + it.fails( + 'PG13: inserting after a nested list item keeps the new paragraph inside the list, not at document root', + async () => { + const muya = bootMuya('- outer\n\n - inner1\n - inner2\n'); + placeCursorOn(muya, 'inner1'); + + muya.insertParagraph('after', 'INNERSIBLING'); + + await vi.waitFor(() => { + expect(existsAnywhere(muya, 'INNERSIBLING')).toBe(true); + }); + + // Desired: the new paragraph is an INNER sibling — the top-level + // block count is unchanged (still just the one bullet-list) and the + // paragraph is NOT a root-level sibling. Today it lands at root. + expect(muya.getState().length).toBe(1); + expect(topLevelHasParagraph(muya, 'INNERSIBLING')).toBe(false); + }, + ); + + it.fails( + 'PG13: inserting after a paragraph inside a blockquote stays inside the blockquote', + async () => { + const muya = bootMuya('> quoted line\n'); + placeCursorOn(muya, 'quoted line'); + + muya.insertParagraph('after', 'QUOTESIBLING'); + + await vi.waitFor(() => { + expect(existsAnywhere(muya, 'QUOTESIBLING')).toBe(true); + }); + + // Desired: still a single top-level block (the blockquote) with the + // new paragraph nested inside it. Today it lands after the quote at + // document root. + expect(muya.getState().length).toBe(1); + expect(muya.getState()[0].name).toBe('block-quote'); + expect(topLevelHasParagraph(muya, 'QUOTESIBLING')).toBe(false); + }, + ); +}); diff --git a/packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts b/packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts new file mode 100644 index 0000000000..b93b3b2dc8 --- /dev/null +++ b/packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts @@ -0,0 +1,141 @@ +// @vitest-environment happy-dom + +import type TaskListItem from '../../taskListItem'; +import type TaskListCheckbox from '../index'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../../muya'; + +// PARITY SCOREBOARD — gap PG3 (file PG03). +// +// Legacy `packages/muyajs` read `muya.options.autoCheck` in +// `clickCtrl.js#listItemCheckBoxClick`: toggling a task-list checkbox with +// `autoCheck` on cascaded the state to all descendant checkboxes +// (`updateChildrenCheckBoxState`) and re-derived ancestors +// (`updateParentsCheckBoxState`). +// +// `@muyajs/core` still accepts `autoCheck` (construction + setOptions) but +// NOTHING reads it — `grep autoCheck packages/muya/src` finds only the type +// decl + default. The checkbox click handler +// (`block/gfm/taskListCheckbox/index.ts`) toggles only the clicked item via +// `update(checked, 'user')`, so enabling `autoCheck` has no cascade effect. +// +// We drive the checkbox's `update(checked, 'user')` directly — that is exactly +// what the DOM click handler invokes — and assert the DESIRED cascade. These +// are expected to FAIL today. When the engine consumes `autoCheck`, drop the +// `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level handlers registered during init — and removes the host + // node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string, options: Record = {}): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { + markdown, + ...options, + } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// A parent task item with two nested children, all unchecked. State shape: +// task-list > task-list-item(meta.checked) > [ paragraph, task-list > ... ] +const NESTED_TASKS = '- [ ] parent\n\n - [ ] child1\n - [ ] child2\n'; + +// Collect every task-list-item block in document order [parent, child1, child2]. +function taskItems(muya: Muya): TaskListItem[] { + const items: TaskListItem[] = []; + const visit = (block: { constructor: { blockName?: string }; children?: { forEach: (cb: (b: unknown) => void) => void } }) => { + if ((block.constructor as { blockName?: string }).blockName === 'task-list-item') + items.push(block as unknown as TaskListItem); + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + return items; +} + +// The checkbox is attached to its task-list-item via `appendAttachment`. +function checkboxOf(item: TaskListItem): TaskListCheckbox { + let found: TaskListCheckbox | null = null; + (item.attachments as unknown as { forEach: (cb: (a: TaskListCheckbox) => void) => void }).forEach((a) => { + if ((a.constructor as { blockName?: string }).blockName === 'task-list-checkbox') + found = a; + }); + if (!found) + throw new Error('task-list-checkbox attachment not found'); + return found; +} + +// Read the checked flags off the document state, in order +// [parent, child1, child2]. +function checkedFlags(muya: Muya): boolean[] { + const flags: boolean[] = []; + const walk = (nodes: ReturnType) => { + for (const node of nodes) { + const n = node as { name: string; meta?: { checked?: boolean }; children?: unknown }; + if (n.name === 'task-list-item') + flags.push(!!n.meta?.checked); + if (Array.isArray(n.children)) + walk(n.children as ReturnType); + } + }; + walk(muya.getState()); + return flags; +} + +describe('parity PG3: autoCheck task-list cascade', () => { + it.fails( + 'PG3: autoCheck cascades the toggle to descendant task items', + async () => { + const muya = bootMuya(NESTED_TASKS, { autoCheck: true }); + const [parent] = taskItems(muya); + expect(checkedFlags(muya)).toEqual([false, false, false]); + + // Equivalent to clicking the parent checkbox (the real DOM handler + // calls `update(checked, 'user')`). + checkboxOf(parent).update(true, 'user'); + + // Desired: checking the parent cascades to both children. + await vi.waitFor(() => { + expect(checkedFlags(muya)).toEqual([true, true, true]); + }); + }, + ); + + it.fails( + 'PG3: autoCheck re-derives the parent when all descendants become checked', + async () => { + const muya = bootMuya(NESTED_TASKS, { autoCheck: true }); + const [, child1, child2] = taskItems(muya); + expect(checkedFlags(muya)).toEqual([false, false, false]); + + checkboxOf(child1).update(true, 'user'); + checkboxOf(child2).update(true, 'user'); + + // Desired: when every child is checked, the parent becomes checked. + await vi.waitFor(() => { + expect(checkedFlags(muya)).toEqual([true, true, true]); + }); + }, + ); +}); diff --git a/packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts b/packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts new file mode 100644 index 0000000000..086b5cb31c --- /dev/null +++ b/packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts @@ -0,0 +1,97 @@ +import type { Muya } from '../../muya'; +import { describe, expect, it, vi } from 'vitest'; + +// PARITY SCOREBOARD — gap PG9 (file PG-COPYRICH). +// +// Legacy `packages/muyajs` "Copy as Rich Text" put the rendered HTML into +// `text/html` (so pasting into Word / email yields formatted rich text) plus +// the markdown source into `text/plain`. +// +// `@muyajs/core` exposes no `copyAsRich` path. The desktop renderer maps +// `copyAsRich` → `copyAsHtml`, whose `copyHandler` branch does +// `setData('text/html', '')` + `setData('text/plain', html)` — pasting yields +// the raw HTML markup as literal text, NOT rich text. The engine's `normal` +// copyType already does the rich thing (`text/html = html`, +// `text/plain = text`) but is not exposed as a method/copyType. +// +// These tests assert the DESIRED `copyAsRich` behaviour and are expected to +// FAIL today (the branch doesn't exist, so `copyHandler` writes nothing for +// `copyType = 'copyAsRich'`). When the engine adds the `copyAsRich` copyType + +// `copyAsRich()` method, drop the `.fails`. + +// The clipboard module pulls in CodeBlockContent → utils/prism which touches +// `window` at import time. Stub the prism shim so the test can run under Node. +vi.mock('../../utils/prism/index', () => ({ + default: {}, + walkTokens: () => null, + loadedLanguages: new Set(), + transformAliasToOrigin: (s: string) => s, + loadLanguage: () => null, + search: () => [], +})); + +const Clipboard = (await import('../index')).default; + +function makeEvent() { + const setData = vi.fn(); + return { + event: { clipboardData: { setData } } as unknown as ClipboardEvent, + setData, + }; +} + +function makeClipboard(html: string, text: string) { + const clipboard = new Clipboard({} as Muya); + clipboard.getClipboardData = () => ({ html, text }); + return clipboard; +} + +describe('parity PG9: copyAsRich writes rendered HTML as rich text', () => { + it.fails( + 'PG9: copyAsRich sets text/html=rendered html AND text/plain=text', + () => { + const html = '

Title

bold

'; + const text = '# Title\n\n**bold**'; + const clipboard = makeClipboard(html, text); + clipboard.copyType = 'copyAsRich'; + const { event, setData } = makeEvent(); + + clipboard.copyHandler(event); + + // Desired: rich-text contract — html in the html slot (so a + // rich-text target renders it), source in the plain slot. + expect(setData).toHaveBeenCalledWith('text/html', html); + expect(setData).toHaveBeenCalledWith('text/plain', text); + }, + ); + + it.fails( + 'PG9: copyAsRich puts the rendered HTML in the text/html slot (unlike copyAsHtml)', + () => { + const html = '

rich

'; + const text = 'rich'; + + // copyAsHtml (the current mapping target) blanks text/html and puts + // the markup into text/plain — pasting yields literal markup. + const asHtmlClip = makeClipboard(html, text); + asHtmlClip.copyType = 'copyAsHtml'; + const asHtml = makeEvent(); + asHtmlClip.copyHandler(asHtml.event); + const asHtmlHtmlSlot = asHtml.setData.mock.calls.find( + c => c[0] === 'text/html', + )?.[1]; + expect(asHtmlHtmlSlot).toBe(''); + + // copyAsRich must instead place the real rendered HTML in the html + // slot so a rich-text target renders it. + const asRichClip = makeClipboard(html, text); + asRichClip.copyType = 'copyAsRich'; + const asRich = makeEvent(); + asRichClip.copyHandler(asRich.event); + const asRichHtmlSlot = asRich.setData.mock.calls.find( + c => c[0] === 'text/html', + )?.[1]; + expect(asRichHtmlSlot).toBe(html); + }, + ); +}); diff --git a/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts b/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts new file mode 100644 index 0000000000..c9278b1cfb --- /dev/null +++ b/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts @@ -0,0 +1,176 @@ +import type Content from '../../block/base/content'; +import type { Muya } from '../../muya'; +import { describe, expect, it, vi } from 'vitest'; + +// PARITY SCOREBOARD — gaps PG5 (file PG05) + PG6 (file PG06). +// +// PG6: legacy `packages/muyajs` routed a pasted image FILE through +// `imageAction(imagePath, id)` so the user's `imageInsertAction` preference +// (copy-to-assets / upload / keep-path) applied. `@muyajs/core`'s path-paste +// branch calls `insertImagePath(anchorBlock, imagePath)`, which writes +// `![](rawPath)` verbatim and NEVER invokes `options.imageAction` — so a +// pasted image file is linked from its original on-disk location and the +// document is non-portable. +// +// PG5: legacy `packages/muyajs` `pasteImage()` had a binary/bitmap branch: +// when no clipboard file path resolved, it read the in-memory image File via +// `clipboardData.items[i].getAsFile()` + `FileReader.readAsDataURL` and +// persisted it via `imageAction(file, id)`. `@muyajs/core`'s `pasteHandler` +// has no `getAsFile`/`FileReader`/`clipboardData.files` path at all, so a +// bitmap-only clipboard (screenshot, browser "Copy Image") inserts nothing. +// +// Both assert the DESIRED behaviour and are expected to FAIL today. When the +// engine restores the `imageAction`-routed paste, drop the `.fails`. + +// The clipboard module pulls in CodeBlockContent → utils/prism which touches +// `window` at import time. Stub the prism shim so the test can run under Node +// (same stub as clipboardFilePath.spec / copyHandler.spec). +vi.mock('../../utils/prism/index', () => ({ + default: {}, + walkTokens: () => null, + loadedLanguages: new Set(), + transformAliasToOrigin: (s: string) => s, + loadLanguage: () => null, + search: () => [], +})); + +// Keep the real `resolveClipboardImagePath` (the decision under test) but neuter +// `normalizePastedHTML`, whose DOMPurify call needs a DOM the default `node` +// test environment doesn't provide. +vi.mock('../../utils/paste', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + normalizePastedHTML: async (html: string) => html, + }; +}); + +const Clipboard = (await import('../index')).default; + +function makeAnchorBlock(initialText = '', cursor = 0) { + const block = { + text: initialText, + blockName: 'paragraph.content', + getCursor: () => ({ + start: { offset: cursor }, + end: { offset: cursor }, + }), + setCursor: vi.fn(), + getAnchor: () => null, + }; + return block as unknown as Content & { setCursor: ReturnType }; +} + +function makeClipboard(options: Record, anchorBlock: Content) { + const clipboard = new Clipboard({ options } as unknown as Muya); + Object.defineProperty(clipboard, 'selection', { + get: () => ({ + getSelection: () => ({ + isSelectionInSameBlock: true, + anchorBlock, + }), + }), + }); + return clipboard; +} + +// A paste event with optional text/html data and optional in-memory image +// `files`/`items` (the bitmap clipboard case for PG5). +function makePasteEvent( + data: Record = {}, + files: File[] = [], +) { + const getData = vi.fn((type: string) => data[type] ?? ''); + const items = files.map(file => ({ + kind: 'file', + type: file.type, + getAsFile: () => file, + })); + return { + event: { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clipboardData: { getData, files, items }, + } as unknown as ClipboardEvent, + getData, + }; +} + +describe('parity PG6: pasted image FILE routes through imageAction', () => { + it.fails( + 'PG6: a resolved clipboard image path is persisted via options.imageAction (insert preference)', + async () => { + const clipboardFilePath = vi.fn().mockResolvedValue('/abs/photo.png'); + // The user's insert preference moves the file into the assets dir and + // returns the rewritten src. With the gap, this is never called. + const imageAction = vi + .fn() + .mockResolvedValue('assets/photo.png'); + const anchorBlock = makeAnchorBlock('', 0); + const clipboard = makeClipboard( + { clipboardFilePath, imageAction }, + anchorBlock, + ); + const { event } = makePasteEvent(); + + await clipboard.pasteHandler(event); + + // Desired: imageAction was invoked with the resolved source path so + // the copy-to-assets / upload preference can apply. + expect(imageAction).toHaveBeenCalledTimes(1); + const arg = imageAction.mock.calls[0][0]; + const src = typeof arg === 'string' ? arg : arg?.src; + expect(src).toBe('/abs/photo.png'); + }, + ); + + it.fails( + 'PG6: the persisted (rewritten) src — not the raw on-disk path — is inserted', + async () => { + const clipboardFilePath = vi.fn().mockResolvedValue('/abs/photo.png'); + const imageAction = vi.fn().mockResolvedValue('assets/photo.png'); + const anchorBlock = makeAnchorBlock('', 0); + const clipboard = makeClipboard( + { clipboardFilePath, imageAction }, + anchorBlock, + ); + const { event } = makePasteEvent(); + + await clipboard.pasteHandler(event); + + // Desired: the assets-relative src returned by imageAction is what + // lands in the document (portable). Today the raw absolute path is + // written verbatim. + expect(anchorBlock.text).toBe('![](assets/photo.png)'); + }, + ); +}); + +describe('parity PG5: binary/bitmap clipboard image paste', () => { + it.fails( + 'PG5: a bitmap-only clipboard (no file path) inserts an image via imageAction', + async () => { + // No clipboardFilePath hook resolves a path: the only data is an + // in-memory PNG File (the screenshot / "Copy Image" case). + const clipboardFilePath = vi.fn().mockResolvedValue(''); + const imageAction = vi.fn().mockResolvedValue('assets/pasted.png'); + const anchorBlock = makeAnchorBlock('', 0); + const clipboard = makeClipboard( + { clipboardFilePath, imageAction }, + anchorBlock, + ); + const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { + type: 'image/png', + }); + const { event } = makePasteEvent({}, [pngFile]); + + await clipboard.pasteHandler(event); + + // Desired: the binary image is persisted through imageAction and an + // image is inserted. Today nothing is read from clipboardData.files + // and nothing is inserted. + expect(imageAction).toHaveBeenCalledTimes(1); + expect(anchorBlock.text).toBe('![](assets/pasted.png)'); + }, + ); +}); diff --git a/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts new file mode 100644 index 0000000000..8b9c59f05c --- /dev/null +++ b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLASS_NAMES } from '../../config'; +import { Muya } from '../../muya'; + +// PARITY SCOREBOARD — gap PG12 (file PG12). +// +// Legacy `packages/muyajs` read `muya.options.hideLinkPopup` in +// `eventHandler/mouseEvent.js`: the link-hover handler only dispatched +// `muya-link-tools` (the link-edit/jump popover) when `!hideLinkPopup`. So +// `hideLinkPopup: true` suppressed the popover on link hover. +// +// `@muyajs/core` still accepts `hideLinkPopup` (construction + setOptions) but +// `editor/linkMouseEvents.ts#overHandler` emits `muya-link-tools` +// UNCONDITIONALLY — it never reads the option. `grep hideLinkPopup +// packages/muya/src` finds only the type decl + default. So the popover always +// appears on hover even with `hideLinkPopup: true`. +// +// The gap test asserts the DESIRED suppression and is expected to FAIL today. +// A positive control proves the harness actually drives the hover emit. When +// the engine gates the popover on `hideLinkPopup`, drop the `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level handlers registered during init — and removes the host + // node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string, options: Record = {}): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { + markdown, + ...options, + } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// Return the rendered link wrapper, forced into preview mode (the preceding +// source-marker sibling carries `.mu-hide`, which `isPopoverTarget` requires). +function previewLink(muya: Muya): HTMLElement { + const link = muya.domNode.querySelector(`span.${CLASS_NAMES.MU_LINK}`)!; + link.previousElementSibling?.classList.add(CLASS_NAMES.MU_HIDE); + return link; +} + +function hover(link: HTMLElement): void { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true })); +} + +// A `muya-link-tools` payload with a truthy `reference` opens the popover; a +// null reference hides it. Count only the popover-opening emits. +function countOpenEmits(handler: ReturnType): number { + return handler.mock.calls.filter(c => c[0]?.reference).length; +} + +describe('parity PG12: hideLinkPopup gates the link hover popover', () => { + it('control: with hideLinkPopup=false, hovering a preview link opens the popover', () => { + const muya = bootMuya('[hello](https://example.com)\n', { hideLinkPopup: false }); + const link = previewLink(muya); + + const handler = vi.fn(); + muya.on('muya-link-tools', handler); + hover(link); + + expect(countOpenEmits(handler)).toBe(1); + }); + + it.fails( + 'PG12: with hideLinkPopup=true, hovering a preview link does NOT open the popover', + () => { + const muya = bootMuya('[hello](https://example.com)\n', { hideLinkPopup: true }); + const link = previewLink(muya); + + const handler = vi.fn(); + muya.on('muya-link-tools', handler); + hover(link); + + // Desired: the popover stays suppressed when the preference is set. + // Today the emitter ignores the option and opens it anyway. + expect(countOpenEmits(handler)).toBe(0); + }, + ); +}); diff --git a/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts new file mode 100644 index 0000000000..be6a5f0de8 --- /dev/null +++ b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLASS_NAMES } from '../../config'; +import { Muya } from '../../muya'; + +// PARITY SCOREBOARD — gap PG10 (file PG09/PG14, "Space preview"). +// +// Legacy `packages/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 emits `preview-image`: the image-selected keydown +// handler (`selection/index.ts`) only acts on Backspace/Delete/Enter — Space +// falls through to native handling (inserts a space) — and the desktop's +// `preview-image` subscription is dead code. The Cmd/Ctrl-click preview path +// survives via `format-click`, so only the keyboard affordance is lost. +// +// This asserts the DESIRED Space-to-preview emit and is expected to FAIL +// today. When the engine restores the `preview-image` emit, drop the `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level keydown/click handlers registered by selection — and + // removes the host node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +// Boot an image and inject the loaded the renderer would have produced +// (the async image-load path never resolves under happy-dom). Returns the muya +// instance and the . +function bootImage(src: string): { muya: Muya; img: HTMLImageElement } { + const muya = bootMuya(`![alt](${src})`); + const wrapper = muya.domNode.querySelector( + `span.${CLASS_NAMES.MU_INLINE_IMAGE}`, + )!; + const container = wrapper.querySelector( + `.${CLASS_NAMES.MU_IMAGE_CONTAINER}`, + )!; + const img = document.createElement('img'); + img.setAttribute('src', src); + container.appendChild(img); + return { muya, img }; +} + +// Plain-click the image to populate `selection.selectedImage` (the same state a +// real user click leaves behind before pressing Space). +function selectImage(img: HTMLImageElement): void { + img.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); +} + +describe('parity PG10: Space previews a selected image', () => { + it.fails( + 'PG10: pressing Space with an image selected emits preview-image', + () => { + const src = 'https://example.com/pic.png'; + const { muya, img } = bootImage(src); + selectImage(img); + // Sanity: the click populated the selected-image state. + expect(muya.editor.selection.selectedImage).toBeTruthy(); + + const handler = vi.fn(); + muya.on('preview-image', handler); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }), + ); + + // Desired: the engine emits preview-image so the host can open the + // full-screen viewer. Today nothing fires (Space inserts a space). + expect(handler).toHaveBeenCalledTimes(1); + }, + ); + + it.fails( + 'PG10: the preview-image payload carries the selected image src', + () => { + const src = 'https://example.com/pic.png'; + const { muya, img } = bootImage(src); + selectImage(img); + + let payload: unknown = null; + muya.on('preview-image', (p: unknown) => { + payload = p; + }); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }), + ); + + // Desired: the payload exposes the image src (legacy shape was + // `{ data: src }`); the exact key may differ but the src must be + // recoverable from the payload. + expect(JSON.stringify(payload)).toContain(src); + }, + ); +}); diff --git a/packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts b/packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts new file mode 100644 index 0000000000..4e582ec1df --- /dev/null +++ b/packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import type Content from '../../block/base/content'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../muya'; + +// PARITY SCOREBOARD — gap PG1 (file PG01). +// +// Legacy `packages/muyajs` emitted `selectionChange` with an `affiliation` +// chain of the ancestor PARAGRAPH-type blocks plus per-block `.type` (the +// markdown block type: `h1`, `p`, `pre`, …) and `.functionType` +// (`codeContent`, `cellContent`, …). The desktop store +// (`createApplicationMenuState`) consumed those to light up the Paragraph-menu +// check marks, the Loose/Task-list toggles, table/code-fence detection, and to +// disable the Format menu inside code. +// +// `@muyajs/core`'s `selection-change` payload exposes only flat caret/range +// info: { anchor, focus, anchorBlock, anchorPath, focusBlock, focusPath, +// isCollapsed, isSelectionInSameBlock, direction, type, selectedImage, +// cursorCoords, formats }. There is NO `affiliation` ancestor chain, and +// `type` is the selection kind ('Caret' | 'Range'), never the block markdown +// type. Net effect: the native Paragraph/Format menu state is dead. +// +// These tests assert the DESIRED (pre-migration) shape — they are expected to +// FAIL today. When the engine restores the ancestor affiliation / block-type +// info, drop the `.fails`. + +const bootedMuyas: Muya[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + // `destroy()` detaches the engine's DOM listeners — including the + // `document`-level keydown/click handlers registered by selection — and + // removes the host node, so listeners don't leak across tests. + while (bootedMuyas.length) + bootedMuyas.pop()!.destroy(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedMuyas.push(muya); + return muya; +} + +function emitSelectionFor(muya: Muya, content: Content): Record { + let payload: Record | null = null; + muya.on('selection-change', (p: unknown) => { + payload = p as Record; + }); + muya.editor.selection.setSelection({ + anchor: { offset: 0 }, + focus: { offset: 0 }, + block: content, + path: content.path, + } as Parameters[0]); + if (!payload) + throw new Error('selection-change was not emitted'); + return payload; +} + +describe('parity PG1: selection-change block affiliation', () => { + it.fails( + 'PG1: selection-change payload exposes the ancestor block affiliation chain', + () => { + const muya = bootMuya('# Heading\n\nbody\n'); + const heading = muya.editor.scrollPage!.firstContentInDescendant()!; + const payload = emitSelectionFor(muya, heading); + + // Desired: the payload carries an `affiliation` map/list of the + // ancestor block types so the desktop Paragraph menu can light up. + // Today the key is entirely absent. + expect('affiliation' in payload).toBe(true); + }, + ); + + it.fails( + 'PG1: selection-change exposes the current block markdown type (h1), not just the selection kind', + () => { + const muya = bootMuya('# Heading\n\nbody\n'); + const heading = muya.editor.scrollPage!.firstContentInDescendant()!; + const payload = emitSelectionFor(muya, heading); + + // Desired: a consumer can learn the cursor sits in an `h1` heading + // (so `heading1MenuItem` can be checked). Today `type` is the + // selection kind 'Caret' / 'Range' and no field reports `h1`. + const flat = JSON.stringify(payload); + expect(flat).toContain('h1'); + }, + ); +}); diff --git a/packages/muya/src/state/__tests__/parityExportHtml.spec.ts b/packages/muya/src/state/__tests__/parityExportHtml.spec.ts new file mode 100644 index 0000000000..8baa2d51ff --- /dev/null +++ b/packages/muya/src/state/__tests__/parityExportHtml.spec.ts @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest'; +import { MarkdownToHtml } from '../markdownToHtml'; + +// PARITY SCOREBOARD — gaps PG7 (file PG07) + PG8 (file PG08). +// +// PG7: legacy `packages/muyajs` `ExportHtml.generate` inlined +// github-markdown-css, the prism theme, and katex CSS as `` +// blocks (via `?inline` imports), so exported HTML/PDF/print was fully +// self-contained and rendered offline. `@muyajs/core`'s +// `MarkdownToHtml.generate` instead links those three core stylesheets from +// external CDNs (``). Offline / behind +// CSP / air-gapped, the standalone HTML export is unstyled. +// +// PG8: legacy export rendered each heading as `` (matching the +// `getHtmlToc` `` anchors), so in-document [TOC] / TOC links +// worked. `@muyajs/core` renders via stock `marked` with no heading-id +// renderer, so exported `

..

` carry NO id and every TOC anchor is dead. +// +// These assert the DESIRED export output and are expected to FAIL today. When +// the engine inlines base CSS (PG7) / injects heading ids (PG8), drop the +// `.fails`. + +const SAMPLE = '# Getting Started\n\n## Installation\n\nSome **body** text.\n'; + +async function generateExport(markdown: string): Promise { + // `MarkdownToHtml` works without a Muya instance (muya is optional); the + // export path the desktop wrapper uses calls `.generate({ title, extraCSS })`. + return new MarkdownToHtml(markdown).generate({ title: 'Doc' }); +} + +describe('parity PG7: export inlines base stylesheets (offline-safe)', () => { + it.fails( + 'PG7: generated HTML inlines github-markdown-css as a `).join('\n') + : CDN_STYLESHEET_LINKS; + return ` ${sanitize(title, EXPORT_DOMPURIFY_CONFIG, true)} - - - - - - +${baseStyles} diff --git a/packages/muya/vite.config.ts b/packages/muya/vite.config.ts index e2a33617a6..9206746ce4 100644 --- a/packages/muya/vite.config.ts +++ b/packages/muya/vite.config.ts @@ -20,6 +20,12 @@ export default defineConfig({ }, }, test: { + // Process CSS imports (including `?inline`) so the export path's + // inlined base stylesheets resolve to real content under Vitest. + // Without this Vitest defaults to `css: { include: [] }` and every + // CSS import returns an empty string, which would silently mask the + // PG7 offline-export regression in `parityExportHtml.spec.ts`. + css: true, coverage: { include: ['src/**/*.ts'], reporter: ['html', 'text', 'json'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 891428c719..d3765f6762 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: fuse.js: specifier: ^7.3.0 version: 7.3.0 + github-markdown-css: + specifier: ^5.9.0 + version: 5.9.0 html-tags: specifier: ^5.1.0 version: 5.1.0 From 0d6d28d59acde142dba1d4c4b02dce3cf886fb60 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Tue, 9 Jun 2026 01:52:32 +0800 Subject: [PATCH 7/9] feat(muya): emit preview-image + heading-copy-link events (PG10/PG11) (#4414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(muya): emit preview-image on Space for a selected image (PG10) 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 '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) * feat(muya): add heading hover-copy affordance + heading-copy-link (PG11) 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) * fix(muya): make heading copy-link affordance accessible (review) Addresses the e2e a11y scan failure (critical image-alt) and Copilot review on the heading copy-link affordance: - The decorative icon 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 ), 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/parityHeadingCopyLink.spec.ts | 47 ++++++- .../muya/src/assets/styles/blockSyntax.css | 49 +++++++ .../src/block/commonMark/atxHeading/index.ts | 4 + .../block/commonMark/headingCopyLink/index.ts | 125 ++++++++++++++++++ .../block/commonMark/setextHeading/index.ts | 4 + packages/muya/src/block/index.ts | 2 + packages/muya/src/config/index.ts | 1 + .../__tests__/parityPreviewImage.spec.ts | 8 +- packages/muya/src/selection/index.ts | 84 +++++++++--- packages/muya/src/state/getTOC.ts | 2 +- 10 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 packages/muya/src/block/commonMark/headingCopyLink/index.ts diff --git a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts index 9b5cb82bf2..d925e004fc 100644 --- a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts +++ b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts @@ -14,9 +14,10 @@ import { Muya } from '../muya'; // `heading-copy-link`; the desktop subscription was removed and documented as // a gap. `copyGithubSlug` is now unreachable dead code. // -// This asserts the DESIRED hover-copy affordance + emit and is expected to -// FAIL today (the affordance element isn't rendered, so the event can't fire). -// When the engine restores the affordance + emit, drop the `.fails`. +// The engine now restores the hover-copy affordance (a `mu-copy-header-link` +// attachment on every heading) and emits `heading-copy-link` { key } on click, +// so these assertions pass. The `key` is the heading's stable slug — the same +// value `getTOC()` exposes as `ITocItem.slug` — so the host can resolve it. const bootedMuyas: Muya[] = []; let originalVersion: string | undefined; @@ -55,7 +56,7 @@ const COPY_LINK_SELECTOR = '.ag-copy-header-link, .mu-copy-header-link, [class*="copy-header-link"]'; describe('parity PG11: heading hover-to-copy-anchor affordance', () => { - it.fails( + it( 'PG11: a heading renders a copy-link affordance', () => { const muya = bootMuya('# Getting Started\n'); @@ -66,7 +67,7 @@ describe('parity PG11: heading hover-to-copy-anchor affordance', () => { }, ); - it.fails( + it( 'PG11: activating the heading copy affordance emits heading-copy-link with the block key', () => { const muya = bootMuya('# Getting Started\n'); @@ -89,4 +90,40 @@ describe('parity PG11: heading hover-to-copy-anchor affordance', () => { expect(payload?.key).toBeTruthy(); }, ); + + it( + 'PG11: the affordance is an accessible, keyboard-focusable button', + () => { + const muya = bootMuya('# Getting Started\n'); + const affordance = muya.domNode.querySelector(COPY_LINK_SELECTOR)!; + + expect(affordance.getAttribute('role')).toBe('button'); + expect(affordance.getAttribute('tabindex')).toBe('0'); + expect(affordance.getAttribute('aria-label')).toBeTruthy(); + // The icon image is decorative — the button carries the label — so + // it must expose an (empty) alt to satisfy the image-alt a11y rule. + const img = affordance.querySelector('img')!; + expect(img.getAttribute('alt')).toBe(''); + }, + ); + + it.each(['Enter', ' '])( + 'PG11: pressing %s on the focused affordance emits heading-copy-link', + (key) => { + const muya = bootMuya('# Getting Started\n'); + + const handler = vi.fn(); + muya.on('heading-copy-link', handler); + + const affordance = muya.domNode.querySelector(COPY_LINK_SELECTOR); + affordance?.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), + ); + + // Keyboard activation mirrors click so the control is operable + // without a pointer. + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0]?.key).toBeTruthy(); + }, + ); }); diff --git a/packages/muya/src/assets/styles/blockSyntax.css b/packages/muya/src/assets/styles/blockSyntax.css index f2ec99e03b..6e830ea397 100644 --- a/packages/muya/src/assets/styles/blockSyntax.css +++ b/packages/muya/src/assets/styles/blockSyntax.css @@ -72,6 +72,55 @@ font-size: 1em; } +/* heading copy-anchor affordance — revealed on heading hover, sits in the + left gutter (matches the legacy `ag-copy-header-link` placement). */ +.mu-container h1 > .mu-copy-header-link, +.mu-container h2 > .mu-copy-header-link, +.mu-container h3 > .mu-copy-header-link, +.mu-container h4 > .mu-copy-header-link, +.mu-container h5 > .mu-copy-header-link, +.mu-container h6 > .mu-copy-header-link { + position: absolute; + top: 0.65em; + left: -26px; + + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + + cursor: pointer; + opacity: 0; + + transition: opacity 0.2s ease; + + user-select: none; +} + +.mu-container h1:hover > .mu-copy-header-link, +.mu-container h2:hover > .mu-copy-header-link, +.mu-container h3:hover > .mu-copy-header-link, +.mu-container h4:hover > .mu-copy-header-link, +.mu-container h5:hover > .mu-copy-header-link, +.mu-container h6:hover > .mu-copy-header-link { + opacity: 0.6; +} + +.mu-container h1 > .mu-copy-header-link:hover, +.mu-container h2 > .mu-copy-header-link:hover, +.mu-container h3 > .mu-copy-header-link:hover, +.mu-container h4 > .mu-copy-header-link:hover, +.mu-container h5 > .mu-copy-header-link:hover, +.mu-container h6 > .mu-copy-header-link:hover { + opacity: 1; +} + +.mu-container .mu-copy-header-link > .mu-icon-inner { + width: 14px; + height: 14px; +} + /* block quote */ .mu-container blockquote { position: relative; diff --git a/packages/muya/src/block/commonMark/atxHeading/index.ts b/packages/muya/src/block/commonMark/atxHeading/index.ts index 5c56d5c5f2..ea210561fc 100644 --- a/packages/muya/src/block/commonMark/atxHeading/index.ts +++ b/packages/muya/src/block/commonMark/atxHeading/index.ts @@ -16,6 +16,10 @@ class AtxHeading extends Parent { static create(muya: Muya, state: IAtxHeadingState) { const heading = new AtxHeading(muya, state); + heading.appendAttachment( + ScrollPage.loadBlock('heading-copy-link').create(muya, null), + ); + heading.append( ScrollPage.loadBlock('atxheading.content').create(muya, state.text), ); diff --git a/packages/muya/src/block/commonMark/headingCopyLink/index.ts b/packages/muya/src/block/commonMark/headingCopyLink/index.ts new file mode 100644 index 0000000000..9f33cf9089 --- /dev/null +++ b/packages/muya/src/block/commonMark/headingCopyLink/index.ts @@ -0,0 +1,125 @@ +import type { Muya } from '../../../muya'; +import formatLinkIcon from '../../../assets/icons/format_link/2.png'; +import { CLASS_NAMES } from '../../../config'; +import { stableSlug } from '../../../state/getTOC'; +import { isKeyboardEvent } from '../../../utils'; +import logger from '../../../utils/logger'; +import TreeNode from '../../base/treeNode'; + +const debug = logger('headingCopyLink:'); + +// marktext rendered an `i.icon.ag-copy-header-link` hover affordance on every +// heading; clicking it dispatched `heading-copy-link` { key } and the desktop +// renderer copied the heading's GitHub slug/anchor to the clipboard. The new +// engine has no equivalent affordance — this attachment block restores it. +// +// It is appended to its heading via `appendAttachment` (the same mechanism the +// task-list checkbox uses), so it carries its own DOM node and click handler +// without participating in document state. 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 (`copyGithubSlug`). +class HeadingCopyLink extends TreeNode { + private _eventIds: string[] = []; + + static override blockName = 'heading-copy-link'; + + // `_state` is unused — the affordance carries no document state — but the + // `ScrollPage.loadBlock(...).create(muya, state)` contract requires the + // second parameter, so accept and ignore it. + static create(muya: Muya, _state?: unknown) { + return new HeadingCopyLink(muya); + } + + get isContainerBlock() { + return false; + } + + constructor(muya: Muya) { + super(muya); + const label = muya.i18n.t('Copy anchor link to this heading'); + this.tagName = 'i'; + this.classList = ['mu-icon', CLASS_NAMES.MU_COPY_HEADER_LINK]; + // Accessible button semantics: discoverable + focusable + operable by + // assistive tech and keyboard (the keydown handler below activates it). + this.attributes = { + 'contenteditable': 'false', + 'role': 'button', + 'tabindex': '0', + 'aria-label': label, + 'title': label, + }; + this.createDomNode(); + + // The button carries the accessible label, so the icon image is purely + // decorative — an empty `alt` keeps screen readers from announcing it + // twice and satisfies the `image-alt` a11y rule. + const img = document.createElement('img'); + img.classList.add('mu-icon-inner'); + img.setAttribute('src', formatLinkIcon); + img.setAttribute('alt', ''); + this.domNode!.appendChild(img); + + this.listen(); + } + + listen() { + const { domNode, muya } = this; + const { eventCenter } = muya; + + 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(); + this._activate(); + }; + + // Keyboard activation (Enter / Space) for the focusable button, so it + // is operable without a pointer. + const keydownHandler = (event: Event) => { + if (!isKeyboardEvent(event)) + return; + if (event.key !== 'Enter' && event.key !== ' ') + return; + event.preventDefault(); + event.stopPropagation(); + this._activate(); + }; + + this._eventIds.push( + eventCenter.attachDOMEvent(domNode!, 'click', clickHandler), + eventCenter.attachDOMEvent(domNode!, 'keydown', keydownHandler), + ); + } + + // Emit `heading-copy-link` with the heading's stable slug. At activation + // time the attachment's parent is the heading block. + private _activate() { + const heading = this.parent; + if (!heading) + return; + + this.muya.eventCenter.emit('heading-copy-link', { + key: stableSlug(heading), + }); + } + + detachDOMEvents() { + for (const id of this._eventIds) + this.muya.eventCenter.detachDOMEvent(id); + } + + override remove(_source: string) { + super.remove(); + this.detachDOMEvents(); + + return this; + } + + getState() { + debug.warn('You should never call this method.'); + } +} + +export default HeadingCopyLink; diff --git a/packages/muya/src/block/commonMark/setextHeading/index.ts b/packages/muya/src/block/commonMark/setextHeading/index.ts index 1148f6bcd3..ef7d359af9 100644 --- a/packages/muya/src/block/commonMark/setextHeading/index.ts +++ b/packages/muya/src/block/commonMark/setextHeading/index.ts @@ -15,6 +15,10 @@ class SetextHeading extends Parent { static create(muya: Muya, state: ISetextHeadingState) { const heading = new SetextHeading(muya, state); + heading.appendAttachment( + ScrollPage.loadBlock('heading-copy-link').create(muya, null), + ); + heading.append( ScrollPage.loadBlock('setextheading.content').create(muya, state.text), ); diff --git a/packages/muya/src/block/index.ts b/packages/muya/src/block/index.ts index e099be4aa2..7516d943b9 100644 --- a/packages/muya/src/block/index.ts +++ b/packages/muya/src/block/index.ts @@ -5,6 +5,7 @@ import BlockQuote from './commonMark/blockQuote'; import BulletList from './commonMark/bulletList'; import CodeBlock from './commonMark/codeBlock'; import Code from './commonMark/codeBlock/code'; +import HeadingCopyLink from './commonMark/headingCopyLink'; import HTMLBlock from './commonMark/html'; import HTMLContainer from './commonMark/html/htmlContainer'; import HTMLPreview from './commonMark/html/htmlPreview'; @@ -48,6 +49,7 @@ export function registerBlocks() { ScrollPage.register(AtxHeadingContent); ScrollPage.register(SetextHeading); ScrollPage.register(SetextHeadingContent); + ScrollPage.register(HeadingCopyLink); ScrollPage.register(BlockQuote); ScrollPage.register(ThematicBreak); ScrollPage.register(ThematicBreakContent); diff --git a/packages/muya/src/config/index.ts b/packages/muya/src/config/index.ts index 8569bea9e3..e5a1777354 100644 --- a/packages/muya/src/config/index.ts +++ b/packages/muya/src/config/index.ts @@ -105,6 +105,7 @@ export const CLASS_NAMES = genUpper2LowerKeyHash([ 'MU_CONTAINER_BLOCK', 'MU_CONTAINER_PREVIEW', 'MU_CONTAINER_ICON', + 'MU_COPY_HEADER_LINK', 'MU_COPY_REMOVE', 'MU_DISABLE_HTML_RENDER', 'MU_EMOJI_MARKED_TEXT', diff --git a/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts index be6a5f0de8..d88b347281 100644 --- a/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts +++ b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts @@ -16,8 +16,8 @@ import { Muya } from '../../muya'; // `preview-image` subscription is dead code. The Cmd/Ctrl-click preview path // survives via `format-click`, so only the keyboard affordance is lost. // -// This asserts the DESIRED Space-to-preview emit and is expected to FAIL -// today. When the engine restores the `preview-image` emit, drop the `.fails`. +// The engine now restores the Space-to-preview emit (selection keydown +// handler emits `preview-image` { data: src }), so these assertions pass. const bootedMuyas: Muya[] = []; let originalVersion: string | undefined; @@ -74,7 +74,7 @@ function selectImage(img: HTMLImageElement): void { } describe('parity PG10: Space previews a selected image', () => { - it.fails( + it( 'PG10: pressing Space with an image selected emits preview-image', () => { const src = 'https://example.com/pic.png'; @@ -100,7 +100,7 @@ describe('parity PG10: Space previews a selected image', () => { }, ); - it.fails( + it( 'PG10: the preview-image payload carries the selected image src', () => { const src = 'https://example.com/pic.png'; diff --git a/packages/muya/src/selection/index.ts b/packages/muya/src/selection/index.ts index eef455d7ff..6ca9f90a6c 100644 --- a/packages/muya/src/selection/index.ts +++ b/packages/muya/src/selection/index.ts @@ -528,25 +528,6 @@ class Selection { return this._handleClickInlineImage(event, imageWrapper); }; - const handleKeydown = (event: Event) => { - if (!isKeyboardEvent(event)) - return; - - const { key } = event; - const { selectedImage } = this; - // marktext ed1b3354 (#2816): `Delete` was missing from the - // image-selected key set, so it fell through to native - // contenteditable handling and removed the text *after* the - // image. Match key exactly to avoid substring-collisions - // like `BackspaceX`. - if (selectedImage && /^(?:Backspace|Delete|Enter)$/.test(key)) { - event.preventDefault(); - const { block, ...imageInfo } = selectedImage; - block.deleteImage(imageInfo); - this.selectedImage = null; - } - }; - eventCenter.attachDOMEvent(domNode, 'mousedown', handleMousedown); eventCenter.attachDOMEvent(domNode, 'mousemove', handleMousemoveOrClick); eventCenter.attachDOMEvent(domNode, 'mouseup', handleMouseupOrLeave); @@ -554,7 +535,70 @@ class Selection { eventCenter.attachDOMEvent(domNode, 'click', handleMousemoveOrClick); eventCenter.attachDOMEvent(domNode, 'click', handleClick); eventCenter.attachDOMEvent(document, 'click', docHandlerClick); - eventCenter.attachDOMEvent(document, 'keydown', handleKeydown); + eventCenter.attachDOMEvent(document, 'keydown', this._handleImageKeydown); + } + + // Keydown handling while an image is selected. Bound as a field so it can + // be passed directly to `attachDOMEvent` and keeps `_listenSelectActions` + // small. No-op unless an image is currently selected. + private _handleImageKeydown = (event: Event) => { + if (!isKeyboardEvent(event)) + return; + + const { key } = event; + const { selectedImage } = this; + // `selectedImage` is the gate: it is only ever set by an in-editor + // image click (`_handleClickInlineImage`) and is cleared on ANY + // document click (`docHandlerClick`) and on every delete/preview here. + // So this handler is inert unless the user has an image actively + // selected inside this editor — matching the legacy muyajs behavior. + if (!selectedImage) + return; + + // marktext (#2816 era): pressing Space with an image selected asks the + // host to open the full-screen preview. Mirror the legacy `keyboard.js` + // emit (`preview-image` { data: src }) and resolve the src the same way + // the Cmd/Ctrl-click path does, so relative / file paths become + // loadable URLs. `preventDefault` stops the native space from being + // inserted next to the selected image. + if (key === ' ') { + event.preventDefault(); + this._previewSelectedImage(selectedImage); + return; + } + + // marktext ed1b3354 (#2816): `Delete` was missing from the + // image-selected key set, so it fell through to native contenteditable + // handling and removed the text *after* the image. Match key exactly + // to avoid substring-collisions like `BackspaceX`. + if (/^(?:Backspace|Delete|Enter)$/.test(key)) { + event.preventDefault(); + const { block, ...imageInfo } = selectedImage; + block.deleteImage(imageInfo); + this.selectedImage = null; + } + }; + + // Resolve the selected image's src and ask the host to full-screen + // preview it. Mirrors the legacy `preview-image` { data: src } payload so + // the desktop renderer's existing subscription opens `SimpleImageViewer`. + // Resolution matches the Cmd/Ctrl-click path: prefer the token src + // (run through `getImageSrc` so relative / file paths become loadable), + // and fall back to the rendered 's own `src` attribute. + private _previewSelectedImage(selectedImage: NonNullable) { + const { token, imageId } = selectedImage; + const tokenSrc = token.src || token.attrs.src || ''; + const imgSrc + = this.muya.domNode + .querySelector(`#${imageId} img`) + ?.getAttribute('src') ?? ''; + const src = getImageSrc(tokenSrc).src || imgSrc; + + if (src) { + this.muya.eventCenter.emit('preview-image', { + data: src, + }); + } } // Handle click inline image. diff --git a/packages/muya/src/state/getTOC.ts b/packages/muya/src/state/getTOC.ts index 4ee204c8a9..62ae7b6421 100644 --- a/packages/muya/src/state/getTOC.ts +++ b/packages/muya/src/state/getTOC.ts @@ -23,7 +23,7 @@ interface IHeadingBlock extends Parent { // cross-instance collision. const slugCache = new WeakMap(); -function stableSlug(block: Parent): string { +export function stableSlug(block: Parent): string { let slug = slugCache.get(block); if (slug == null) { slug = getUniqueId(); From 410a7ca9b0d0df0fab1d14f38267fd4e952a8860 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Tue, 9 Jun 2026 01:56:28 +0800 Subject: [PATCH 8/9] feat(muya): restore drag-and-drop image insertion (PG4) (#4413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(muya): restore drag-and-drop image insertion (PG4) The @muyajs/core rewrite (#4406) shipped without any DnD handler, so dropping an image into the document was a no-op — parity gap PG4. Port the legacy muyajs dragDrop/dragDropCtrl behaviour as an engine-level, embedder-agnostic handler. `attachDragDropImageHandlers(muya)` binds dragstart/dragover/drop on the editor container (and dragleave for the ghost) via `eventCenter.attachDOMEvent`, so cleanup rides on `muya.destroy() → detachAllDomEvents()`. It is wired in `Editor.init()` alongside `attachLinkMouseHandlers`. Two drop paths mirror the legacy controller: - web-link image (`text/uri-list`): verify it is an image (extension or content-type sniff) then insert `![](url)`. - local image FILE (`dataTransfer.files`): resolve the file to a path via the new embedder `getPathForFile` hook, insert a `![loading-id](path)` placeholder, persist it through the new `imageAction` option (the same `{ src, alt, title }` contract the imageEditTool plugin consumes), then swap in the returned src. Two optional `IMuyaOptions` hooks are added (mirroring the existing `clipboardFilePath`): `imageAction` and `getPathForFile`. The engine stays free of `window.electron`; the desktop wires these in wave 2. Tested in `dragDropImage.spec.ts`: happy-dom provides a fully working `DataTransfer` (items.add / getAsString / files) and fires getAsString synchronously, so a synthetic `drop` event drives the real handler end-to-end — asserting both drop paths and the no-op-off-target case. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(parity): mark PG4 engine half automated, note wave-2 desktop wiring PG4 (drag-drop image insertion) was a manual-QA-only entry because real drag gestures are hard headless. The engine handler now has a synthetic- DataTransfer unit test, so update the scoreboard and QA checklist: - PARITY_QA.md § PG4: describe what is now automated (both drop paths via the live handler), keep the OS-integration steps manual, and document the desktop wave-2 wiring needed for the local-file persistence path (pass `imageAction` / `getPathForFile` into the Muya constructor). - PARITY_SCOREBOARD.md: point PG4 at the new spec, flag the engine half fixed, and drop the remaining-gaps count 15 → 14. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(muya): address PG4 drag-drop review (ghost CSS, no-imageAction, web-image gating) Copilot review on #4413: - Add the `#mu-dragover-ghost` CSS rule (position/height/background) to `assets/styles/index.css`. The new engine had no ghost style, so the drop indicator was invisible — legacy muyajs shipped this rule. - When no `imageAction` hook is configured, insert a clean `![name](path)` with the raw path verbatim instead of a `![loading-id](path)` placeholder that would never be swapped (it persists only when imageAction resolves). Matches the documented `imageAction` contract and imageEditTool's direct-replacement behaviour. - Gate the web-link path on the legacy "image dragged from a browser" signature — `text/uri-list` + `text/html` and NO `text/plain` — in both `dragover` and `drop`. A plain hyperlink drag (uri-list + text/plain) is now left to the browser instead of being intercepted and swallowed by `preventDefault()`. Tests updated: web-link drags use the realistic (uri-list + html) payload, plus new cases for the no-imageAction clean insert and the plain-hyperlink pass-through. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/desktop/test/PARITY_QA.md | 46 ++- packages/desktop/test/PARITY_SCOREBOARD.md | 10 +- packages/muya/src/assets/styles/index.css | 10 + .../editor/__tests__/dragDropImage.spec.ts | 263 +++++++++++++++ packages/muya/src/editor/dragDropImage.ts | 300 ++++++++++++++++++ packages/muya/src/editor/index.ts | 5 + packages/muya/src/types.ts | 21 +- 7 files changed, 637 insertions(+), 18 deletions(-) create mode 100644 packages/muya/src/editor/__tests__/dragDropImage.spec.ts create mode 100644 packages/muya/src/editor/dragDropImage.ts diff --git a/packages/desktop/test/PARITY_QA.md b/packages/desktop/test/PARITY_QA.md index 34d5235ca9..2b126cf03c 100644 --- a/packages/desktop/test/PARITY_QA.md +++ b/packages/desktop/test/PARITY_QA.md @@ -17,9 +17,21 @@ steps; the entry passes when the **Expected (after fix)** result is observed. ## PG4 — Drag-and-drop image insertion (local file + web link) -**Why manual:** drag-and-drop needs a real `DataTransfer` with `files` / -`text/uri-list` and a genuine drop gesture over the editor; Playwright/Electron -cannot synthesize an OS-level file drop into the contenteditable reliably. +**What is now automated:** the engine drag-drop handler is restored and unit +tested in `packages/muya/src/editor/__tests__/dragDropImage.spec.ts` (PG4). +happy-dom provides a fully working `DataTransfer` (`items.add` / `getAsString` / +`files`) and fires `getAsString` synchronously, so a synthetic `drop` event +drives the real handler end-to-end. The spec asserts both drop paths against the +live handler: a dropped local image FILE inserts a `![loading-id](path)` +placeholder and invokes the `imageAction` hook with `{ src, alt, title }`; a +dropped web-link image (`text/uri-list`) inserts `![](url)`; and a drop outside +an editor content block is a no-op. + +**Why this part stays manual:** the unit test mocks the embedder hooks +(`getPathForFile` / `imageAction`). It cannot exercise a genuine OS-level file +drop from the file manager, Electron's real `webUtils.getPathForFile`, or the +desktop assets-folder / upload persistence behind `imageAction`. Verify those by +hand: ### Steps — local image file 1. Open a document (ideally a saved `.md` so assets-folder behaviour applies). @@ -31,14 +43,24 @@ renders. With `Preferences → Image → insert action = "copy to folder"` the f is copied into the document's assets folder and the link points there (not the original absolute path). -**Current (gap):** nothing is inserted — the drop is a no-op. +> Requires the desktop wave-2 wiring (see below): the engine `imageAction` / +> `getPathForFile` options must be passed when constructing Muya in +> `editor.vue`. Without that wiring the drop inserts the raw path verbatim and +> the insert-action preference is ignored. ### Steps — web-link image 1. In a browser, drag an image (or its URL) over the editor and drop it. -**Expected (after fix):** `![]()` is inserted and the image renders. +**Expected (after fix):** `![]()` is inserted and the image renders. (This +path needs no desktop wiring — it works as soon as the engine handler ships.) -**Current (gap):** nothing is inserted. +### Desktop wave-2 wiring required +The engine now reads two new `IMuyaOptions` hooks for the local-file path: +`imageAction({ src, alt, title }) => Promise` (persist per insert +preference) and `getPathForFile(file) => string` (resolve a dropped `File` to a +path). `editor.vue` already defines `muyaImageAction` and uses +`window.electron.webUtils.getPathForFile` elsewhere — pass them into the Muya +constructor `options` so the dropped-file path is persisted and resolvable. --- @@ -76,10 +98,14 @@ silently dead. ## Notes for fixers -- After closing PG4 / PG5, consider adding a Playwright spec that drives the - engine paste/drop handler with a synthetic `DataTransfer` where the platform - allows it, and keep this manual entry only for the OS-integration parts that - remain un-automatable. +- PG4 and PG5 both now have engine-unit regression tests that drive the real + handler with a synthetic `DataTransfer` + (`packages/muya/src/editor/__tests__/dragDropImage.spec.ts` for PG4, + `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` for PG5). + These manual entries cover only the OS-integration / desktop-wiring parts the + unit tests cannot reach (real OS file drop, `webUtils.getPathForFile`, the + assets-folder/upload persistence behind `imageAction`, the OS clipboard, and + the macOS `screencapture` integration). - PG5's engine half is now closed: the binary-paste branch reads `clipboardData.files` → base64 `data:` URL → `imageAction`, and the regression test's `it.fails` is now a passing `it`. This manual entry covers diff --git a/packages/desktop/test/PARITY_SCOREBOARD.md b/packages/desktop/test/PARITY_SCOREBOARD.md index 59aac876b9..3dce390144 100644 --- a/packages/desktop/test/PARITY_SCOREBOARD.md +++ b/packages/desktop/test/PARITY_SCOREBOARD.md @@ -32,16 +32,18 @@ is marked as an *expected failure* so the test suites stay GREEN. ## Scoreboard -> **Gaps remaining: 12 / 15.** PG5, PG6, PG9 closed in the -> @muyajs/core clipboard parity PR (engine side); PG5's OS-clipboard delivery -> and PG9's desktop `copyAsRich` menu map remain (the latter is wave 2). +> **Gaps remaining: 11 / 15.** PG4, PG5, PG6, PG9 closed on the engine side. +> PG4's drag-drop image handler is fixed and unit-tested (its local-file +> persistence path still needs the desktop wave-2 wiring noted in +> `PARITY_QA.md` § PG4); PG5's OS-clipboard delivery and PG9's desktop +> `copyAsRich` menu map likewise remain as wave-2 desktop work. | Gap | Severity | Behaviour lost | Test location(s) | Mechanism | Status | |-----|----------|----------------|------------------|-----------|--------| | **PG1** | major | `selection-change` lacks block affiliation / ancestor type → native Paragraph & Format menu state is dead | `packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts` (`PG1:` ×2) · `packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts` (`PG1:`) | `it.fails` + `test.fail()` | ❌ xfail | | **PG2** | major | source-mode → WYSIWYG caret not restored (`handleFileChange` drops `muyaIndexCursor`) | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG2:`) | `test.fail()` | ❌ xfail | | **PG3** | major | `autoCheck` preference not consumed (task-list checkbox cascade lost) | `packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts` (`PG3:` ×2) | `it.fails` | ❌ xfail | -| **PG4** | major | drag-drop image insertion (local file + web link) absent | `packages/desktop/test/PARITY_QA.md` § PG4 | manual-QA | ❌ xfail | +| **PG4** | major | drag-drop image insertion (local file + web link) absent | `packages/muya/src/editor/__tests__/dragDropImage.spec.ts` (PG4 ×7) · `packages/desktop/test/PARITY_QA.md` § PG4 | unit (synthetic `DataTransfer`) + manual-QA | ✅ engine fixed | | **PG5** | major | binary/bitmap clipboard image paste lost (screenshot, browser "Copy Image") | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG5:`) · `packages/desktop/test/PARITY_QA.md` § PG5 | passing `it` + manual-QA | ✅ engine fixed (OS-clipboard manual-QA remains) | | **PG6** | major | pasted image FILE bypasses `imageAction` (copy-to-assets / upload preference ignored) | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG6:` ×2) | passing `it` | ✅ fixed | | **PG7** | major | export loads core CSS from CDN instead of inlining it (unstyled offline) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG7:` ×2) | `it.fails` | ❌ xfail | diff --git a/packages/muya/src/assets/styles/index.css b/packages/muya/src/assets/styles/index.css index 643b134299..7791782390 100644 --- a/packages/muya/src/assets/styles/index.css +++ b/packages/muya/src/assets/styles/index.css @@ -239,3 +239,13 @@ body { background: none; } + +/* Drag-and-drop image drop indicator (PG4). `dragDropImage.ts` positions this + absolutely-anchored bar at the top/bottom edge of the drop-target block. */ +#mu-dragover-ghost { + position: absolute; + + height: 3px; + + background: var(--highlight-color); +} diff --git a/packages/muya/src/editor/__tests__/dragDropImage.spec.ts b/packages/muya/src/editor/__tests__/dragDropImage.spec.ts new file mode 100644 index 0000000000..b6d96c5097 --- /dev/null +++ b/packages/muya/src/editor/__tests__/dragDropImage.spec.ts @@ -0,0 +1,263 @@ +// @vitest-environment happy-dom + +import type Parent from '../../block/base/parent'; +import type { Muya } from '../../muya'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BLOCK_DOM_PROPERTY } from '../../config'; +import EventCenter from '../../event'; + +// Regression tests for marktext #4406 parity gap PG4: drag-and-drop image +// insertion (local image FILE + web-link image) was entirely absent in the +// @muyajs/core rewrite. `attachDragDropImageHandlers` restores it by binding +// dragover/drop on the editor container and inserting a dropped image as a new +// `![](src)` block — the local-file path additionally persists the image +// through the embedder `imageAction` hook. +// +// happy-dom provides a fully working `DataTransfer` (items.add / getAsString / +// files) and fires `getAsString` synchronously, so the synthetic `drop` event +// drives the real handler end-to-end. The block tree below is mocked at the +// `ScrollPage.loadBlock` seam (the single factory the handler uses to create +// the inserted paragraph) plus a fake outermost-block anchor stamped onto the +// drop-target DOM node. + +// Capture the paragraph blocks the handler creates so each test can assert the +// inserted markdown text and where it landed relative to the anchor. +const createdBlocks: Array<{ text: string; insertedBefore: boolean; insertedAfter: boolean }> = []; + +vi.mock('../../block/scrollPage', () => ({ + ScrollPage: { + loadBlock: () => ({ + create: (_muya: unknown, state: { text: string }) => { + const record = { + text: state.text, + insertedBefore: false, + insertedAfter: false, + }; + createdBlocks.push(record); + const block = { + record, + firstContentInDescendant: () => ({ setCursor: vi.fn() }), + }; + return block; + }, + }), + }, +})); + +const { attachDragDropImageHandlers } = await import('../dragDropImage'); + +interface IMockMuya { + eventCenter: EventCenter; + domNode: HTMLElement; + options: { + imageAction?: (state: { src: string; alt: string; title: string }) => Promise; + getPathForFile?: (file: File) => string; + }; + editor: { inlineRenderer: { renderer: { urlMap: Map } } }; +} + +// Build a fake outermost anchor block whose `parent.insertBefore/insertAfter` +// flag the created paragraph so the test can assert the insert position. +function makeAnchor(anchorDom: HTMLElement): Parent { + const parent = { + insertBefore: (newBlock: { record?: { insertedBefore: boolean } }) => { + if (newBlock.record) + newBlock.record.insertedBefore = true; + }, + insertAfter: (newBlock: { record?: { insertedAfter: boolean } }) => { + if (newBlock.record) + newBlock.record.insertedAfter = true; + }, + }; + return { + domNode: anchorDom, + parent, + } as unknown as Parent; +} + +// A `span.mu-content` drop target stamped with a fake content block whose +// `outMostBlock` is the anchor — exactly what `findContentDOM` + `getBlock` +// resolve at runtime. +function makeDropTarget(muya: IMockMuya): HTMLElement { + const contentDom = document.createElement('span'); + contentDom.classList.add('mu-content'); + muya.domNode.appendChild(contentDom); + + const anchorDom = document.createElement('div'); + anchorDom.getBoundingClientRect = () => + ({ top: 0, left: 0, width: 100, height: 40 }) as DOMRect; + const anchor = makeAnchor(anchorDom); + + (contentDom as unknown as Record)[BLOCK_DOM_PROPERTY] = { + outMostBlock: anchor, + }; + + return contentDom; +} + +function makeMuya(options: IMockMuya['options'] = {}): IMockMuya { + const domNode = document.createElement('div'); + document.body.appendChild(domNode); + return { + eventCenter: new EventCenter(), + domNode, + options, + editor: { inlineRenderer: { renderer: { urlMap: new Map() } } }, + }; +} + +function dropEvent(target: HTMLElement, dataTransfer: DataTransfer): DragEvent { + const event = new DragEvent('drop', { bubbles: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(event, 'target', { value: target }); + Object.defineProperty(event, 'clientY', { value: 5 }); // top half → insert above + return event; +} + +afterEach(() => { + createdBlocks.length = 0; + document.body.innerHTML = ''; +}); + +describe('attachDragDropImageHandlers — local image FILE', () => { + it('inserts a loading placeholder and invokes imageAction with the resolved path', async () => { + const imageAction = vi.fn().mockResolvedValue('assets/shot.png'); + const getPathForFile = vi.fn().mockReturnValue('/abs/shot.png'); + const muya = makeMuya({ imageAction, getPathForFile }); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + const file = new File(['x'], 'shot.png', { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + // Let the fire-and-forget imageAction promise settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(getPathForFile).toHaveBeenCalledWith(file); + // A `![loading-id](/abs/shot.png)` placeholder paragraph was inserted. + expect(createdBlocks).toHaveLength(1); + expect(createdBlocks[0].text).toMatch(/^!\[loading-[^\]]+\]\(\/abs\/shot\.png\)$/); + // clientY in the top half → inserted above the anchor. + expect(createdBlocks[0].insertedBefore).toBe(true); + // imageAction persisted the file per the embedder preference. + expect(imageAction).toHaveBeenCalledWith({ + src: '/abs/shot.png', + alt: 'shot.png', + title: '', + }); + }); + + it('inserts a clean `![name](path)` (no loading placeholder) when imageAction is absent', () => { + const getPathForFile = vi.fn().mockReturnValue('/abs/shot.png'); + const muya = makeMuya({ getPathForFile }); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + const file = new File(['x'], 'shot.png', { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + + // Raw path is used verbatim — no permanent `loading-*` alt is left. + expect(createdBlocks).toHaveLength(1); + expect(createdBlocks[0].text).toBe('![shot.png](/abs/shot.png)'); + }); + + it('does nothing when getPathForFile yields no path', () => { + const imageAction = vi.fn().mockResolvedValue('x'); + const getPathForFile = vi.fn().mockReturnValue(''); + const muya = makeMuya({ imageAction, getPathForFile }); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + const file = new File(['x'], 'shot.png', { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + + expect(createdBlocks).toHaveLength(0); + expect(imageAction).not.toHaveBeenCalled(); + }); +}); + +// A browser image drag carries `text/uri-list` + `text/html` and (crucially) +// NO `text/plain`; a plain hyperlink drag additionally carries `text/plain`. +// The handler gates on that signature so it intercepts only likely images. +function webImageDataTransfer(url: string): DataTransfer { + const dt = new DataTransfer(); + dt.items.add(url, 'text/uri-list'); + dt.items.add(``, 'text/html'); + return dt; +} + +describe('attachDragDropImageHandlers — web-link image', () => { + it('inserts `![](url)` for an image URL dragged from a browser', () => { + const muya = makeMuya(); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + const dt = webImageDataTransfer('https://example.com/pic.png'); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + + // happy-dom fires getAsString synchronously and the URL has an image + // extension, so the block is inserted within the same tick. + expect(createdBlocks).toHaveLength(1); + expect(createdBlocks[0].text).toBe('![](https://example.com/pic.png)'); + expect(createdBlocks[0].insertedBefore).toBe(true); + }); + + it('ignores a non-image URL even with the web-image signature', () => { + const muya = makeMuya(); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + // Well-formed signature, but the URL is not an image and the + // content-type sniff (HEAD fetch) fails under happy-dom → no insert. + const dt = webImageDataTransfer('https://example.com/page.html'); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + + expect(createdBlocks).toHaveLength(0); + }); + + it('ignores a plain hyperlink drag (uri-list + text/plain, no html)', () => { + const muya = makeMuya(); + const contentDom = makeDropTarget(muya); + attachDragDropImageHandlers(muya as unknown as Muya); + + // The "dragged a normal link" shape — must be left to the browser. + const dt = new DataTransfer(); + dt.items.add('https://example.com/pic.png', 'text/uri-list'); + dt.items.add('https://example.com/pic.png', 'text/plain'); + + muya.domNode.dispatchEvent(dropEvent(contentDom, dt)); + + expect(createdBlocks).toHaveLength(0); + }); +}); + +describe('attachDragDropImageHandlers — drop target resolution', () => { + it('inserts nothing when the drop is not over an editor content block', () => { + const getPathForFile = vi.fn().mockReturnValue('/abs/shot.png'); + const muya = makeMuya({ imageAction: vi.fn(), getPathForFile }); + attachDragDropImageHandlers(muya as unknown as Muya); + + // Drop target is a bare div with no `.mu-content` ancestor. + const stray = document.createElement('div'); + muya.domNode.appendChild(stray); + const file = new File(['x'], 'shot.png', { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + + muya.domNode.dispatchEvent(dropEvent(stray, dt)); + + expect(createdBlocks).toHaveLength(0); + expect(getPathForFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/muya/src/editor/dragDropImage.ts b/packages/muya/src/editor/dragDropImage.ts new file mode 100644 index 0000000000..aefcc5b75d --- /dev/null +++ b/packages/muya/src/editor/dragDropImage.ts @@ -0,0 +1,300 @@ +import type Format from '../block/base/format'; +import type Parent from '../block/base/parent'; +import type { Muya } from '../muya'; +import { ScrollPage } from '../block/scrollPage'; +import { IMAGE_EXT_REG, URL_REG } from '../config'; +import { findContentDOM } from '../selection/dom'; +import { getUniqueId } from '../utils'; +import { getBlock, query } from '../utils/dom'; +import { checkImageContentType, getImageInfo, getImageSrc } from '../utils/image'; +import logger from '../utils/logger'; + +const debug = logger('editor:dragDropImage:'); + +// Port of marktext `src/muya/lib/eventHandler/dragDrop.js` + +// `contentState/dragDropCtrl.js`. The legacy engine bound +// dragover/drop/dragleave/dragstart on the editor container and inserted a +// dropped image — either a web-link image (`text/uri-list`) or a local image +// FILE — as a new `![](src)` paragraph block. The TS rewrite shipped without +// any DnD handler (#4406 parity gap PG4); this module restores it. +// +// Two drop paths, mirroring the legacy controller: +// - web-link image (`text/uri-list`) → verify it is an image, then insert +// `![](url)` verbatim. +// - local image FILE (`dataTransfer.files`) → resolve the file to a path via +// the embedder `getPathForFile` hook, insert a `![loading-id](path)` +// placeholder, persist it through the `imageAction` option (copy-to-assets +// / upload), then swap in the returned src. +// +// Cleanup: every listener is attached via `eventCenter.attachDOMEvent`, so +// `muya.destroy()` → `eventCenter.detachAllDomEvents()` removes them. + +const GHOST_ID = 'mu-dragover-ghost'; +const GHOST_HEIGHT = 3; + +interface IDropTarget { + anchor: Parent; + position: 'up' | 'down'; +} + +function hideGhost(): void { + const ghost = document.querySelector(`#${GHOST_ID}`); + ghost && ghost.remove(); +} + +// Above-or-below decision relative to the anchor block's vertical midpoint. +function verticalPosition(event: DragEvent, rect: DOMRect): 'up' | 'down' { + return event.clientY > rect.top + rect.height / 2 ? 'down' : 'up'; +} + +// A single dragged image FILE: exactly one item, of an image MIME type. +function isImageFileDrag(dataTransfer: DataTransfer): boolean { + return ( + dataTransfer.items.length === 1 + && dataTransfer.items[0].type.includes('image') + ); +} + +// The "image dragged from a browser" signature: a `text/uri-list` item that +// also carries `text/html` but NOT `text/plain`. Mirrors legacy muyajs +// (`dragDropCtrl.js` dragoverHandler) so that dragging a plain hyperlink — which +// carries `text/uri-list` + `text/plain` — is left to the browser instead of +// being intercepted and swallowed. +function isWebImageDrag(dataTransfer: DataTransfer): boolean { + const items = Array.from(dataTransfer.items); + const hasUri = items.some(i => i.type === 'text/uri-list'); + const hasHtml = items.some(i => i.type === 'text/html'); + const hasText = items.some(i => i.type === 'text/plain'); + + return hasUri && hasHtml && !hasText; +} + +// Resolve the drop target to an outermost block + insert position. Returns +// `null` when the pointer is not over an editor content block. +function resolveDropTarget(event: DragEvent): IDropTarget | null { + const contentDom = findContentDOM(event.target as Node | null); + if (!contentDom) + return null; + + const block = getBlock(contentDom); + const anchor = block?.outMostBlock; + if (!anchor || !anchor.domNode) + return null; + + const rect = anchor.domNode.getBoundingClientRect(); + + return { anchor, position: verticalPosition(event, rect) }; +} + +// Draw the horizontal drop indicator at the anchor block's top/bottom edge. +function drawGhost(target: IDropTarget): void { + const rect = target.anchor.domNode!.getBoundingClientRect(); + let ghost = document.querySelector(`#${GHOST_ID}`); + if (!ghost) { + ghost = document.createElement('div'); + ghost.id = GHOST_ID; + document.body.appendChild(ghost); + } + + Object.assign(ghost.style, { + width: `${rect.width}px`, + left: `${rect.left}px`, + top: + target.position === 'up' + ? `${rect.top - GHOST_HEIGHT}px` + : `${rect.top + rect.height}px`, + }); +} + +// Insert a `![alt](src)` image as a new paragraph block above/below the +// target anchor, place the cursor inside it, and return the new block. +function insertImageParagraph( + muya: Muya, + target: IDropTarget, + text: string, +): Parent { + const state = { name: 'paragraph', text }; + const imageBlock = ScrollPage.loadBlock('paragraph').create(muya, state); + const { anchor, position } = target; + + if (position === 'up') + anchor.parent!.insertBefore(imageBlock, anchor); + else + anchor.parent!.insertAfter(imageBlock, anchor); + + imageBlock.firstContentInDescendant()?.setCursor(0, 0, true); + + return imageBlock; +} + +// Drop path 1 — a web-link image carried as `text/uri-list`. Verify the URL +// resolves to an image (by extension, or by content-type sniff), then insert +// `![](url)` verbatim. +function handleWebLinkImage( + muya: Muya, + event: DragEvent, + target: IDropTarget, +): boolean { + const items = Array.from(event.dataTransfer?.items ?? []); + const uriItem = items.find( + item => item.kind === 'string' && item.type === 'text/uri-list', + ); + if (!uriItem) + return false; + + uriItem.getAsString(async (url) => { + if (!URL_REG.test(url)) + return; + + const isImage + = IMAGE_EXT_REG.test(url) || (await checkImageContentType(url)); + if (!isImage) + return; + + insertImageParagraph(muya, target, `![](${url})`); + }); + + return true; +} + +// Replace a `![loading-id](path)` placeholder with the persisted src returned +// by `imageAction`. Mirrors the imageEditTool upload flow. +async function persistDroppedImage( + muya: Muya, + path: string, + name: string, + loadingId: string, +): Promise { + const { imageAction } = muya.options; + if (!imageAction) + return; + + try { + const newSrc = await imageAction({ src: path, alt: name, title: '' }); + const { src } = getImageSrc(path); + if (src) + muya.editor.inlineRenderer.renderer.urlMap.set(newSrc, src); + + const imageWrapper = query( + `span[data-id=${loadingId}]`, + muya.domNode, + ); + if (imageWrapper) { + const imageInfo = getImageInfo(imageWrapper); + const block = getBlock( + findContentDOM(imageWrapper), + ) as Format | undefined; + block?.replaceImage(imageInfo, { alt: name, src: newSrc }); + } + } + catch (error) { + debug.warn(`Unexpected error on image action: ${String(error)}`); + } +} + +// Drop path 2 — a local image FILE. Resolve it to a path via the embedder +// `getPathForFile` hook, then insert it. +// +// When an `imageAction` hook is configured we insert a `![loading-id](path)` +// placeholder and let `persistDroppedImage` swap in the persisted src once the +// hook resolves (copy-to-assets / upload). Without the hook there is nothing to +// persist to, so we insert a clean `![name](path)` with the raw path verbatim — +// matching the documented `imageAction` contract and the imageEditTool's +// direct-replacement behaviour (a permanent `loading-*` alt would otherwise be +// left behind). +function handleFileImage( + muya: Muya, + event: DragEvent, + target: IDropTarget, +): boolean { + const files = Array.from(event.dataTransfer?.files ?? []); + const image = files.find(file => /image/.test(file.type)); + if (!image) + return false; + + const path = muya.options.getPathForFile?.(image); + if (!path) + return false; + + const { name } = image; + + if (!muya.options.imageAction) { + insertImageParagraph(muya, target, `![${name}](${path})`); + return true; + } + + const loadingId = `loading-${getUniqueId()}`; + insertImageParagraph(muya, target, `![${loadingId}](${path})`); + + void persistDroppedImage(muya, path, name, loadingId); + + return true; +} + +export function attachDragDropImageHandlers(muya: Muya): void { + const { eventCenter, domNode } = muya; + + // Prevent the browser from starting its own image drag inside the editor; + // it would otherwise open the dragged image as a navigation. + const dragStartHandler = (event: Event) => { + if ((event.target as HTMLElement)?.tagName === 'IMG') + event.preventDefault(); + }; + + const dragOverHandler = (event: Event) => { + const dragEvent = event as DragEvent; + const { dataTransfer } = dragEvent; + if (!dataTransfer) + return; + + // Only intercept a single image file or a likely web-image drag; leave + // everything else (plain hyperlinks, tab reordering, text) to the + // browser so we never suppress an unrelated default drop. + if (!isImageFileDrag(dataTransfer) && !isWebImageDrag(dataTransfer)) + return; + + const target = resolveDropTarget(dragEvent); + if (!target) { + hideGhost(); + return; + } + + event.preventDefault(); + dataTransfer.dropEffect = 'copy'; + drawGhost(target); + }; + + const dropHandler = (event: Event) => { + const dragEvent = event as DragEvent; + const { dataTransfer } = dragEvent; + if (!dataTransfer) + return; + + hideGhost(); + const target = resolveDropTarget(dragEvent); + if (!target) + return; + + // Try the file path first (a dropped image file also exposes a + // synthetic `text/uri-list`, but the file branch is the intended one). + // Only fall through to the web-link branch for the likely-web-image + // signature, so a plain hyperlink drop is left to the browser rather + // than suppressed by `preventDefault()`. + let inserted = handleFileImage(muya, dragEvent, target); + if (!inserted && isWebImageDrag(dataTransfer)) + inserted = handleWebLinkImage(muya, dragEvent, target); + + if (inserted) + event.preventDefault(); + }; + + const dragLeaveHandler = () => hideGhost(); + + eventCenter.attachDOMEvent(domNode, 'dragstart', dragStartHandler); + eventCenter.attachDOMEvent(domNode, 'dragover', dragOverHandler); + eventCenter.attachDOMEvent(domNode, 'drop', dropHandler); + // Legacy muyajs bound `dragleave` on `window` to clear the ghost when the + // pointer leaves the page; `document` bubbles the same event and is within + // `attachDOMEvent`'s accepted target union. + eventCenter.attachDOMEvent(document, 'dragleave', dragLeaveHandler); +} diff --git a/packages/muya/src/editor/index.ts b/packages/muya/src/editor/index.ts index f49b43c71a..a40302eeb5 100644 --- a/packages/muya/src/editor/index.ts +++ b/packages/muya/src/editor/index.ts @@ -19,6 +19,7 @@ import JSONState from '../state'; import { hasPick, isHTMLElement } from '../utils'; import { getBlock } from '../utils/dom'; import logger from '../utils/logger'; +import { attachDragDropImageHandlers } from './dragDropImage'; import { attachLinkMouseHandlers } from './linkMouseEvents'; const debug = logger('editor:'); @@ -74,6 +75,10 @@ export class Editor { // dispatches `muya-link-tools` so the staged popover lights up. // Cleanup is handled by `muya.destroy()` → `detachAllDomEvents`. attachLinkMouseHandlers(muya); + // marktext PG4 (#4406 follow-up): dropping an image file or web-link + // image into the editor inserts it as a new `![](src)` block. Cleanup + // is likewise handled by `detachAllDomEvents`. + attachDragDropImageHandlers(muya); this.focus(); } diff --git a/packages/muya/src/types.ts b/packages/muya/src/types.ts index 4860cd1823..ea155974b0 100644 --- a/packages/muya/src/types.ts +++ b/packages/muya/src/types.ts @@ -56,14 +56,27 @@ export interface IMuyaOptions { * resolve to the src that should be written into the document. * * Invoked on paste — both when a clipboard FILE path is resolved (PG06) - * and when an in-memory bitmap is read from `clipboardData` (PG05) — and - * by the image-edit toolbar. `src` is an absolute local path (or a - * `data:` URL for a freshly pasted bitmap). Returning the original `src` - * keeps the path as-is. + * and when an in-memory bitmap is read from `clipboardData` (PG05) — by + * the image-edit toolbar, and by the drag-and-drop image handler (PG04), + * so a dropped local image file is persisted exactly like one inserted + * through the toolbar. `src` is an absolute local path (or a `data:` URL + * for a freshly pasted bitmap). Returning the original `src` keeps the + * path as-is; omitting the hook uses the raw `src` verbatim. * * Ported from the legacy `@muyajs` `imageAction` option. */ imageAction?: (state: IImageActionState) => Promise; + /** + * Resolve a dropped `File` to a local filesystem path. + * + * The DnD `DataTransfer` exposes a `File` object but not its on-disk + * path; only the embedder (e.g. Electron's `webUtils.getPathForFile`) + * can resolve it. Provide this hook to enable dropping a local image + * file into the document. Return `''` when no path is available. + * + * Ported from the legacy `@muyajs` direct `webUtils.getPathForFile` call. + */ + getPathForFile?: (file: File) => string; } /** From 073625237ec61880635af27ee368e80f18322c40 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Tue, 9 Jun 2026 03:09:03 +0800 Subject: [PATCH 9/9] feat(desktop): wire @muyajs/core parity APIs (menu state, copyAsRich, heading-link, source cursor, saved indicator) (#4415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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 `#` 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/editorWithTabs/editor.vue | 171 ++++++++++++++---- .../desktop/src/renderer/src/store/editor.ts | 4 +- .../desktop/src/renderer/src/store/help.ts | 11 +- packages/desktop/src/renderer/src/util/pdf.ts | 53 ++++-- packages/desktop/test/PARITY_SCOREBOARD.md | 95 ++++++---- .../test/e2e/parity-pg1-menu-state.spec.ts | 15 +- .../test/e2e/parity-source-undo-saved.spec.ts | 26 +-- .../src/__tests__/setCursorByOffset.spec.ts | 139 ++++++++++++++ packages/muya/src/muya.ts | 46 +++++ packages/muya/src/selection/offsetCursor.ts | 168 +++++++++++++++++ 10 files changed, 615 insertions(+), 113 deletions(-) create mode 100644 packages/muya/src/__tests__/setCursorByOffset.spec.ts create mode 100644 packages/muya/src/selection/offsetCursor.ts diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index a235acf12f..0e2ec1b733 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -271,14 +271,24 @@ let scrollHandler: ((e: Event) => void) | null = null // the desktop store's `tab.history` (which drives the save/dirty tracking and // is migrated separately). We therefore keep the real engine history in a // per-tab map here for restoration across in-session tab switches, and feed the -// store a SYNTHETIC desktop-shaped history whose entry id changes on every edit -// so the store's `isSaved` logic keeps flipping correctly. +// store a SYNTHETIC desktop-shaped history. +// +// The synthetic entry id is the engine undo-stack DEPTH — a stable position +// marker, NOT an ever-incrementing counter. The store records the save-time id +// as `lastSavedHistoryId` and clears the dirty indicator whenever the current +// id matches it again. Using the undo-stack depth means undoing back to the +// on-disk content returns the id to its saved value, so the saved/clean +// indicator is restored (parity with the legacy history-index behaviour). An +// ever-incrementing counter never matched again after an undo, leaving the tab +// permanently dirty even when its content matched disk. const engineHistoryByTab = new Map() -let editSeq = 0 -const makeSyntheticHistory = (): IFileHistoryLike => { - editSeq += 1 +const engineUndoDepth = (history: unknown): number => { + const stack = (history as { stack?: { undo?: unknown[] } } | null)?.stack + return Array.isArray(stack?.undo) ? stack.undo.length : 0 +} +const makeSyntheticHistory = (engineHistory: unknown): IFileHistoryLike => { return { - stack: [{ id: editSeq }], + stack: [{ id: engineUndoDepth(engineHistory) }], index: 0, lastEditIndex: 0, lastInitIndex: -1 @@ -297,38 +307,83 @@ interface SelectionFormatLike { [key: string]: unknown } -// The engine's `selection-change` payload is keyed by `anchor`/`focus` + -// `anchorPath`/`focusPath` and carries live block refs. The desktop's -// application-menu state builder (`createApplicationMenuState`) was written -// against the legacy `{ start, end, affiliation }` shape, so adapt it. Only -// `start`/`end` (key + offset + the active content block) are derivable from -// the new payload; the rich block `affiliation` chain is not surfaced by the -// engine yet, so it degrades to empty (block-context menu toggles like -// list/table awareness are a documented gap — format toggles still work via -// `formats`). +// Container `blockName` → legacy `functionType`. The engine's affiliation +// entries carry `blockName` but not the legacy `functionType` the desktop +// menu-state builder keys off for `pre`/`figure` containers (table detection + +// Format-menu disable). Re-derive it here so `createApplicationMenuState`'s +// existing `pre`/`figure` branches fire. The `code$` / `multiplemath` / +// `frontmatter` / `html` / `table` values match the legacy muyajs vocabulary +// (`createApplicationMenuState`'s `/frontmatter|html|multiplemath|code$/` test +// and `=== 'table'` check). +const CONTAINER_FUNCTION_TYPE: Record = { + 'code-block': 'fencecode', + frontmatter: 'frontmatter', + table: 'table', + 'html-block': 'html', + 'math-block': 'multiplemath' +} + +interface EngineAffiliationEntry { + type: string + blockName: string + listType?: string + listItemType?: string + isLooseListItem?: boolean + [key: string]: unknown +} + +// The engine's `selection-change` payload (since #4410) carries an +// `affiliation` chain (shared-ancestor paragraph-type blocks, outermost-first) +// plus per-endpoint `anchorBlockInfo`/`focusBlockInfo` describing the content +// leaf (`type: 'span'` + `functionType`), alongside the live `anchorBlock`/ +// `focusBlock` refs (which carry `.text`). The desktop's application-menu state +// builder (`createApplicationMenuState`) and the selected-text derivation in +// `SELECTION_CHANGE` were written against the legacy `{ start, end, affiliation }` +// shape, so map the new payload onto it: +// - `start.type`/`end.type` from the leaf info (`'span'`) so the +// `start.type === 'span'` guards fire, +// - `start.block.functionType`/`end.block.functionType` from the leaf info so +// code-content / table-cell detection lights up, +// - `start.block.text`/`end.block.text` from the live block so the store can +// still slice the selected text (`SELECTION_CHANGE` → search prefill), +// - `affiliation` straight through (entries already carry `type` + +// `listType`/`listItemType`/`isLooseListItem`), surfacing a derived +// `functionType` on `pre`/`figure` containers for table / code-fence keys. const adaptSelectionChange = (changes: MuyaChange) => { const anchorPath = (changes.anchorPath ?? []) as Array const focusPath = (changes.focusPath ?? anchorPath) as Array - const anchorBlock = changes.anchorBlock as - | { text?: string; functionType?: string } + const anchorBlock = changes.anchorBlock as { text?: string } | null | undefined + const focusBlock = changes.focusBlock as { text?: string } | null | undefined + const anchorInfo = changes.anchorBlockInfo as + | { type?: string; functionType?: string } + | null | undefined - const focusBlock = changes.focusBlock as - | { text?: string; functionType?: string } + const focusInfo = changes.focusBlockInfo as + | { type?: string; functionType?: string } + | null | undefined + const rawAffiliation = (changes.affiliation ?? []) as EngineAffiliationEntry[] + const affiliation = rawAffiliation.map((entry) => { + const functionType = + entry.type === 'pre' || entry.type === 'figure' + ? CONTAINER_FUNCTION_TYPE[entry.blockName] + : undefined + return functionType ? { ...entry, functionType } : entry + }) return { start: { key: anchorPath.join('/'), offset: (changes.anchor?.offset ?? 0) as number, - block: anchorBlock, - type: changes.type as string | undefined + block: { text: anchorBlock?.text, functionType: anchorInfo?.functionType }, + type: anchorInfo?.type }, end: { key: focusPath.join('/'), offset: (changes.focus?.offset ?? 0) as number, - block: focusBlock, - type: changes.type as string | undefined + block: { text: focusBlock?.text, functionType: focusInfo?.functionType }, + type: focusInfo?.type }, - affiliation: [] as Array<{ type: string; [key: string]: unknown }> + affiliation } } @@ -935,10 +990,11 @@ const handleSelectAll = () => { } // Custom copyAsRich copyAsHtml pasteAsPlainText. -// The engine has no `copyAsRich`; the legacy "copy as rich text" maps to -// `copyAsHtml` (copies the rendered HTML of the selection). -const COPY_PASTE_METHOD_MAP: Record = { - copyAsRich: 'copyAsHtml', +// `copyAsRich` writes the rendered HTML to `text/html` AND the plain text to +// `text/plain`, so pasting into Word/email yields formatted rich text (whereas +// `copyAsHtml` blanks `text/html` and puts the HTML source into `text/plain`). +const COPY_PASTE_METHOD_MAP: Record = { + copyAsRich: 'copyAsRich', copyAsHtml: 'copyAsHtml', pasteAsPlainText: 'pasteAsPlainText' } @@ -1255,12 +1311,29 @@ interface FileChangePayload { blocks?: unknown } +// A source-mode (CodeMirror) index cursor: `{ anchor, focus }` in `{ line, ch }` +// coordinates. Produced by sourceCode.vue and carried on `file-changed` as +// `muyaIndexCursor` when handing a tab back to WYSIWYG. Both `line` AND `ch` +// must be present numbers — otherwise the engine would clamp a missing `ch` to +// 0 and silently restore the caret to the wrong column. +const isIndexPosition = (pos: unknown): pos is { line: number; ch: number } => { + const p = pos as { line?: unknown; ch?: unknown } | null + return !!p && typeof p.line === 'number' && typeof p.ch === 'number' +} +const isIndexCursor = ( + cursor: unknown +): cursor is { anchor: { line: number; ch: number }; focus: { line: number; ch: number } } => { + const c = cursor as { anchor?: unknown; focus?: unknown } | null + return !!c && isIndexPosition(c.anchor) && isIndexPosition(c.focus) +} + // listen for markdown change form source mode or change tabs etc const handleFileChange = (payload: unknown) => { const { id, markdown: newMarkdown, cursor: newCursor, + muyaIndexCursor, scrollTop } = (payload ?? {}) as FileChangePayload if (!editor.value) return @@ -1273,13 +1346,32 @@ const handleFileChange = (payload: unknown) => { // in-session tab switch. The `history` in the payload is the synthetic // desktop-shaped history used for save tracking, not the engine history. editor.value.setContent(newMarkdown) + if (newCursor) { + editor.value.setCursor(newCursor) + } else if (isIndexCursor(muyaIndexCursor)) { + // Coming back from source-code mode the tab only has a CodeMirror + // `{ line, ch }` index cursor; map it onto a block-key cursor so the + // WYSIWYG caret lands where the source-mode cursor was (PG2). The engine + // runs its own setContent dance internally, so restore the history after. + editor.value.setCursorByOffset(muyaIndexCursor) + } const savedEngineHistory = id ? engineHistoryByTab.get(id) : undefined if (savedEngineHistory) { editor.value.setHistory(savedEngineHistory) } - if (newCursor) { - editor.value.setCursor(newCursor) - } + // PARITY (gap PG14 — accept-defer): the bulk source-mode change is rebuilt + // via `setContent` and is NOT recorded as an engine undo op, so the first + // Ctrl+Z after exiting source mode replays the last pre-source WYSIWYG op + // instead of reverting the source-mode edit in one step (legacy muyajs + // pushed a full-state snapshot that made it a single undo boundary). + // Recording it as one boundary would mean computing a json1 op from the + // pre-source state to the post-source state and feeding it through + // `Editor.updateContents`' pick/drop walker — but that walker only handles + // specific op shapes (block insert at index, text edit, checked/meta), so a + // general whole-document diff (arbitrary add/remove/move/nested-replace) + // risks corrupting the document. Deferred rather than ship a fragile fix; + // the prior op stack is intact and undo still works, only the first-undo + // granularity across the boundary differs. } else if (newCursor) { editor.value.setCursor(newCursor) } @@ -1493,8 +1585,10 @@ onMounted(() => { const { id } = currentFile.value if (!id) return const markdown = editor.value.getMarkdown() - // Stash the real engine history for in-session tab-switch restoration. - engineHistoryByTab.set(id, editor.value.getHistory()) + // Stash the real engine history for in-session tab-switch restoration, and + // derive the synthetic save-tracking id from its undo-stack depth. + const engineHistory = editor.value.getHistory() + engineHistoryByTab.set(id, engineHistory) editorStore.LISTEN_FOR_CONTENT_CHANGE({ id, markdown, @@ -1502,7 +1596,7 @@ onMounted(() => { cursor: serializeCursor(editor.value.getSelection()), // Synthetic, desktop-shaped history so the store's save/dirty tracking // keeps working (the engine history shape is incompatible). - history: makeSyntheticHistory(), + history: makeSyntheticHistory(engineHistory), toc: editor.value.getTOC(), blocks: editor.value.getState() }) @@ -1517,9 +1611,12 @@ onMounted(() => { } container.addEventListener('scroll', scrollHandler, { passive: true }) - // NOTE (gap): the engine does not emit `heading-copy-link` yet, so the - // hover-to-copy-heading-anchor affordance is unavailable. `scroll-to-header` - // (TOC navigation) and `copyGithubSlug` (via the command/menu) still work. + // Clicking the hover-to-copy affordance on a heading emits `heading-copy-link` + // with the heading's stable slug; copy the matching GitHub anchor to the + // clipboard (resolved via `listToc.find(i => i.slug === key)`). + editor.value.on('heading-copy-link', ({ key }: { key: string }) => { + editorStore.copyGithubSlug(key) + }) editor.value.on( 'format-click', diff --git a/packages/desktop/src/renderer/src/store/editor.ts b/packages/desktop/src/renderer/src/store/editor.ts index a15036d838..530bb73f0b 100644 --- a/packages/desktop/src/renderer/src/store/editor.ts +++ b/packages/desktop/src/renderer/src/store/editor.ts @@ -1830,7 +1830,9 @@ const createApplicationMenuState = ({ if (aff.length >= 1 && /ul|ol/.test(aff[0].type)) { const listBlock = aff[0] state.affiliation[listBlock.type] = true - state.isLooseListItem = !!listBlock.children?.[0]?.isLooseListItem + // The engine's affiliation entry carries the loose flag on the list block + // itself (derived from `meta.loose`), not via a `children` chain. + state.isLooseListItem = !!listBlock.isLooseListItem state.isTaskList = listBlock.listType === 'task' } else if (aff.length >= 3 && aff[1].type === 'li') { const listItem = aff[1] diff --git a/packages/desktop/src/renderer/src/store/help.ts b/packages/desktop/src/renderer/src/store/help.ts index 522c188d38..68a06777ca 100644 --- a/packages/desktop/src/renderer/src/store/help.ts +++ b/packages/desktop/src/renderer/src/store/help.ts @@ -119,7 +119,12 @@ export const getBlankFileState = ( id, filename: `${defaultFilenamePrefix}-${++untitleId}`, markdown, - lastSavedHistoryId: -1 + // The freshly-loaded document IS its on-disk/clean baseline. The engine + // clears its undo history on `setContent`, so the baseline undo-stack depth + // (the synthetic save-tracking id) is 0. Seeding `lastSavedHistoryId` to 0 + // (not -1) lets the dirty indicator clear again when an edit is undone back + // to this baseline, even before the document has ever been saved. + lastSavedHistoryId: 0 }) as IFileState } @@ -143,7 +148,9 @@ export const createDocumentState = ( return Object.assign(docState, { id, - lastSavedHistoryId: -1 + // See `getBlankFileState`: the loaded document is its own clean baseline and + // the engine's baseline undo-stack depth (the synthetic id) is 0. + lastSavedHistoryId: 0 }) as IFileState } diff --git a/packages/desktop/src/renderer/src/util/pdf.ts b/packages/desktop/src/renderer/src/util/pdf.ts index 7507e119c2..65cc916f41 100644 --- a/packages/desktop/src/renderer/src/util/pdf.ts +++ b/packages/desktop/src/renderer/src/util/pdf.ts @@ -1,14 +1,11 @@ // `escapeHTML`/`unescapeHTML` are migrated to @muyajs/core (identical impl). -import { escapeHTML, unescapeHTML } from '@muyajs/core' -// NOTE: `Slugger` is intentionally still sourced from the legacy muyajs engine. -// The TOC anchors produced here (`#${slugger.slug(content)}`) must match the -// heading `id` attributes in the exported document, and those ids are emitted -// by the muyajs export renderer (`Muya#exportStyledHTML`) via the SAME Slugger. -// editor.vue — and therefore the export render path — is still on muyajs in -// this PR, so swapping to @muyajs/core's `generateGithubSlug` (a different -// algorithm with no dedup/unicode downcoding) would break in-document TOC -// links. This import moves to @muyajs/core together with the editor.vue swap. -import Slugger from 'muya/lib/parser/marked/slugger' +// The TOC anchors produced here (`#${slug}`) must match the heading `id` +// attributes in the exported document. Now that editor.vue exports via +// @muyajs/core (#4406) and the engine injects github-compatible heading ids +// (#4412), this module derives its slugs from the SAME `generateGithubSlug` +// algorithm, with the SAME `-N` document-order dedup the engine uses, so the +// in-document TOC links resolve. +import { escapeHTML, unescapeHTML, generateGithubSlug } from '@muyajs/core' import academicTheme from '@/assets/themes/export/academic.theme.css?inline' import liberTheme from '@/assets/themes/export/liber.theme.css?inline' import { deepClone } from '../util' @@ -128,10 +125,29 @@ export interface HtmlTocOptions { [key: string]: unknown } +// Replicate @muyajs/core's `MarkdownToHtml#_injectHeadingIds` slugging so the +// TOC `href="#slug"` anchors target the exact ids the engine writes onto the +// exported `

..

`: github-compatible base slug (falling back to +// `heading` when the text slugs to empty), deduplicated in document order with +// an incrementing `-N` suffix. Computed over the FULL heading list in order +// (before the render-time filtering below) to keep the dedup sequence aligned +// with the engine's whole-document pass. +const assignHeadingSlugs = (tocList: TocEntry[]): void => { + const seen = new Set() + for (const entry of tocList) { + const base = generateGithubSlug(entry.content) || 'heading' + let slug = base + let n = 1 + while (seen.has(slug)) { + slug = `${base}-${n++}` + } + seen.add(slug) + entry.slug = slug + } +} + const generateHtmlToc = ( tocList: TocEntry[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slugger: any, currentLevel: number, options: HtmlTocOptions ): string => { @@ -142,30 +158,29 @@ const generateHtmlToc = ( const topLevel = tocList[0].lvl if (!options.tocIncludeTopHeading && topLevel <= 1) { tocList.shift() - return generateHtmlToc(tocList, slugger, currentLevel, options) + return generateHtmlToc(tocList, currentLevel, options) } else if (topLevel <= currentLevel) { return '' } const shifted = tocList.shift() as TocEntry - const { content, lvl } = shifted - const slug = slugger.slug(content) + const { content, lvl, slug } = shifted let html = `
  • ${content}` // Generate sub-items if (tocList.length !== 0 && tocList[0].lvl > lvl) { - html += '
      ' + generateHtmlToc(tocList, slugger, lvl, options) + '
    ' + html += '
      ' + generateHtmlToc(tocList, lvl, options) + '
    ' } - html += '
  • ' + generateHtmlToc(tocList, slugger, currentLevel, options) + html += '' + generateHtmlToc(tocList, currentLevel, options) return html } export const getHtmlToc = (toc: TocEntry[], options: HtmlTocOptions = {}): string => { const list = deepClone(toc) - const slugger = new Slugger() - const tocList = generateHtmlToc(list, slugger, 0, options) + assignHeadingSlugs(list) + const tocList = generateHtmlToc(list, 0, options) if (!tocList) { return '' } diff --git a/packages/desktop/test/PARITY_SCOREBOARD.md b/packages/desktop/test/PARITY_SCOREBOARD.md index 3dce390144..02d9300652 100644 --- a/packages/desktop/test/PARITY_SCOREBOARD.md +++ b/packages/desktop/test/PARITY_SCOREBOARD.md @@ -2,20 +2,29 @@ This is a **failing-test scoreboard**. The desktop app migrated from the legacy `packages/muyajs` engine to `@muyajs/core` (`packages/muya`) in PR #4406. That -migration left **15 confirmed functional-parity gaps**. This board encodes each -one as a regression test that **fails on `develop` today** (proving the gap) but -is marked as an *expected failure* so the test suites stay GREEN. +migration left **15 confirmed functional-parity gaps**. This board encoded each +one as a regression test that *failed on `develop`* (proving the gap), marked as +an *expected failure* so the suites stayed GREEN. **14 of the 15 are now fixed** +(seven Wave-1 engine PRs #4408–#4414 + the Wave-2 desktop consumer wiring); the +xfail markers were removed as each landed and the tests now assert the correct +behaviour directly. **PG14 alone remains xfail** (accept-defer — see its row). ## How it works +The board started fully xfail and is now almost entirely flipped to real +assertions (only PG14 remains xfail). The mechanism, for the one remaining gap +and any future ones: + - **muya engine unit tests** (`packages/muya/src/**/__tests__/parity*.spec.ts`) - use vitest `it.fails(...)`: the assertion describes the correct - (pre-migration) behaviour and fails today, which vitest counts as a *pass*. - When a fix lands and the behaviour becomes correct, the test starts passing - and `it.fails` then **errors** — forcing the fixer to delete `.fails`. + used vitest `it.fails(...)`: the assertion describes the correct + (pre-migration) behaviour and failed pre-fix, which vitest counts as a *pass*. + When a fix lands and the behaviour becomes correct, `it.fails` then **errors** + — forcing the fixer to delete `.fails`. All engine parity specs now use plain + `it` and pass. - **desktop e2e tests** (`packages/desktop/test/e2e/parity-*.spec.ts`) use - Playwright `test.fail()`: the test runs headless and currently fails, which - Playwright counts as a *pass*. When the fix lands, remove `test.fail()`. + Playwright `test.fail()`: the test runs headless and fails pre-fix, which + Playwright counts as a *pass*. When the fix lands, remove `test.fail()`. Only + PG14 still carries it. - **manual-QA** entries (`packages/desktop/test/PARITY_QA.md`) cover gaps that cannot be driven headless (real OS clipboard bitmaps, drag-and-drop gestures). @@ -32,45 +41,65 @@ is marked as an *expected failure* so the test suites stay GREEN. ## Scoreboard -> **Gaps remaining: 11 / 15.** PG4, PG5, PG6, PG9 closed on the engine side. -> PG4's drag-drop image handler is fixed and unit-tested (its local-file -> persistence path still needs the desktop wave-2 wiring noted in -> `PARITY_QA.md` § PG4); PG5's OS-clipboard delivery and PG9's desktop -> `copyAsRich` menu map likewise remain as wave-2 desktop work. +> **Gaps remaining: 1 / 15** (PG14, accept-defer). The other 14 are closed: +> the seven Wave-1 engine PRs (#4408–#4414) landed the engine halves, and the +> Wave-2 desktop PR wired the consumers (PG1 affiliation adapter, PG2 +> `setCursorByOffset`, PG8 pdf.ts slugger, PG9 `copyAsRich` map, PG11 +> `heading-copy-link` subscription, PG15 stable saved-id). PG4's local-file +> drag-drop persistence and PG5's OS-clipboard bitmap delivery still have +> manual-QA entries in `PARITY_QA.md` (cannot be driven headless), but their +> code paths are fixed and unit-tested. PG14 (single-undo-boundary across the +> source-mode handoff) is deferred — see its row. | Gap | Severity | Behaviour lost | Test location(s) | Mechanism | Status | |-----|----------|----------------|------------------|-----------|--------| -| **PG1** | major | `selection-change` lacks block affiliation / ancestor type → native Paragraph & Format menu state is dead | `packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts` (`PG1:` ×2) · `packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts` (`PG1:`) | `it.fails` + `test.fail()` | ❌ xfail | -| **PG2** | major | source-mode → WYSIWYG caret not restored (`handleFileChange` drops `muyaIndexCursor`) | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG2:`) | `test.fail()` | ❌ xfail | -| **PG3** | major | `autoCheck` preference not consumed (task-list checkbox cascade lost) | `packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts` (`PG3:` ×2) | `it.fails` | ❌ xfail | -| **PG4** | major | drag-drop image insertion (local file + web link) absent | `packages/muya/src/editor/__tests__/dragDropImage.spec.ts` (PG4 ×7) · `packages/desktop/test/PARITY_QA.md` § PG4 | unit (synthetic `DataTransfer`) + manual-QA | ✅ engine fixed | -| **PG5** | major | binary/bitmap clipboard image paste lost (screenshot, browser "Copy Image") | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG5:`) · `packages/desktop/test/PARITY_QA.md` § PG5 | passing `it` + manual-QA | ✅ engine fixed (OS-clipboard manual-QA remains) | -| **PG6** | major | pasted image FILE bypasses `imageAction` (copy-to-assets / upload preference ignored) | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG6:` ×2) | passing `it` | ✅ fixed | -| **PG7** | major | export loads core CSS from CDN instead of inlining it (unstyled offline) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG7:` ×2) | `it.fails` | ❌ xfail | -| **PG8** | major | exported headings carry no `id` (dead TOC / `[TOC]` anchors) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG8:` ×2) | `it.fails` | ❌ xfail | -| **PG9** | major | "Copy as Rich Text" pastes HTML *source* not rich text (no `copyAsRich` path) | `packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts` (`PG9:` ×2) | passing `it` | ✅ engine fixed (desktop `copyAsRich` map = wave 2) | -| **PG10** | minor | `preview-image` never emitted — select-image + Space full-screen preview lost | `packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts` (`PG10:` ×2) | `it.fails` | ❌ xfail | -| **PG11** | minor | `heading-copy-link` never emitted — hover-to-copy-anchor affordance gone | `packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts` (`PG11:` ×2) | `it.fails` | ❌ xfail | -| **PG12** | minor | `hideLinkPopup` preference not consumed — link hover popover not gated | `packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts` (`PG12:`) | `it.fails` (+ control) | ❌ xfail | -| **PG13** | minor | `insertParagraph` anchors to outermost not immediate block in nested structures | `packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts` (`PG13:` ×2) | `it.fails` | ❌ xfail | -| **PG14** | minor | first undo after source-mode doesn't revert the edit as one step | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG14:`) | `test.fail()` | ❌ xfail | -| **PG15** | minor | undo back to on-disk content doesn't restore the saved/clean indicator | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG15:`) | `test.fail()` | ❌ xfail | +| **PG1** | major | `selection-change` lacks block affiliation / ancestor type → native Paragraph & Format menu state is dead | `packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts` (`PG1:` ×2) · `packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts` (`PG1:`) | passing `it` + passing `test` | ✅ fixed (engine #4410 · desktop wave 2) | +| **PG2** | major | source-mode → WYSIWYG caret not restored (`handleFileChange` drops `muyaIndexCursor`) | `packages/muya/src/__tests__/setCursorByOffset.spec.ts` (`PG2:` ×5) · `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG2:`) | passing `it` + passing `test` | ✅ fixed (engine `setCursorByOffset` + desktop wave 2) | +| **PG3** | major | `autoCheck` preference not consumed (task-list checkbox cascade lost) | `packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts` (`PG3:` ×2) | passing `it` | ✅ engine fixed (#4409) | +| **PG4** | major | drag-drop image insertion (local file + web link) absent | `packages/muya/src/editor/__tests__/dragDropImage.spec.ts` (PG4 ×7) · `packages/desktop/test/PARITY_QA.md` § PG4 | unit (synthetic `DataTransfer`) + manual-QA | ✅ engine fixed (#4413) | +| **PG5** | major | binary/bitmap clipboard image paste lost (screenshot, browser "Copy Image") | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG5:`) · `packages/desktop/test/PARITY_QA.md` § PG5 | passing `it` + manual-QA | ✅ engine fixed #4411 (OS-clipboard manual-QA remains) | +| **PG6** | major | pasted image FILE bypasses `imageAction` (copy-to-assets / upload preference ignored) | `packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (`PG6:` ×2) | passing `it` | ✅ engine fixed (#4411) | +| **PG7** | major | export loads core CSS from CDN instead of inlining it (unstyled offline) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG7:` ×2) | passing `it` | ✅ engine fixed (#4412) | +| **PG8** | major | exported headings carry no `id` (dead TOC / `[TOC]` anchors) | `packages/muya/src/state/__tests__/parityExportHtml.spec.ts` (`PG8:` ×2) | passing `it` | ✅ fixed (engine #4412 · desktop pdf.ts slugger wave 2) | +| **PG9** | major | "Copy as Rich Text" pastes HTML *source* not rich text (no `copyAsRich` path) | `packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts` (`PG9:` ×2) | passing `it` | ✅ fixed (engine #4411 · desktop `copyAsRich` map wave 2) | +| **PG10** | minor | `preview-image` never emitted — select-image + Space full-screen preview lost | `packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts` (`PG10:` ×2) | passing `it` | ✅ engine fixed #4414 (desktop subscription already present) | +| **PG11** | minor | `heading-copy-link` never emitted — hover-to-copy-anchor affordance gone | `packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts` (`PG11:` ×2) | passing `it` | ✅ fixed (engine #4414 · desktop subscription wave 2) | +| **PG12** | minor | `hideLinkPopup` preference not consumed — link hover popover not gated | `packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts` (`PG12:`) | passing `it` (+ control) | ✅ engine fixed (#4409) | +| **PG13** | minor | `insertParagraph` anchors to outermost not immediate block in nested structures | `packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts` (`PG13:` ×2) | passing `it` | ✅ engine fixed (#4408) | +| **PG14** | minor | first undo after source-mode doesn't revert the edit as one step | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG14:`) | `test.fail()` | ❌ xfail (accept-defer) | +| **PG15** | minor | undo back to on-disk content doesn't restore the saved/clean indicator | `packages/desktop/test/e2e/parity-source-undo-saved.spec.ts` (`PG15:`) | passing `test` | ✅ desktop fixed (wave 2) | ### Severity tally -- **major:** PG1, PG2, PG3, PG4, PG5, PG6, PG7, PG8, PG9 (9) -- **minor:** PG10, PG11, PG12, PG13, PG14, PG15 (6) +- **major:** PG1, PG2, PG3, PG4, PG5, PG6, PG7, PG8, PG9 (9) — all fixed +- **minor:** PG10, PG11, PG12, PG13, PG14, PG15 (6) — all fixed except **PG14** + +### PG14 — why it is deferred + +On source-mode exit, `handleFileChange` rebuilds the document via `setContent` +(which `history.clear()`s) then restores the pre-source op stack, so the bulk +source-mode change is not recorded as a single engine undo op. Making it one +undo boundary would require computing a general whole-document `ot-json1` diff +(pre-source state → post-source state) and applying it through +`Editor.updateContents`' pick/drop walker. That walker only handles a fixed set +of op shapes (block insert at index, text edit, `checked`/`meta`); an arbitrary +diff (removes, moves, nested replaces) could mis-apply and corrupt the document. +The risk outweighs the benefit — first-undo granularity across the boundary is a +narrow edge case, undo still works, and the prior op stack is intact — so PG14 +is left as `test.fail()` rather than shipping a fragile fix. Reviving it cleanly +would mean a dedicated engine "record a state replacement as one op" API. ## Running the suites ```bash -# muya engine xfail tests (suite must stay GREEN: it.fails entries count as pass) +# muya engine parity tests (all pass; PG2 cursor mapping is also covered by +# src/__tests__/setCursorByOffset.spec.ts) pnpm -C packages/muya test # a single gap's engine tests pnpm -C packages/muya exec vitest run src/state/__tests__/parityExportHtml.spec.ts -# desktop parity e2e (needs `pnpm run build:unpack` first; suite stays GREEN) +# desktop parity e2e (needs `pnpm run build:unpack` first; PG14 stays xfail) pnpm -C packages/desktop exec playwright test \ test/e2e/parity-pg1-menu-state.spec.ts \ test/e2e/parity-source-undo-saved.spec.ts \ diff --git a/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts b/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts index a722d90ecc..02fe48f23f 100644 --- a/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts +++ b/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts @@ -9,15 +9,11 @@ import { launchWithMarkdown, setSourceMarkdown, waitForMenuReady } from './helpe // // Legacy muyajs `selectionChange` carried the ancestor block `affiliation` // chain + block markdown types, which `createApplicationMenuState` -// (store/editor.ts) turned into Paragraph-menu check marks. With @muyajs/core -// the `selection-change` payload has no affiliation chain, so the affiliation -// map the store builds stays empty and the Paragraph-menu check marks never -// light up. Here we read the live application-menu `checked` state after -// placing the caret in a heading. -// -// This RUNS but currently fails (the menu item never gets checked), so it is -// marked `test.fail()`. When the engine restores affiliation and the store -// lights the check mark, remove the `test.fail()`. +// (store/editor.ts) turned into Paragraph-menu check marks. #4410 restored the +// affiliation chain + per-endpoint block info on the `selection-change` +// payload, and the desktop adapter (`adaptSelectionChange`) now feeds them to +// the store, so the Paragraph-menu check marks light up again. Here we read the +// live application-menu `checked` state after placing the caret in a heading. const headingMenuChecked = async(app: ElectronApplication, id: string): Promise => { return await app.evaluate(({ Menu }, menuId) => { @@ -69,7 +65,6 @@ test.describe('Parity PG1 — Paragraph menu reflects the current block', () => if (app) await app.close() }) - test.fail() test('PG1: placing the caret in an H1 checks heading1MenuItem in the Paragraph menu', async() => { await setSourceMarkdown(page, app, '# A heading\n') // Sanity: the heading rendered and the caret landed inside it (so a `false` diff --git a/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts index 25cae7ae66..f97eb870f1 100644 --- a/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts +++ b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts @@ -23,10 +23,9 @@ const undo = async(app: Parameters[0]): Promise } test.describe('Parity PG2 — WYSIWYG caret restored after a source-mode edit', () => { - // handleFileChange drops `muyaIndexCursor`/`blocks` and the engine has no - // index→path cursor conversion, so the source-mode editing position is lost - // on the handoff back to WYSIWYG and no meaningful caret is restored. - test.fail() + // handleFileChange now maps the saved `muyaIndexCursor` ({line, ch}) onto a + // block-key cursor via the engine's `setCursorByOffset`, so the source-mode + // editing position is restored on the handoff back to WYSIWYG. test('PG2: the caret lands in the block the source-mode cursor was on', async() => { const { app, page } = await launchWithMarkdown( 'first para\n\nsecond para\n\nthird para here\n' @@ -68,9 +67,14 @@ test.describe('Parity PG2 — WYSIWYG caret restored after a source-mode edit', }) test.describe('Parity PG14 — first undo after source mode reverts the edit in one step', () => { - // On source-mode exit the engine rebuilds the document via setContent (which - // does NOT record an undo op) then restores the pre-source op stack, so the - // bulk source-mode change is not a single undo boundary. + // ACCEPT-DEFER: on source-mode exit the engine rebuilds the document via + // setContent (which does NOT record an undo op) then restores the pre-source + // op stack, so the bulk source-mode change is not a single undo boundary. + // Recording it as one boundary would require feeding a general + // whole-document json1 diff through Editor.updateContents' pick/drop walker, + // which only handles specific op shapes (block insert / text edit / + // checked|meta) and would risk corrupting the document on arbitrary diffs. + // Left as `test.fail()` — see the matching note in editor.vue handleFileChange. test.fail() test('PG14: one undo after exiting source mode reverts the source-mode change', async() => { const { app, page } = await launchWithMarkdown('base\n') @@ -93,10 +97,10 @@ test.describe('Parity PG14 — first undo after source mode reverts the edit in }) test.describe('Parity PG15 — undo back to on-disk content restores the saved indicator', () => { - // The desktop feeds the store a synthetic history whose id is regenerated on - // every json-change (including undo), so the saved-id comparison never - // matches again and the tab stays marked dirty even when content == disk. - test.fail() + // The synthetic save-tracking id is now the engine undo-stack depth (a stable + // position marker), and a freshly-loaded tab seeds `lastSavedHistoryId` to the + // baseline depth (0). Undoing an edit back to disk content returns the id to + // its saved value, so the saved/clean indicator is restored. test('PG15: undoing an edit back to disk content clears the unsaved indicator', async() => { const { app, page } = await launchWithMarkdown('hello world\n') await waitForMenuReady(app) diff --git a/packages/muya/src/__tests__/setCursorByOffset.spec.ts b/packages/muya/src/__tests__/setCursorByOffset.spec.ts new file mode 100644 index 0000000000..e2dda418d3 --- /dev/null +++ b/packages/muya/src/__tests__/setCursorByOffset.spec.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../muya'; +import { injectSentinels, resolveSentinelCursor } from '../selection/offsetCursor'; + +// PARITY (gap PG2): the source-code -> WYSIWYG handoff carries only a +// CodeMirror `{ line, ch }` index cursor. `setCursorByOffset` reproduces the +// legacy muyajs index->block-key cursor conversion (sentinel injection + tree +// walk) so the WYSIWYG caret lands on the block the source-mode cursor was in. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) { + const host = bootedHosts.pop()!; + host.remove(); + } + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +describe('muya.setCursorByOffset() (PG2)', () => { + it('PG2: maps a source-mode {line, ch} cursor onto the matching paragraph block', async () => { + const muya = bootMuya('first para\n\nsecond para\n\nthird para here\n'); + // Line 4 = "third para here" (lines: 0 first, 1 blank, 2 second, 3 blank, 4 third). + const restored = muya.setCursorByOffset({ + anchor: { line: 4, ch: 6 }, + focus: { line: 4, ch: 6 }, + }); + expect(restored).toBe(true); + + await vi.waitFor(() => { + const sel = muya.editor.selection.getSelection(); + expect(sel).not.toBeNull(); + // The caret lands inside the "third para here" block. + expect(sel!.anchorBlock.text).toBe('third para here'); + expect(sel!.anchor.offset).toBe(6); + }); + // The document content is left clean (no sentinel residue). + expect(muya.getMarkdown()).not.toContain('mUyAcUrSoR'); + expect(muya.getMarkdown().trim()).toBe('first para\n\nsecond para\n\nthird para here'); + }); + + it('PG2: maps a cursor inside a heading block', async () => { + const muya = bootMuya('# Title\n\nbody text\n'); + const restored = muya.setCursorByOffset({ + anchor: { line: 0, ch: 4 }, // inside "# Title" (after "# Ti") + focus: { line: 0, ch: 4 }, + }); + expect(restored).toBe(true); + await vi.waitFor(() => { + const sel = muya.editor.selection.getSelection(); + // This engine keeps the `# ` marker in the heading content block's + // text, so the caret lands at offset 4 of "# Title". + expect(sel!.anchorBlock.text).toBe('# Title'); + expect(sel!.anchor.offset).toBe(4); + }); + }); + + it('PG2: resolves a non-collapsed selection within a block to the right offsets', () => { + // Asserted at the resolver level: happy-dom does not preserve a + // non-collapsed DOM range across the contenteditable re-render, so the + // DOM-readback `getSelection()` would collapse it (works in Chromium). + // The resolver is what computes the sentinel-free anchor/focus offsets. + const muya = bootMuya('hello world\n'); + const sentinelMarkdown = injectSentinels(muya.getMarkdown(), { + anchor: { line: 0, ch: 0 }, + focus: { line: 0, ch: 5 }, + }); + expect(sentinelMarkdown).not.toBeNull(); + muya.editor.setContent(sentinelMarkdown!); + const cursor = resolveSentinelCursor(muya.editor.scrollPage!); + expect(cursor).not.toBeNull(); + expect(cursor!.anchor!.offset).toBe(0); + expect(cursor!.focus!.offset).toBe(5); + // Both endpoints resolve to the same block (same path). + expect(cursor!.anchorPath).toEqual(cursor!.focusPath); + }); + + it('PG2: returns false and leaves the document intact for a stale line', () => { + const muya = bootMuya('only line\n'); + const restored = muya.setCursorByOffset({ + anchor: { line: 99, ch: 0 }, + focus: { line: 99, ch: 0 }, + }); + expect(restored).toBe(false); + expect(muya.getMarkdown().trim()).toBe('only line'); + }); + + it('PG2: returns false for a null cursor', () => { + const muya = bootMuya('text\n'); + expect(muya.setCursorByOffset({ anchor: null, focus: null })).toBe(false); + }); + + it('PG2: preserves the undo history across the internal setContent rebuild', async () => { + const muya = bootMuya('alpha\n'); + // Seed a non-empty undo stack so we can detect a clobber. The text-setter + // op flushes to the history on the next frame (JSONState._emitStateChange). + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + first.setCursor(5, 5, true); + muya.editor.activeContentBlock = first; + first.text = 'alpha beta'; + await vi.waitFor(() => { + expect(muya.getHistory().stack.undo.length).toBeGreaterThan(0); + }); + const before = muya.getHistory(); + + const restored = muya.setCursorByOffset({ + anchor: { line: 0, ch: 3 }, + focus: { line: 0, ch: 3 }, + }); + expect(restored).toBe(true); + + // The undo stack survives the caret-restore (setContent would otherwise + // have cleared it). + const after = muya.getHistory(); + expect(after.stack.undo.length).toBe(before.stack.undo.length); + }); +}); diff --git a/packages/muya/src/muya.ts b/packages/muya/src/muya.ts index 4a55507d95..14550b2d6f 100644 --- a/packages/muya/src/muya.ts +++ b/packages/muya/src/muya.ts @@ -2,6 +2,7 @@ import type Content from './block/base/content'; import type Parent from './block/base/parent'; import type { Listener } from './event/types'; import type { ILocale } from './i18n/types'; +import type { IIndexCursor } from './selection/offsetCursor'; import type { ICursor } from './selection/types'; import type { ITocItem } from './state/getTOC'; import type { IBulletListState, IOrderListState, ITableState, ITaskListState, TState } from './state/types'; @@ -19,6 +20,7 @@ import { Editor } from './editor/index'; import EventCenter from './event/index'; import I18n from './i18n/index'; +import { injectSentinels, resolveSentinelCursor } from './selection/offsetCursor'; import { getTOC } from './state/getTOC'; import { isAnyListState, isAtxHeadingState } from './state/types'; import { replaceBlockByLabel } from './ui/paragraphQuickInsertMenu/config'; @@ -717,6 +719,50 @@ export class Muya { }); } + /** + * 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. + * + * `Editor.setContent` clears the undo history, so this method snapshots the + * history before its internal rebuild and restores it afterwards — the undo + * stack is preserved, leaving only the caret changed. No-op (returns + * `false`) when the cursor is stale / unresolvable, letting the caller fall + * back to its default. + */ + setCursorByOffset(indexCursor: IIndexCursor): boolean { + const { scrollPage } = this.editor; + if (!scrollPage) + return false; + + const cleanMarkdown = this.getMarkdown(); + const sentinelMarkdown = injectSentinels(cleanMarkdown, indexCursor); + if (sentinelMarkdown == null) + return false; + + // Preserve the undo history across the internal setContent rebuild + // (setContent clears it) so this stays a caret-only operation. + const savedHistory = this.getHistory(); + + this.editor.setContent(sentinelMarkdown); + const cursor = resolveSentinelCursor(this.editor.scrollPage!); + this.editor.setContent(cleanMarkdown); + this.setHistory(savedHistory); + + if (!cursor) + return false; + + this.setCursor(cursor); + + return true; + } + /** * Convert the block at the cursor to another type, mirroring marktext's * `updateParagraph`. `type` uses the marktext/muyajs paragraph-menu diff --git a/packages/muya/src/selection/offsetCursor.ts b/packages/muya/src/selection/offsetCursor.ts new file mode 100644 index 0000000000..228912bf36 --- /dev/null +++ b/packages/muya/src/selection/offsetCursor.ts @@ -0,0 +1,168 @@ +// Index-cursor → block-key cursor conversion for the source-code → WYSIWYG +// handoff. +// +// PARITY (gap PG2): when the desktop app switches a tab back from source-code +// mode to WYSIWYG, the only cursor it holds is a CodeMirror `{ line, ch }` +// offset pair (`muyaIndexCursor`). Legacy `packages/muyajs` translated that +// into a real block-key cursor by injecting sentinel strings into the markdown +// at the line/ch offsets, re-parsing, and walking the block tree to find which +// block's text the sentinels landed in (`addCursorToMarkdown` + +// `convertMuyaIndexCursortoCursor`). `@muyajs/core` had no equivalent, so the +// WYSIWYG caret was lost on the handoff. This module reproduces that mapping by +// resolving the offsets against the live block tree. + +import type Content from '../block/base/content'; +import type { ScrollPage } from '../block/scrollPage'; +import type { ICursor } from './types'; + +/** One end of a source-mode (CodeMirror) selection: a `{ line, ch }` offset. */ +export interface IIndexPosition { + line: number; + ch: number; +} + +/** A source-mode selection in CodeMirror `{ line, ch }` coordinates. */ +export interface IIndexCursor { + anchor: IIndexPosition | null; + focus: IIndexPosition | null; +} + +// Sentinel strings injected at the cursor offsets. They must be improbable in +// real markdown AND survive the markdown -> state round-trip as literal text. +// (The legacy engine used private-use-area code points, but this engine's +// markdown parser strips non-ASCII control/PUA characters, so the markers are +// plain ASCII with a long random-looking token unlikely to occur in real +// documents.) The two markers share no common substring so neither can be +// found inside the other. +const ANCHOR_SENTINEL = 'mUyAcUrSoRzZqAnChOr9x7kPvWb'; +const FOCUS_SENTINEL = 'mUyAcUrSoRzZqFoCuS4t2nDhGj'; + +function _clampOffset(offset: number, length: number): number { + if (!Number.isInteger(offset)) + return 0; + + return Math.min(Math.max(offset, 0), length); +} + +/** + * Inject the anchor/focus sentinels into `markdown` at the given `{ line, ch }` + * offsets. Returns `null` when either offset references a line that does not + * exist (stale cursor) so the caller can fall back to no cursor restore. + */ +export function injectSentinels( + markdown: string, + cursor: IIndexCursor, +): string | null { + const { anchor, focus } = cursor; + if (!anchor || !focus) + return null; + + const lines = markdown.split('\n'); + const isValidLine = (line: number): boolean => + Number.isInteger(line) && line >= 0 && line < lines.length; + + if (!isValidLine(anchor.line) || !isValidLine(focus.line)) + return null; + + const anchorText = lines[anchor.line]!; + const focusText = lines[focus.line]!; + const anchorCh = _clampOffset(anchor.ch, anchorText.length); + const focusCh = _clampOffset(focus.ch, focusText.length); + + if (anchor.line === focus.line) { + const min = Math.min(anchorCh, focusCh); + const max = Math.max(anchorCh, focusCh); + const first = anchorText.substring(0, min); + const middle = anchorText.substring(min, max); + const last = anchorText.substring(max); + lines[anchor.line] + = first + + (anchorCh <= focusCh ? ANCHOR_SENTINEL : FOCUS_SENTINEL) + + middle + + (anchorCh <= focusCh ? FOCUS_SENTINEL : ANCHOR_SENTINEL) + + last; + } + else { + lines[anchor.line] + = anchorText.substring(0, anchorCh) + ANCHOR_SENTINEL + anchorText.substring(anchorCh); + lines[focus.line] + = focusText.substring(0, focusCh) + FOCUS_SENTINEL + focusText.substring(focusCh); + } + + return lines.join('\n'); +} + +interface ISentinelHit { + block: Content; + offset: number; +} + +/** + * Walk the live content blocks of `scrollPage` and, for each sentinel found in + * a block's text, record the owning block and the offset the sentinel sits at + * (with the sentinel removed from the offset accounting). The block's text is + * left untouched — the tree carrying the sentinels is transient and replaced by + * the caller immediately after. + */ +function _findSentinel(scrollPage: ScrollPage, sentinel: string): ISentinelHit | null { + let hit: ISentinelHit | null = null; + + scrollPage.depthFirstTraverse((node) => { + if (hit || !node.isContent()) + return; + + const idx = node.text.indexOf(sentinel); + if (idx > -1) + hit = { block: node, offset: idx }; + }); + + return hit; +} + +/** + * Resolve the index cursor against the live (sentinel-bearing) block tree into + * a PATH-ONLY `ICursor` (json paths + offsets), or `null` when neither sentinel + * resolved to a content block. + * + * Only the plain `anchorPath`/`focusPath` arrays are captured (snapshotted from + * the live blocks here) — NOT the live block references. The caller rebuilds + * the clean document immediately after, detaching these block instances, so + * `setCursor` must re-resolve fresh blocks from those paths against the new + * tree. The structure is identical between the sentinel tree and the clean tree + * (the sentinels only change text), so the paths stay valid. + * + * The returned offsets are sentinel-free: the focus offset is decremented when + * the anchor sentinel precedes it in the same block, mirroring the legacy + * two-sentinel bookkeeping. + */ +export function resolveSentinelCursor(scrollPage: ScrollPage): ICursor | null { + const anchorHit = _findSentinel(scrollPage, ANCHOR_SENTINEL); + const focusHit = _findSentinel(scrollPage, FOCUS_SENTINEL); + + if (!anchorHit && !focusHit) + return null; + + const anchor = anchorHit ?? focusHit!; + const focus = focusHit ?? anchorHit!; + + let anchorOffset = anchor.offset; + let focusOffset = focus.offset; + + // When both sentinels live in the same block, the second one's recorded + // offset is shifted by the first sentinel's length. Normalise so both + // offsets are expressed against the sentinel-free text. + if (anchor.block === focus.block) { + if (anchorOffset <= focusOffset) + focusOffset = Math.max(focusOffset - ANCHOR_SENTINEL.length, anchorOffset); + else + anchorOffset = Math.max(anchorOffset - FOCUS_SENTINEL.length, focusOffset); + } + + // Snapshot the paths now, while the blocks are still attached. + return { + anchor: { offset: anchorOffset }, + anchorPath: [...anchor.block.path], + focus: { offset: focusOffset }, + focusPath: [...focus.block.path], + }; +}