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_QA.md b/packages/desktop/test/PARITY_QA.md new file mode 100644 index 0000000000..2b126cf03c --- /dev/null +++ b/packages/desktop/test/PARITY_QA.md @@ -0,0 +1,113 @@ +# 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) + +**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). +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). + +> 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. (This +path needs no desktop wiring — it works as soon as the engine handler ships.) + +### 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. + +--- + +## 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 read into a +base64 `data:` URL and persisted via `imageAction` — is now **implemented and +passing** in +`packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts` (PG5; the +`it.fails` marker is removed). The full OS-clipboard + macOS `screencapture` +integration can only be verified by hand and stays manual. + +### 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 + +- 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 + only the desktop OS-clipboard delivery (real bitmap, macOS `screencapture`) + 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..02d9300652 --- /dev/null +++ b/packages/desktop/test/PARITY_SCOREBOARD.md @@ -0,0 +1,115 @@ +# 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 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`) + 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 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). + +**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: 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:`) | 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) — 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 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; 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 \ + --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..02fe48f23f --- /dev/null +++ b/packages/desktop/test/e2e/parity-pg1-menu-state.spec.ts @@ -0,0 +1,82 @@ +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. #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) => { + 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('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..f97eb870f1 --- /dev/null +++ b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts @@ -0,0 +1,130 @@ +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 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' + ) + 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', () => { + // 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') + 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 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) + + 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/package.json b/packages/muya/package.json index ce79d543b0..f4292755c8 100644 --- a/packages/muya/package.json +++ b/packages/muya/package.json @@ -64,6 +64,7 @@ "fast-diff": "^1.3.0", "flowchart.js": "^1.18.0", "fuse.js": "^7.3.0", + "github-markdown-css": "^5.9.0", "html-tags": "^5.1.0", "joplin-turndown-plugin-gfm": "^1.0.12", "katex": "0.16.47", diff --git a/packages/muya/src/__tests__/blockEditing.spec.ts b/packages/muya/src/__tests__/blockEditing.spec.ts index a3149af12d..5f234ba43e 100644 --- a/packages/muya/src/__tests__/blockEditing.spec.ts +++ b/packages/muya/src/__tests__/blockEditing.spec.ts @@ -52,6 +52,26 @@ function placeCursorOnFirstBlock(muya: Muya): Content { return first; } +// Place the cursor on the leaf content block whose text matches `text`, the way +// a click sets `activeContentBlock`. Used to exercise nested-block anchoring. +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; +} + describe('muya block editing api', () => { it('duplicate() copies the current block in place', async () => { const muya = bootMuya('# Title\n\nbody\n'); @@ -91,6 +111,22 @@ describe('muya block editing api', () => { expect(muya.getMarkdown()).toContain('intro'); }); + it('insertParagraph("after", text, true) anchors at the outermost block in nested structures', async () => { + // The explicit "Create Paragraph Below" caller passes outMost=true, so a + // cursor inside a blockquote inserts the new paragraph AFTER the whole + // blockquote at document root — not as an inner sibling. + const muya = bootMuya('> quoted line\n'); + placeCursorOn(muya, 'quoted line'); + muya.insertParagraph('after', 'OUTERSIBLING', true); + await vi.waitFor(() => { + const after = muya.getState(); + expect(after.length).toBe(2); + expect(after[0].name).toBe('block-quote'); + expect(after[1].name).toBe('paragraph'); + }); + expect(muya.getMarkdown()).toContain('OUTERSIBLING'); + }); + it('deleteParagraph() removes the current block and keeps the rest', async () => { const muya = bootMuya('# Title\n\nbody\n'); placeCursorOnFirstBlock(muya); diff --git a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts new file mode 100644 index 0000000000..d925e004fc --- /dev/null +++ b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts @@ -0,0 +1,129 @@ +// @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. +// +// 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; +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( + '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( + '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(); + }, + ); + + 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/__tests__/parityInsertParagraphNested.spec.ts b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts new file mode 100644 index 0000000000..058113129b --- /dev/null +++ b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts @@ -0,0 +1,134 @@ +// @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)` originally ALWAYS resolved +// the target via `_outmostBlockAtCursor()` → `outMostBlock` (the OUTERMOST +// container), so in a nested list/blockquote the new paragraph landed AFTER the +// entire outer block (at document root) instead of as an inner sibling. +// +// The engine now restores the immediate-anchor path: `insertParagraph` gained a +// third `outMost` flag (default `false`) that anchors to the IMMEDIATE block at +// the cursor, matching the legacy context-menu "Insert Paragraph Before/After" +// behaviour. The explicit "Create Paragraph Below" caller passes `outMost=true` +// to keep anchoring at the outermost container. These specs assert the restored +// immediate-anchor behaviour and now PASS. + +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( + '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( + '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/__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/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/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/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/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts b/packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts new file mode 100644 index 0000000000..7bb28272d4 --- /dev/null +++ b/packages/muya/src/block/gfm/taskListCheckbox/__tests__/parityAutoCheck.spec.ts @@ -0,0 +1,137 @@ +// @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`). +// +// `block/gfm/taskListCheckbox/index.ts#update` now reads `muya.options.autoCheck` +// and, for a `user` toggle, cascades the state to every descendant task item and +// re-derives each ancestor — restoring parity. +// +// We drive the checkbox's `update(checked, 'user')` directly — that is exactly +// what the DOM click handler invokes — and assert the cascade. + +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( + '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( + '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/block/gfm/taskListCheckbox/index.ts b/packages/muya/src/block/gfm/taskListCheckbox/index.ts index f8dbfb223b..765e5452f0 100644 --- a/packages/muya/src/block/gfm/taskListCheckbox/index.ts +++ b/packages/muya/src/block/gfm/taskListCheckbox/index.ts @@ -1,5 +1,7 @@ import type { Muya } from '../../../muya'; import type { ITaskListItemMeta } from '../../../state/types'; +import type { Nullable } from '../../../types'; +import type Parent from '../../base/parent'; import type TaskList from '../taskList'; import type TaskListItem from '../taskListItem'; import { isFirefox } from '../../../config'; @@ -10,6 +12,100 @@ import TreeNode from '../../base/treeNode'; const debug = logger('tasklistCheckbox:'); +// Block-name discriminators used by the autoCheck cascade to narrow the +// generic `TreeNode`/`Parent` tree shapes without a double-cast. +function isTaskListItem(node: Nullable): node is TaskListItem { + return !!node && node.blockName === 'task-list-item'; +} + +function isTaskList(node: Nullable): node is TaskList { + return !!node && node.blockName === 'task-list'; +} + +function isCheckbox(node: TreeNode): node is TaskListCheckbox { + return node.blockName === TaskListCheckbox.blockName; +} + +// Find the `task-list-checkbox` attachment of a `task-list-item`. +function checkboxOf(item: TaskListItem): TaskListCheckbox | null { + let found: TaskListCheckbox | null = null; + item.attachments.forEach((attachment: Parent) => { + if (isCheckbox(attachment)) + found = attachment; + }); + + return found; +} + +// Set a task item's checked state, dispatching the OT op (`TaskListItem.checked` +// setter) and syncing its checkbox DOM. No-op when already in the target state. +function setItemChecked(item: TaskListItem, checked: boolean): void { + if (item.checked === checked) + return; + + item.checked = checked; + const checkbox = checkboxOf(item); + if (checkbox) + checkbox.syncDom(checked); +} + +// The nested `task-list` directly under a `task-list-item`, if any. A task item +// holds a leading paragraph plus an optional nested list of sub-tasks. +function nestedTaskListOf(item: TaskListItem): TaskList | null { + let nested: TaskList | null = null; + item.children.forEach((child: TreeNode) => { + if (isTaskList(child)) + nested = child; + }); + + return nested; +} + +// Cascade `checked` to every descendant task item (depth-first). +function cascadeToDescendants(item: TaskListItem, checked: boolean): void { + const nested = nestedTaskListOf(item); + if (!nested) + return; + + nested.children.forEach((child: TreeNode) => { + if (!isTaskListItem(child)) + return; + + setItemChecked(child, checked); + cascadeToDescendants(child, checked); + }); +} + +// A parent item is checked iff every sibling in its task-list is checked. +function allSiblingsChecked(list: TaskList): boolean { + let all = true; + list.children.forEach((child: TreeNode) => { + if (isTaskListItem(child) && !child.checked) + all = false; + }); + + return all; +} + +// Re-derive each ancestor task item: walking up from the toggled item's list, +// set every enclosing item to the computed state until one is unchanged. +function rederiveAncestors(item: TaskListItem): void { + let list = item.parent; + + while (isTaskList(list)) { + const ancestor = list.parent; + if (!isTaskListItem(ancestor)) + return; + + const computed = allSiblingsChecked(list); + if (ancestor.checked === computed) + return; + + setItemChecked(ancestor, computed); + list = ancestor.parent; + } +} + // The Task List Item component is Firefox compatible, because in Firefox, // the input element is not clickable in the contenteditable element(li), // and in Firefox, the span element is used instead of the input element. @@ -88,25 +184,54 @@ class TaskListCheckbox extends TreeNode { } update = (checked: boolean, source = 'api') => { - operateClassName( - this.domNode!, - checked ? 'add' : 'remove', - 'mu-checkbox-checked', - ); const taskListItem = this.parent as TaskListItem; const taskList = taskListItem!.parent as TaskList; - if (isHTMLInputElement(this.domNode) && this.domNode.checked !== checked && !isFirefox) - this.domNode.checked = checked; + this._applyChecked(checked, source); + + // marktext `clickCtrl.js#listItemCheckBoxClick` cascaded a user toggle + // through `muya.options.autoCheck`: checking/unchecking an item set the + // same state on every descendant task item, then re-derived each + // ancestor (checked iff all its siblings are checked). `source === 'api'` + // is the silent, OT-free path used by the cascade itself, so it never + // recurses. + if (source !== 'api' && this.muya.options.autoCheck) { + cascadeToDescendants(taskListItem, checked); + rederiveAncestors(taskListItem); + } - // No need to trigger the OT operation If the source is `api`. + taskList.orderIfNecessary(); + }; + + // Reflect `checked` onto this checkbox's DOM and onto its task-list-item + // state. A `user` source dispatches the OT `replace` op (via the + // `TaskListItem.checked` setter); an `api` source mutates the state + // silently so the cascade can update many items without op spam. + private _applyChecked(checked: boolean, source: string) { + this.syncDom(checked); + + const taskListItem = this.parent as TaskListItem; if (source === 'api') taskListItem.meta.checked = checked; else taskListItem.checked = checked; + } - taskList.orderIfNecessary(); - }; + // Sync only this checkbox's DOM + internal flag to `checked`. Used by the + // autoCheck cascade, which has already mutated the owning item's state (and + // dispatched the OT op) via the `TaskListItem.checked` setter, so this must + // not touch state again. + syncDom(checked: boolean) { + this._checked = checked; + operateClassName( + this.domNode!, + checked ? 'add' : 'remove', + 'mu-checkbox-checked', + ); + + if (isHTMLInputElement(this.domNode) && this.domNode.checked !== checked && !isFirefox) + this.domNode.checked = checked; + } detachDOMEvents() { for (const id of this._eventIds) 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/clipboard/__tests__/parityCopyAsRich.spec.ts b/packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts new file mode 100644 index 0000000000..e36308e72f --- /dev/null +++ b/packages/muya/src/clipboard/__tests__/parityCopyAsRich.spec.ts @@ -0,0 +1,96 @@ +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. The engine now adds +// the `copyAsRich` copyType in `copyHandler` plus the `copyAsRich()` method on +// `Clipboard`/`Muya` (PG-COPYRICH), so they pass. + +// 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( + '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( + '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..e67f0f614a --- /dev/null +++ b/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts @@ -0,0 +1,177 @@ +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. The engine now routes pasted images +// (resolved file paths and in-memory bitmaps) through `options.imageAction` +// (PG05/PG06), so they pass. + +// 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( + '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( + '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( + '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/clipboard/index.ts b/packages/muya/src/clipboard/index.ts index 5983b16a8e..b3e9f9e708 100644 --- a/packages/muya/src/clipboard/index.ts +++ b/packages/muya/src/clipboard/index.ts @@ -15,11 +15,11 @@ import StateToMarkdown from '../state/stateToMarkdown'; import { isAnyListState, isParagraphState } from '../state/types'; import { deepClone, isClipboardEvent, isKeyboardEvent } from '../utils'; import { getClipBoardHtml } from '../utils/marked'; -import { getCopyTextType, isStandaloneTableHtml, normalizePastedHTML, resolveClipboardImagePath } from '../utils/paste'; +import { getClipboardImageFile, getCopyTextType, isStandaloneTableHtml, normalizePastedHTML, readFileAsDataURL, resolveClipboardImagePath } from '../utils/paste'; import { mergePasteIntoHeading } from './mergePasteIntoHeading'; class Clipboard { - public copyType: string = 'normal'; // `normal` or `copyAsMarkdown` or `copyAsHtml` or `copyCodeContent` + public copyType: string = 'normal'; // `normal` or `copyAsMarkdown` or `copyAsHtml` or `copyAsRich` or `copyCodeContent` public pasteType: string = 'normal'; // `normal` or `pasteAsPlainText` public copyInfo: string = ''; @@ -353,6 +353,19 @@ class Clipboard { break; } + // "Copy as Rich Text": put the rendered HTML in the html slot so a + // rich-text target (Word, email, contenteditable) renders formatted + // content, and keep the markdown source in the plain slot. Mirrors + // the `normal` branch; `copyAsHtml` instead blanks text/html and + // drops the markup into text/plain as literal source. + case 'copyAsRich': { + if (text.length === 0) + return; + event.clipboardData.setData('text/html', html); + event.clipboardData.setData('text/plain', text); + break; + } + case 'copyAsMarkdown': { if (text.length === 0) return; @@ -627,6 +640,10 @@ class Clipboard { // detached) clipboard. const text = rawText ?? event.clipboardData.getData('text/plain'); let html = rawHtml ?? event.clipboardData.getData('text/html'); + // Snapshot any in-memory image File (the bitmap / "Copy Image" / + // screenshot case, PG05) synchronously too — `clipboardData.files` + // is also detached after the first `await`. + const imageFile = getClipboardImageFile(event.clipboardData); if (!isSelectionInSameBlock) { this.cutHandler(); @@ -634,17 +651,11 @@ class Clipboard { return this.pasteHandler(event, text, html); } - // When the OS clipboard holds a file (e.g. an image copied from a - // file manager), let the embedder resolve it to a local path and - // insert it as an inline image, short-circuiting the text/HTML paste. - // Ported from the legacy `@muyajs` `clipboardFilePath` hook. - const imagePath = await resolveClipboardImagePath( - muya.options.clipboardFilePath, - ); - if (imagePath) { - this.insertImagePath(anchorBlock, imagePath); + // When the clipboard holds an image — either a file resolved to a path + // (PG06) or an in-memory bitmap (PG05) — insert it as an inline image + // routed through `imageAction`, short-circuiting the text/HTML paste. + if (await this.tryPasteImage(anchorBlock, imageFile)) return; - } // Support pasted URLs from Firefox. if (URL_REG.test(text) && !/\s/.test(text) && !html) @@ -771,16 +782,66 @@ class Clipboard { } /** - * Insert a resolved clipboard file path as an inline image at the cursor. + * Insert a pasted image when the clipboard carries one. Tries a resolved + * clipboard FILE path first (PG06, via the `clipboardFilePath` hook), then + * an in-memory bitmap File (PG05, read as a base64 `data:` URL). Returns + * `true` when an image was inserted so the caller skips the text/HTML + * paste, `false` to fall through. Ported from the legacy `@muyajs` + * `pasteImage` ordering (file path, then binary). + */ + private async tryPasteImage( + anchorBlock: Content, + imageFile: Nullable, + ): Promise { + const imagePath = await resolveClipboardImagePath( + this.muya.options.clipboardFilePath, + ); + if (imagePath) { + await this.insertImageSrc(anchorBlock, imagePath); + return true; + } + + if (imageFile) { + const dataUrl = await readFileAsDataURL(imageFile); + if (dataUrl) { + await this.insertImageSrc(anchorBlock, dataUrl); + return true; + } + } + + return false; + } + + /** + * Insert a pasted image at the cursor, routing it through the embedder's + * `imageAction` first so the user's insert preference (copy-to-assets / + * upload / keep-path) applies and a portable src is written. `src` is + * either a resolved clipboard file path (PG06) or a `data:` URL for an + * in-memory bitmap (PG05). When no `imageAction` is configured the src is + * inserted as-is, preserving the legacy file-path behaviour. + */ + private async insertImageSrc(anchorBlock: Content, src: string): Promise { + let finalSrc = src; + const { imageAction } = this.muya.options; + if (imageAction) { + const resolved = await imageAction({ src, alt: '', title: '' }); + if (resolved) + finalSrc = resolved; + } + + this.insertImageText(anchorBlock, finalSrc); + } + + /** + * Splice `![](src)` into the anchor block at the current selection. * * Inline images in muya are plain markdown text (`![](src)`) on a content - * block; rendering turns the token into an image. We splice the image - * markdown into the anchor block at the current selection (replacing any - * collapsed/expanded range) and place the cursor after it. The src is + * block; rendering turns the token into an image. We replace any + * collapsed/expanded range and place the cursor after it. The src is * escaped the same way as {@link Format.replaceImage} so spaces and `#` * survive in the path. */ - private insertImagePath(anchorBlock: Content, src: string): void { + private insertImageText(anchorBlock: Content, src: string): void { const cursor = anchorBlock.getCursor(); if (!cursor) return; @@ -813,6 +874,12 @@ class Clipboard { this.copyType = 'normal'; } + copyAsRich() { + this.copyType = 'copyAsRich'; + document.execCommand('copy'); + this.copyType = 'normal'; + } + pasteAsPlainText() { this.pasteType = 'pasteAsPlainText'; document.execCommand('paste'); 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/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/__tests__/parityHideLinkPopup.spec.ts b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts new file mode 100644 index 0000000000..173c0d275b --- /dev/null +++ b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts @@ -0,0 +1,98 @@ +// @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. +// +// `editor/linkMouseEvents.ts#overHandler` now reads `muya.options.hideLinkPopup` +// and returns early when it is set, so the popover is suppressed on hover — +// restoring parity. The gap test below asserts that suppression; the positive +// control proves the harness actually drives the hover emit. + +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( + '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/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/editor/linkMouseEvents.ts b/packages/muya/src/editor/linkMouseEvents.ts index 78f35ed2ac..bb744ce5d9 100644 --- a/packages/muya/src/editor/linkMouseEvents.ts +++ b/packages/muya/src/editor/linkMouseEvents.ts @@ -80,6 +80,13 @@ export function attachLinkMouseHandlers(muya: Muya): void { const { eventCenter, domNode } = muya; const overHandler = (event: Event) => { + // marktext `eventHandler/mouseEvent.js` gated the link-tools dispatch + // on `!hideLinkPopup`: when the user sets `hideLinkPopup: true`, the + // hover popover is suppressed entirely. Read it live so a runtime + // `setOptions({ hideLinkPopup })` toggle takes effect immediately. + if (muya.options?.hideLinkPopup) + return; + const wrapper = findLinkWrapper(event.target); if (!wrapper || !isPopoverTarget(wrapper)) return; diff --git a/packages/muya/src/muya.ts b/packages/muya/src/muya.ts index 369a795899..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'; @@ -448,6 +450,17 @@ export class Muya { this.editor.clipboard.copyAsHtml(); } + /** + * Copy the current selection as rich text to the clipboard: the rendered + * HTML goes in the `text/html` slot so a rich-text target (Word, email, a + * contenteditable) renders formatting, and the markdown source goes in the + * `text/plain` slot. Unlike {@link copyAsHtml}, which blanks `text/html` + * and drops the markup into `text/plain` as literal source. + */ + copyAsRich() { + this.editor.clipboard.copyAsRich(); + } + /** * Paste the clipboard content as plain text at the current cursor. */ @@ -466,6 +479,21 @@ export class Muya { return content?.outMostBlock ?? null; } + /** + * The immediate block-level parent of the active content leaf — the + * paragraph/heading block that directly wraps the cursor. This mirrors the + * legacy `getAnchor`/`getParent` anchor used by the context-menu + * "Insert Paragraph Before/After" path, so a new paragraph lands as an + * inner sibling inside a list item / blockquote rather than jumping out to + * the outermost container. Uses the persisted active content block (which + * survives the menu/IPC round-trip), falling back to the selection anchor. + */ + private _immediateBlockAtCursor(): Parent | null { + const content = this.editor.activeContentBlock ?? this.editor.selection.anchorBlock; + + return content?.parent ?? null; + } + /** * Duplicate the block at the current cursor, placing the cursor in the * copy. No-op when there is no current block. @@ -485,9 +513,16 @@ export class Muya { * Insert an empty paragraph relative to the block at the current cursor. * @param location Insert `before` or `after` the current block (default `after`). * @param text Initial text of the new paragraph. + * @param outMost When `true`, anchor the new paragraph to the OUTERMOST + * container (the legacy "Create Paragraph Below" behaviour). When `false` + * (default), anchor to the IMMEDIATE block at the cursor so the paragraph + * stays as an inner sibling inside a list item / blockquote — the legacy + * context-menu "Insert Paragraph Before/After" behaviour. */ - insertParagraph(location: 'before' | 'after' = 'after', text = '') { - const block = this._outmostBlockAtCursor(); + insertParagraph(location: 'before' | 'after' = 'after', text = '', outMost = false) { + const block = outMost + ? this._outmostBlockAtCursor() + : this._immediateBlockAtCursor(); if (!block) return; @@ -684,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/__tests__/parityPreviewImage.spec.ts b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts new file mode 100644 index 0000000000..d88b347281 --- /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. +// +// 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; +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( + '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( + '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..13809e1a8a --- /dev/null +++ b/packages/muya/src/selection/__tests__/paritySelectionChange.spec.ts @@ -0,0 +1,190 @@ +// @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( + '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); + + // The payload carries an `affiliation` list of the ancestor block + // types so the desktop Paragraph menu can light up. + expect('affiliation' in payload).toBe(true); + expect(Array.isArray(payload.affiliation)).toBe(true); + }, + ); + + it( + '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); + + // A consumer can learn the cursor sits in an `h1` heading (so + // `heading1MenuItem` can be checked) — the affiliation chain reports + // the markdown block type, separate from the selection kind + // (`type` stays 'Caret' / 'Range'). + const affiliation = payload.affiliation as Array<{ type: string }>; + expect(affiliation.map(entry => entry.type)).toContain('h1'); + // The selection kind is still the flat caret/range type. + expect(payload.type).toBe('Caret'); + }, + ); + + it( + 'PG1: selection-change exposes per-endpoint content-leaf block info (type + functionType)', + () => { + const muya = bootMuya('```js\nconst a = 1\n```\n'); + // `firstContentInDescendant` of a code block is the language-input + // leaf; the code text lives in the last content leaf. + const codeLeaf = muya.editor.scrollPage!.lastContentInDescendant()!; + const payload = emitSelectionFor(muya, codeLeaf); + + // The desktop store keys `isCodeFences` / `isCodeContent` off + // `start.type === 'span' && block.functionType === 'codeContent'`. + const info = payload.anchorBlockInfo as { + type: string; + functionType?: string; + }; + expect(info.type).toBe('span'); + expect(info.functionType).toBe('codeContent'); + // The fenced code block contributes a `pre`-typed affiliation entry. + const affiliation = payload.affiliation as Array<{ type: string }>; + expect(affiliation.map(entry => entry.type)).toContain('pre'); + }, + ); + + it( + 'PG1: selection-change surfaces list context (ul / li / loose / task) in affiliation', + () => { + const muya = bootMuya('- [ ] task\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant()!; + const payload = emitSelectionFor(muya, leaf); + const affiliation = payload.affiliation as Array<{ + type: string; + listType?: string; + listItemType?: string; + isLooseListItem?: boolean; + }>; + const list = affiliation.find(entry => entry.type === 'ul'); + const item = affiliation.find(entry => entry.type === 'li'); + + expect(list).toBeTruthy(); + expect(list!.listType).toBe('task'); + expect(list!.isLooseListItem).toBe(false); + expect(item).toBeTruthy(); + expect(item!.listItemType).toBe('task'); + }, + ); + + it( + 'PG1: ordered-list items report listItemType "order" (not misclassified as bullet)', + () => { + const muya = bootMuya('1. one\n2. two\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant()!; + const payload = emitSelectionFor(muya, leaf); + const affiliation = payload.affiliation as Array<{ + type: string; + listType?: string; + listItemType?: string; + }>; + + expect(affiliation.find(e => e.type === 'ol')?.listType).toBe('order'); + // Bullet and ordered lists share the `list-item` block; the item's + // discriminator must come from the parent list. + expect(affiliation.find(e => e.type === 'li')?.listItemType).toBe('order'); + }, + ); + + it( + 'PG1: loose lists report isLooseListItem true on both the list and the item', + () => { + // Blank lines between items make a loose list. + const muya = bootMuya('- one\n\n- two\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant()!; + const payload = emitSelectionFor(muya, leaf); + const affiliation = payload.affiliation as Array<{ + type: string; + isLooseListItem?: boolean; + }>; + + // Loose-ness lives on the list block; the `li` entry mirrors it. + expect(affiliation.find(e => e.type === 'ul')?.isLooseListItem).toBe(true); + expect(affiliation.find(e => e.type === 'li')?.isLooseListItem).toBe(true); + }, + ); +}); diff --git a/packages/muya/src/selection/affiliation.ts b/packages/muya/src/selection/affiliation.ts new file mode 100644 index 0000000000..541aad7ac2 --- /dev/null +++ b/packages/muya/src/selection/affiliation.ts @@ -0,0 +1,253 @@ +// Block-affiliation derivation for the `selection-change` payload. +// +// PARITY (gap PG1): legacy `packages/muyajs` emitted `selectionChange` with an +// `affiliation` chain — the shared ancestor PARAGRAPH-type blocks of the +// selection endpoints — plus per-endpoint `.type` (the markdown block type, +// e.g. `span` for a content leaf) 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` models the document as a block tree keyed by +// `blockName`, so this module re-derives the same shape from that tree. + +import type Content from '../block/base/content'; +import type Parent from '../block/base/parent'; +import type TreeNode from '../block/base/treeNode'; +import type { Nullable } from '../types'; + +/** + * The legacy "markdown block type" vocabulary the desktop menu vocabulary is + * keyed on (`MENU_ID_MAP` in `main/menu/actions/paragraph.ts`, + * `PARAGRAPH_TYPES` in the renderer config). Only ancestors whose mapped type + * is one of these belong in the affiliation chain. + */ +const PARAGRAPH_TYPES: ReadonlySet = new Set([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'pre', + 'ul', + 'ol', + 'li', + 'figure', +]); + +/** + * Container `blockName` → legacy markdown block `type`. Heading blocks resolve + * their level from `tagName` (`h1`…`h6`) so they are handled separately. + */ +const CONTAINER_TYPE_BY_NAME: Readonly> = { + 'paragraph': 'p', + 'block-quote': 'blockquote', + 'bullet-list': 'ul', + 'task-list': 'ul', + 'order-list': 'ol', + 'list-item': 'li', + 'task-list-item': 'li', + 'code-block': 'pre', + 'frontmatter': 'pre', + 'table': 'figure', + 'html-block': 'figure', + 'math-block': 'figure', + 'diagram': 'figure', +}; + +/** + * Leaf-content `blockName` → legacy `functionType`. Mirrors the muyajs content + * blocks (`codeContent`, `cellContent`, `languageInput`, `paragraphContent`). + */ +const FUNCTION_TYPE_BY_NAME: Readonly> = { + 'codeblock.content': 'codeContent', + 'table.cell.content': 'cellContent', + 'language-input': 'languageInput', + 'paragraph.content': 'paragraphContent', + 'atxheading.content': 'paragraphContent', + 'setextheading.content': 'paragraphContent', +}; + +/** + * List-block `blockName` → list discriminator (matches muyajs's + * `listType` / `listItemType`: `bullet` | `order` | `task`). Keyed only on the + * list container blocks — list-item blocks share the `list-item` block name for + * both bullet and ordered lists, so an item's discriminator is read from the + * parent list, never from the item itself. + */ +const LIST_TYPE_BY_NAME: Readonly> = { + 'bullet-list': 'bullet', + 'order-list': 'order', + 'task-list': 'task', +}; + +/** + * One ancestor block in the affiliation chain. `type` is the legacy markdown + * block type; the remaining fields carry the list-context the desktop menu + * needs. Shape parity with muyajs's affiliation entries. + */ +export interface IAffiliationEntry { + /** Legacy markdown block type: `p`, `h1`…`h6`, `ul`, `ol`, `li`, `pre`, `figure`, `blockquote`. */ + type: string; + /** Engine block name (`bullet-list`, `code-block`, …) for callers that want the precise block. */ + blockName: string; + /** Present on list ancestors (`ul` / `ol`): `bullet` | `order` | `task`. */ + listType?: string; + /** + * Present on list-item ancestors (`li`): the parent list's discriminator + * (`bullet` | `order` | `task`). Read from the parent list because both + * bullet and ordered lists share the `list-item` block. + */ + listItemType?: string; + /** + * Whether the enclosing list is rendered loose (blank-line separated). For + * `li` entries this reflects the parent list's `meta.loose`, since the + * looseness flag lives on the list, not the item. + */ + isLooseListItem?: boolean; +} + +/** + * Per-endpoint block info for one selection end. `type` is always `span` for a + * content leaf (parity with muyajs's content-block `type`); `functionType` + * distinguishes code / table-cell / language-input content. + */ +export interface IEndpointBlockInfo { + /** Engine block name of the content leaf, e.g. `codeblock.content`. */ + blockName: string; + /** Legacy content-block type — always `span` for a content leaf. */ + type: string; + /** Legacy `functionType`: `codeContent` | `cellContent` | `languageInput` | `paragraphContent`. */ + functionType?: string; +} + +function _markdownTypeOf(block: TreeNode): string | undefined { + if (block.blockName === 'atx-heading' || block.blockName === 'setext-heading') + return block.tagName; // `h1`…`h6` + + return CONTAINER_TYPE_BY_NAME[block.blockName]; +} + +const LIST_BLOCK_NAMES: ReadonlySet = new Set([ + 'bullet-list', + 'order-list', + 'task-list', +]); + +function _isLoose(block: Parent | null | undefined): boolean { + // Lists carry `meta.loose`; list *items* do not, so loose-ness for an `li` + // is read from its parent list block. + const meta = (block as (Parent & { meta?: { loose?: boolean } }) | null)?.meta; + + return Boolean(meta?.loose); +} + +/** + * Walk up from a list-item block to its enclosing list block (`bullet-list` / + * `order-list` / `task-list`), which owns the list discriminator and the + * loose/tight flag. + */ +function _parentListOf(item: Parent): Parent | null { + let node: Nullable = item.parent; + while (node) { + if (LIST_BLOCK_NAMES.has(node.blockName)) + return node; + + node = node.parent; + } + + return null; +} + +function _buildEntry(block: Parent, type: string): IAffiliationEntry { + const entry: IAffiliationEntry = { type, blockName: block.blockName }; + + if (type === 'ul' || type === 'ol') { + entry.listType = LIST_TYPE_BY_NAME[block.blockName]; + entry.isLooseListItem = _isLoose(block); + } + else if (type === 'li') { + // Both bullet and ordered items share the `list-item` block, and the + // loose flag lives on the parent list — derive both from there. + const list = _parentListOf(block); + entry.listItemType = list ? LIST_TYPE_BY_NAME[list.blockName] : undefined; + entry.isLooseListItem = _isLoose(list); + } + + return entry; +} + +/** + * Walk from a content leaf up to the outermost block, collecting the + * paragraph-type ancestor blocks. Ordered outermost-first (top block → … → + * leaf's container), matching muyajs where `affiliation[0]` is the enclosing + * list and deeper entries follow. + */ +function _ancestorBlocks(leaf: Content | null): Parent[] { + const blocks: Parent[] = []; + let node: Nullable = leaf?.parent; + + while (node) { + if (PARAGRAPH_TYPES.has(_markdownTypeOf(node) ?? '')) + blocks.unshift(node); + + if (node.isOutMostBlock) + break; + + node = node.parent; + } + + return blocks; +} + +/** + * Walk from a content leaf up to the outermost block, collecting the + * paragraph-type ancestors into an affiliation chain (outermost-first). + */ +export function buildAffiliation(leaf: Content | null): IAffiliationEntry[] { + return _ancestorBlocks(leaf).map(block => + _buildEntry(block, _markdownTypeOf(block)!), + ); +} + +/** + * Compute the shared-ancestor affiliation for a selection. When both endpoints + * sit in the same block the anchor chain is returned; otherwise the chain is + * trimmed to the ancestor block instances shared by both endpoints (parity + * with muyajs's `startParents.filter(p => endParents.includes(p))`). + */ +export function buildSelectionAffiliation( + anchorLeaf: Content | null, + focusLeaf: Content | null, +): IAffiliationEntry[] { + const anchorBlocks = _ancestorBlocks(anchorLeaf); + const shared + = anchorLeaf === focusLeaf + ? anchorBlocks + : _intersectBlocks(anchorBlocks, _ancestorBlocks(focusLeaf)); + + return shared.map(block => _buildEntry(block, _markdownTypeOf(block)!)); +} + +function _intersectBlocks(anchorBlocks: Parent[], focusBlocks: Parent[]): Parent[] { + const focusSet = new Set(focusBlocks); + + return anchorBlocks.filter(block => focusSet.has(block)); +} + +/** + * Describe one selection endpoint's content leaf in the legacy + * `{ type, functionType }` shape. + */ +export function endpointBlockInfo(leaf: Content | null): IEndpointBlockInfo | null { + if (!leaf) + return null; + + return { + blockName: leaf.blockName, + type: leaf.tagName || 'span', + functionType: FUNCTION_TYPE_BY_NAME[leaf.blockName], + }; +} diff --git a/packages/muya/src/selection/index.ts b/packages/muya/src/selection/index.ts index 5f30f046c6..6ca9f90a6c 100644 --- a/packages/muya/src/selection/index.ts +++ b/packages/muya/src/selection/index.ts @@ -9,6 +9,10 @@ import type { ICursor, INodeOffset, ISelection } from './types'; import { BLOCK_DOM_PROPERTY, CLASS_NAMES } from '../config'; import { isElement, isHTMLElement, isKeyboardEvent, isMouseEvent } from '../utils'; import { getImageInfo, getImageSrc } from '../utils/image'; +import { + buildSelectionAffiliation, + endpointBlockInfo, +} from './affiliation'; import { compareParagraphsOrder, findContentDOM, @@ -324,6 +328,18 @@ class Selection { ? anchorBlockRef.getFormatsInRange().formats : []; + // PARITY (gap PG1): re-derive the legacy `selectionChange` block-context + // the desktop Paragraph/Format menu state builder consumes — + // `affiliation` is the shared ancestor PARAGRAPH-type chain, and the + // per-endpoint `{ type, functionType }` describe the content leaves + // (`type: 'span'`, `functionType: 'codeContent' | 'cellContent' | …`). + const affiliation = buildSelectionAffiliation( + this.anchorBlock, + this.focusBlock, + ); + const anchorBlockInfo = endpointBlockInfo(this.anchorBlock); + const focusBlockInfo = endpointBlockInfo(this.focusBlock); + this.muya.eventCenter.emit('selection-change', { anchor, focus, @@ -338,6 +354,9 @@ class Selection { selectedImage, cursorCoords, formats, + affiliation, + anchorBlockInfo, + focusBlockInfo, }); } @@ -509,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); @@ -535,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/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], + }; +} 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..e16cb0dbc7 --- /dev/null +++ b/packages/muya/src/state/__tests__/parityExportHtml.spec.ts @@ -0,0 +1,101 @@ +// @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 export output: the engine now inlines base CSS (PG7) and +// injects github-compatible heading ids (PG8), so they pass. + +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( + '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/src/types.ts b/packages/muya/src/types.ts index d7253d4f52..ea155974b0 100644 --- a/packages/muya/src/types.ts +++ b/packages/muya/src/types.ts @@ -50,6 +50,46 @@ export interface IMuyaOptions { * Ported from the legacy `@muyajs` `clipboardFilePath` option. */ clipboardFilePath?: () => Promise; + /** + * Persist an image per the embedder's insert preference (copy into the + * document's assets folder, upload to an image host, or keep the path) and + * 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) — 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; +} + +/** + * Image descriptor passed to {@link IMuyaOptions.imageAction}. Mirrors the + * `{ src, alt, title }` shape used by the image-edit toolbar. + */ +export interface IImageActionState { + /** Image source — an absolute local path or a `data:` URL for a bitmap. */ + src: string; + /** Image alt text. */ + alt: string; + /** Image title. */ + title: string; } export type Nullable = T | null | undefined | void; diff --git a/packages/muya/src/utils/paste.ts b/packages/muya/src/utils/paste.ts index ea3edc9b90..4ee8f4cc22 100644 --- a/packages/muya/src/utils/paste.ts +++ b/packages/muya/src/utils/paste.ts @@ -151,6 +151,96 @@ export async function resolveClipboardImagePath( return ''; } +/** + * Extract an in-memory image `File` from a paste `DataTransfer`. + * + * Covers the bitmap clipboard case (PG05): screenshots and browser + * "Copy Image" put image bytes — not a file path — on the clipboard. We + * prefer `clipboardData.files` and fall back to scanning `clipboardData.items` + * for the first `image/*` entry. Returns `null` when no image is present. + * + * Ported from the legacy `@muyajs` `pasteImage` `items[i].getAsFile()` snapshot. + */ +export function getClipboardImageFile( + clipboardData: DataTransfer | null, +): File | null { + if (!clipboardData) + return null; + + const { files, items } = clipboardData; + + if (files && files.length > 0) { + for (const file of Array.from(files)) { + if (file.type.startsWith('image/')) + return file; + } + } + + if (items) { + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) + return file; + } + } + } + + return null; +} + +/** + * Read a `File`/`Blob` as a base64 `data:` URL. + * + * Used to turn a pasted bitmap (PG05) into a `data:` URL that the embedder's + * `imageAction` can persist. Prefers the native {@link FileReader} + * (`readAsDataURL`), matching the legacy `@muyajs` path and covering the + * `chrome70` build target where `Blob.arrayBuffer()` is unavailable; falls + * back to `Blob.arrayBuffer()` + `btoa` where `FileReader` is absent (e.g. the + * Node test environment). Resolves to `''` on read error. + */ +export function readFileAsDataURL(file: File): Promise { + if (typeof FileReader !== 'undefined') { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + resolve(typeof reader.result === 'string' ? reader.result : ''); + }); + reader.addEventListener('error', () => resolve('')); + reader.readAsDataURL(file); + }); + } + + // Fallback for environments without `FileReader` (e.g. Node tests). Guard + // the dependencies so a missing API resolves to '' rather than throwing + // out of the `Promise` contract. + if (typeof file.arrayBuffer !== 'function' || typeof btoa !== 'function') + return Promise.resolve(''); + + return file + .arrayBuffer() + .then(bufferToDataURL(file.type)) + .catch(() => ''); +} + +/** + * Base64-encode an `ArrayBuffer` into a `data:` URL of the given MIME type. + * Processes the bytes in chunks so a large blob doesn't build one huge + * intermediate string via per-byte concatenation. + */ +function bufferToDataURL(mimeType: string) { + return (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + const chunk = bytes.subarray(i, i + CHUNK); + binary += String.fromCharCode(...chunk); + } + return `data:${mimeType};base64,${btoa(binary)}`; + }; +} + /** * * @param {string} html 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