Skip to content

[pull] develop from marktext:develop - #47

Merged
pull[bot] merged 17 commits into
code:developfrom
marktext:develop
Jun 8, 2026
Merged

[pull] develop from marktext:develop#47
pull[bot] merged 17 commits into
code:developfrom
marktext:develop

Conversation

@pull

@pull pull Bot commented Jun 8, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

Jocs and others added 17 commits June 8, 2026 11:36
* feat(muya): export html/image/sanitize/wordcount utilities from public API

Desktop consumes escapeHTML, unescapeHTML, getImageInfo, wordCount,
sanitize and generateGithubSlug from the legacy muyajs `muya/lib/utils`
tree. The implementations already exist in @muyajs/core's src/utils but
were not re-exported from the public entrypoint. Surface them so the
desktop migration can drop the muyajs util imports.

Part of the muyajs -> @muyajs/core engine migration (stage A10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(muya): expose clipboard copy/paste on public API

Surface copyAsMarkdown / copyAsHtml / pasteAsPlainText on the Muya class,
delegating to the internal Clipboard module. The desktop Edit menu drives
"Copy as Markdown/HTML" and "Paste as Plain Text" through these, which had
no public entrypoint on @muyajs/core.

Part of the muyajs -> @muyajs/core engine migration (stage A6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(muya): expose selection/focus accessors on public API

Surface getSelection, hasFocus, blur(unfocus, clearSelection) and
hideAllFloatTools on the Muya class. The desktop editor relies on these
for the typewriter/selection sync, editor-blur/focus bus actions, and
hiding floating tools before exports — all of which previously lived only
on the internal selection / ui modules.

Part of the muyajs -> @muyajs/core engine migration (stage A3, partial;
setCursor lands with the content-loading work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(muya): expose inline format(type) on public API

Surface format(type) on the Muya class so the desktop Format menu
(strong/em/u/del/inline_code/link/image/inline_math/sub/sup/mark/clear)
can drive inline formatting programmatically. Mirrors the inline format
toolbar: narrows the active block to a Format, restores the selection,
then toggles the format — previously only reachable through toolbar/
shortcut interaction.

Part of the muyajs -> @muyajs/core engine migration (stage A2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): align blur() with muyajs — clear image selection and hide floats

Address Copilot review on #4381. The previous blur() cleared the DOM
selection but left editor.selection.selectedImage set and never hid the
image transformer/toolbar, so a previously selected inline image could
stay visually selected with its resize bar lingering after blur.

Match marktext muyajs blur(isRemoveAllRange, unSelect): always hide all
float tools and blur the contenteditable node; on unSelect clear the
selected inline image (muya has no selectedTableCells in its model, so
only the image is cleared). The desktop editor-blur path calls
blur(false, true), so the lingering image selection/resize bar is fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): expose themeable CSS variables

Introduce CSS custom properties so desktop's 32 themes can later drive
muya's editor chrome (part of the muyajs -> @muyajs/core migration).
Each new variable defaults to muya's current hardcoded / opacity-tier
value via `var(--name, <fallback>)`, so the bundled light theme renders
identically.

Added in index.css `:root`:
- --editor-area-width (800px) for `.mu-container` max-width
- --link-color (rgb(20 86 240))
- --h1-color .. --h6-color (var(--editor-color-80))
- --blockquote-text-color (var(--editor-color-50))
- --blockquote-border-color (var(--editor-color-30))
- --hr-color (var(--editor-color-10))
- --strong-color / --em-color / --list-marker-color (inherit)
- --button-bg-color-active / --button-border-active / --button-border-focus

Usage sites updated in blockSyntax.css (container width, per-heading
colors, blockquote text + border, thematic break, list markers) and
inlineSyntax.css (link color, strong + em color).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(muya): theme mermaid/vega diagram SVGs

Port marktext's legacy diagram-recoloring rules (packages/muyajs/themes/
default.css `figure[data-role] svg …`) into muya's blockSyntax.css,
adapted to muya's live DOM (`figure.mu-diagram-block .mu-diagram-preview
svg`) and kebab-cased vars (`--editor-bg-color`, `--editor-color`).

Mermaid and vega-lite render an inline <svg> whose default black-on-white
fills/strokes are rewritten to the editor's theme colors, so diagrams
follow the active theme (including dark themes that override those vars).
Additive and entirely theme-driven; the bundled light theme is unchanged
because `--editor-bg-color` is white and `--editor-color` is the editor
text color, matching the diagrams' original look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(muya): correct themeable-vars comment about desktop overrides

Address Copilot review on #4382. The comment implied the desktop themes
already use kebab-case overrides, but they currently use camelCase
(--linkColor, --h1Color, …). Clarify that consumers override per theme and
that the desktop themes will be migrated to these kebab-case names as part
of the muyajs -> @muyajs/core move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add block duplicate/insert/delete public API

Expose duplicate(), insertParagraph(location, text) and deleteParagraph()
on the Muya class so the desktop Edit/Paragraph menu can manipulate block
structure programmatically — previously only reachable through the
paragraph front-menu UI. They resolve the target via the active content
block's outMostBlock (which survives the menu/IPC round-trip) and reuse the
same block primitives as the front menu (ScrollPage.loadBlock().create,
insertAfter/insertBefore, remove). Adds happy-dom unit coverage.

Part of the muyajs -> @muyajs/core engine migration (stage A1, partial;
updateParagraph/createTable/insertImage land next).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(muya): preserve/restore window.MUYA_VERSION in block-editing spec

Address Copilot review on #4384: mirror the preserve-and-restore pattern
used by the other happy-dom specs (getTOC.spec.ts, quick-insert hint.spec.ts)
instead of unconditionally deleting window.MUYA_VERSION in afterEach, so the
spec does not leak global state across test files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): render flowchart and sequence diagrams

Restore the flowchart and sequence diagram code blocks that the legacy
muyajs engine supported but the TypeScript rewrite dropped, for feature
parity ahead of the desktop migration.

- Add flowchart.js (the package legacy used) plus the snap.svg /
  underscore / webfontloader runtime deps for sequence diagrams.
- Vendor the js-sequence-diagrams library (bramp, BSD) under
  src/utils/diagram/sequence/. The js-sequence-diagrams npm package is a
  dead security-holder placeholder, so legacy vendored the source; we do
  the same. Dropped its dead jison UMD `exports` fallback (the file is
  ESM) to avoid a Rollup COMMONJS_VARIABLE_IN_ESM warning.
- Wire flowchart + sequence into loadRenderer, the live diagram preview
  (diagramPreview), and the HTML export path (markdownToHtml). Both use
  the parse(code).drawSVG(target, options) API, matching legacy.
- Parse ```flowchart``` / ```sequence``` fences into diagram blocks
  (markdownToState, IDiagramMeta, getHighlightHtml diagram-type list).
- Add the sequenceTheme option ('hand' | 'simple', default 'hand') to
  IMuyaOptions and MUYA_DEFAULT_OPTIONS, and honor it when rendering
  sequence diagrams.
- Ambient module declarations for flowchart.js, snapsvg-cjs and the
  vendored sequence module; ignore the vendored third-party files in
  eslint (kept verbatim).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(muya): add flowchart and sequence insert menu entries

- Add Flowchart and Sequence entries to the quick-insert diagram section
  and wire the insert handler to create diagram blocks of those types.
- Add flowchart + sequence icons to the paragraph front-button diagram
  icon map. Reuse the existing flowchart icon (already present in muya
  assets) and port the sequence icon from the legacy muyajs pngicon set.
- Center exported flowchart / sequence diagram divs (exportStyle.css),
  matching plantuml / mermaid / vega-lite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(muya): cover flowchart and sequence diagram parity

- diagramFlowchartSequence.spec.ts: ```flowchart``` and ```sequence```
  fences parse into diagram blocks of the right type and round-trip back
  to their fence via stateToMarkdown; mermaid/plantuml/vega-lite still
  parse (no regression); sequenceTheme defaults to 'hand'.
- diagramMenuEntries.spec.ts: the quick-insert diagram section exposes
  flowchart + sequence entries (with icons) alongside the existing three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add ImagePathPicker floating autocomplete UI

Port the legacy `imagePicker` plugin (packages/muyajs/lib/ui/imagePicker)
to the new TypeScript engine so the desktop migration off packages/muyajs
retains the local image-path autocomplete dropdown.

- Add `ImagePathPicker` (BaseScrollFloat subclass, pluginName
  "imagePathPicker") that renders a scrollable, keyboard-navigable list of
  path suggestions on the `muya-image-picker` event and reports the choice
  through the supplied callback.
- Wire it into ImageEditTool: the src input now calls the new
  `imagePathAutoComplete(src)` option on keyup and dispatches
  `muya-image-picker`; keydown routes arrows/Tab/Enter to the open picker.
  The picker is dismissed when the tool hides. All existing ImageEditTool
  behavior is preserved.
- Export `ImagePathPicker` + `IImagePathSuggestion` from the package entry.
- Add happy-dom vitest specs for the picker render/navigation/selection and
  for the ImageEditTool autocomplete wiring.

Icons are rendered via font-icon CSS classes from `iconClass` (the new
engine ships no inline SVG assets); see PR notes for the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): address ImagePathPicker review feedback (#4386)

Five fixes from the Copilot review:
- imageEditTool keydown: stopPropagation on Arrow/Tab so the editor's
  BaseScrollFloat keydown handler (bound on muya.domNode) does not also
  step the picker, which advanced the active item twice per keypress
  (the Enter case already stopped propagation).
- imageEditTool autocomplete: guard against out-of-order resolution with a
  monotonic sequence counter (fast typing could emit stale suggestions),
  and derive the selected path's directory prefix from the CURRENT input
  value rather than the value captured on keyup.
- imagePicker CSS: explicitly set justify-content: flex-start to override
  codeBlockLanguageSelector's global `.mu-list-picker .item`
  justify-content: space-between, which spaced icon/text apart.
- imagePicker render/getItemElement: key items by a stable data-index
  instead of data-label (file names can contain quotes/brackets — unsafe in
  an attribute selector — and duplicate basenames collided). Test updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`pnpm -C packages/muya lint:css` crashed on a clean checkout with
`TypeError: Cannot read properties of undefined (reading 'unprefixed')`.

Root cause: `.stylelintrc` extended `stylelint-config-rational-order/plugin`,
whose v0.1.2 bundles its own `stylelint-order@2.2.1` built for `stylelint@^9`.
That old rule reads a v9-shaped AST node and breaks on the `stylelint@17`
AST the repo actually runs, aborting the whole lint run before it reached
any file.

Fix: drop the unmaintained `plugin/rational-order` plugin and instead drive
the same property ordering through the maintained `stylelint-order@8`
(already a devDep) via the native `order/properties-order` rule. The rule's
group list is the exact rational-order expectation the old plugin generated
for this repo's options (`border-in-box-model: false`,
`empty-line-between-groups: true`), so the ordering intent is preserved with
no change to any CSS behavior. Ordering stays `severity: warning`, matching
the prior config. Removed the now-unused
`stylelint-config-rational-order` devDep (prunes its old stylelint@9 /
postcss@7 subtree from the lockfile).

Also fixed one pre-existing genuine error the working tool finally surfaced:
`font-family: 'danielbd'` -> `font-family: danielbd` in the vendored
sequence-diagram `@font-face` (the standard `font-family-name-quotes` fix;
identical rendering).

`pnpm -C packages/muya lint:css` now completes with 0 errors. lint,
lint:types, check-circular, test, and test:spec all still pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

* feat(muya): add cursorCoords and active formats to selection-change

The desktop editor relies on two extras the legacy muyajs put on its
selectionChange payload: cursorCoords (the caret's screen rect, used for
typewriter-mode scrolling via changes.cursorCoords.y) and the active inline
formats at the cursor (to light up the format toolbar). Add both to the
selection-change event emitted by Selection.setSelection. Formats are
duck-typed off the anchor block's getFormatsInRange to avoid a
selection -> format circular import. Adds happy-dom coverage.

Part of the muyajs -> @muyajs/core engine migration (stage A8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): address selection-change review (#4387)

- cursorCoords now follows the caret/focus end for forward range selections
  (getCursorCoords gains a `preferEnd` param, defaulted false so existing
  callers are unchanged; setSelection passes `direction === 'forward'`).
  getClientRects returns the first rect by default, which is the selection
  start — typewriter scrolling should track the caret, not the start.
- Strengthen the spec with a non-empty case: cursor inside `**bold**` now
  asserts a `strong` token is emitted in `formats`, not just that it is an
  array.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): implement focus mode (dim inactive blocks)

Focus mode was a declared no-op in @muyajs/core: the `focusMode` option and
the `mu-focus-mode` class name existed but nothing applied the class and there
was no dimming CSS.

- Add `Muya#setFocusMode(bool)` which toggles `mu-focus-mode` on the editor
  container and stores `options.focusMode`, mirroring legacy muyajs
  `setFocusMode`. Apply the class at construction (`getContainer`) when
  `focusMode: true` is passed.
- Add dimming CSS to blockSyntax.css: under `.mu-focus-mode`, every top-level
  block (`.mu-container > *`) fades to `opacity: 0.25` with a smooth
  transition, and the active block (`.mu-active`, applied to the focused
  content block's ancestor chain) stays at full opacity — mirroring legacy
  `.ag-focus-mode`.
- Add a happy-dom unit test asserting the class toggles via the constructor
  and `setFocusMode`, and rewrite the e2e spec (which previously documented
  the no-op) to assert the class is applied and inactive blocks are dimmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(muya): make focus-mode e2e opacity assertion non-flaky

The opacity assertion read getComputedStyle synchronously right after the
click, catching the active block mid-transition (the `opacity 0.2s` fade from
0.25 to 1), so it intermittently saw an interpolated value instead of "1".

Switch to Playwright's auto-retrying `toHaveCSS('opacity', ...)` on the active
and inactive block locators, which waits for the transition to settle. Verified
locally against system Chrome: all 3 focus-mode specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…4390)

* feat(muya): emit format-click interaction events for links and images

Desktop is migrating off legacy packages/muyajs to packages/muya
(@muyajs/core). The legacy engine emitted custom interaction events that
the desktop renderer's editor.vue subscribes to in order to open links and
preview images on a Cmd/Ctrl-click. packages/muya had no such emitter, so
port the link + image format-click events:

- editor/linkMouseEvents.ts: on a Cmd/Ctrl-click of a rendered link, emit
  `format-click` with { event, formatType: 'link', data } where data is the
  getLinkInfo payload (superset of the legacy { text, href }). Plain clicks
  keep their cursor-placement-only behavior (preventDefault unchanged).
- selection/index.ts (_handleClickInlineImage): on a Cmd/Ctrl-click of an
  <img>, emit `format-click` with { event, formatType: 'image', data: <src> }
  (src resolved via getImageSrc, falling back to the <img> src attribute).
  The plain-click image-toolbar / transformer / selector path is untouched.

The desktop's format-click handler re-checks the OS modifier and opens the
link / SimpleImageViewer, so muya stays platform-agnostic and only emits.

heading-copy-link is deferred: it needs new heading-hover UI that does not
exist in packages/muya yet.

Adds a happy-dom vitest spec that subscribes via muya.on('format-click')
and simulates modifier-clicks on a rendered link and image, asserting the
payload shape and that plain clicks do not emit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(muya): cover Ctrl-click image format-click (#4390)

Address Copilot review: the image format-click tests only exercised the
macOS metaKey modifier, but the handler emits on (metaKey || ctrlKey). Add a
Ctrl-click case for the image path, mirroring the existing link Ctrl-click test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add updateParagraph block-type conversion API

Expose updateParagraph(type) on the Muya class so the desktop Paragraph
menu can convert the block at the cursor. It accepts the marktext/muyajs
label vocabulary (paragraph, heading 1-6, upgrade/degrade heading,
blockquote, pre, mathblock, html, hr, table, front-matter,
ul-bullet/ol-order/ul-task, loose-list-item, reset-to-paragraph, and the
diagram types) and maps onto muya's replaceBlockByLabel, plus:
- heading upgrade/degrade level cycling (marktext semantics: paragraph->h6
  on upgrade, h6->paragraph on degrade);
- list-type conversion that rebuilds the list preserving every item
  (rather than collapsing to a single-item list of the lead text);
- loose/tight toggling of the current list.

Reuses the existing _outmostBlockAtCursor cursor resolution and the block
primitives the front menu/quick insert already use. Adds happy-dom coverage.

Part of the muyajs -> @muyajs/core engine migration (stage A1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): faithful updateParagraph reset/toggle/guards (#4391)

Address Copilot review on #4391 — close legacy-fidelity gaps so the desktop
Paragraph menu/command palette behave like muyajs and never drop content:

- Accept the command-palette `ol-bullet` label (the menu uses `ol-order`);
  both now map to an ordered list.
- `reset-to-paragraph` no longer collapses structured blocks to a single
  paragraph: lists and blockquotes unwrap into their child blocks (every
  item preserved); tables are left untouched; headings/hr/code fall back to
  a paragraph of their text.
- Selecting the active list type toggles the list off by unwrapping each
  item back into paragraphs (matches the menu's checkbox semantics), instead
  of a no-op.
- Guard hr/table conversions against non-empty content (legacy
  isAllowedTransformation), so converting a non-empty block no longer
  silently drops its text.

Adds regression tests for ol-bullet, list unwrap on reset + same-type
toggle, and the hr content guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dentation (#4393)

* feat(muya): add runtime setOptions / setFont / setTabSize / setListIndentation

Every desktop Preferences toggle needs editor options to update live; the
legacy engine exposed setOptions(options, needRerender) and the setFont/
setTabSize/setListIndentation helpers, which @muyajs/core lacked.

setOptions(options, forceRender) merges into muya.options, reflects the
container-level ones (spellcheck attribute, quick-insert hint class), and —
when forceRender is set — fully re-renders the document from its current
state via ScrollPage.updateState so render-affecting options (superSubScript,
footnote, disableHtml, frontmatterType, codeBlockLineNumbers, GitLab
compatibility, …) take effect. Unlike setContent, this preserves the undo
history; the cursor is captured and restored by path across the re-render.

setFont/setTabSize update their options; setListIndentation routes through
setOptions with a re-render. Adds happy-dom coverage.

Part of the muyajs -> @muyajs/core engine migration (stage A4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): correct setOptions cursor restore + history test (#4393)

Address Copilot review:
- Cursor restore after forceRender was broken: passing only paths to
  Selection.setSelection doesn't work (_setCursor needs a concrete block's
  domNode; a bare queryBlock result is not a Node). Resolve the block at the
  saved path on the rebuilt tree and call block.setCursor directly, mirroring
  Editor.updateContents' same-block restore.
- The history test was trivial (undo() on an empty stack never throws). Assert
  history.canUndo() stays true across a forceRender re-render instead — and
  documents that updateState uses the 'api' source so it neither clears nor
  pollutes history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add spellcheck word-replacement API

Add `replaceCurrentWordInlineUnsafe(word, replacement)` to @muyajs/core,
restoring the legacy muyajs `_replaceCurrentWordInlineUnsafe` API the
desktop spell checker relies on.

When the user right-clicks a misspelled word, Chromium selects the whole
word; choosing a suggestion from the context menu replaces it inline. The
new method finds the word at the cursor (using the VSCode-derived word
boundaries ported from legacy muyajs), asserts it matches the expected
`word`, replaces it through the Content text setter so the change
dispatches a json edit op, and places the cursor after the replacement.
It is a no-op when there is no active content block / cursor, or when the
word at the cursor does not match (guards a Chromium selection mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(muya): fix legacy source path in spellcheck word-boundary comments

The `extractWord` helper and the `WORD_SEPARATORS`/`WORD_DEFINITION`
regexes were ported from legacy muyajs `lib/marktext/spellchecker.js`
(which carries the VSCode wordHelper attribution), not from
`lib/contentState/core.js`. `core.js` only holds the `replaceWordInLine`
range-replacement helper, so the previous comments pointed at the wrong
file. Correct both references to ease future maintenance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): serialize/restore undo history

Add a JSON-serializable view of the History undo/redo stacks so the
desktop shell can persist each tab's editing history across tab switches.

- History.getHistory() returns a deep, JSON-serializable snapshot of the
  undo/redo stacks plus the lastRecorded / selectionStack bookkeeping.
  ot-json1 ops are plain JSON arrays (deep-cloned as-is). The stored
  selection carries live anchorBlock/focusBlock references, so the
  snapshot strips them and keeps only the serializable anchorPath /
  focusPath + offsets.
- History.setHistory() restores the snapshot. The restored, path-only
  selections re-resolve their target block from the path on apply:
  editor.updateContents and selection._setCursor both fall back to
  scrollPage.queryBlock(path) when no block instance is present, so the
  caret restores losslessly.
- History.clear() now also resets selectionStack / lastRecorded.
- Muya exposes getHistory() / setHistory() / clearHistory() delegating to
  editor.history, placed immediately after redo().

Adds a happy-dom vitest spec covering the JSON round-trip, the
setHistory(getHistory()) + undo() lossless reproduction of prior states,
the redo() round-trip, and clearHistory().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(muya): type history selections honestly without unsound cast

`History._fromSerializableSelection` rebuilt a path-only selection (no live
`anchorBlock` / `focusBlock`) and cast it `as unknown as ISelection`, where
those block fields are required — an unsound double-cast. The restored
selection is consumed by `Editor.updateContents`, which re-resolves the
target block from `anchorPath` / `focusPath` via `scrollPage.queryBlock`, so
the block instances are genuinely optional at restore time.

Introduce a dedicated `IHistorySelection` type (`ISelection` with optional
`anchorBlock` / `focusBlock`) in `selection/types.ts` and use it for the
history stacks (`IOperation.selection`, `_selectionStack`), the serialize /
restore helpers, and `Editor.updateContents`. A full `ISelection` is
assignable to it, so live selections from `getSelection()` still fit without
a cast, and the restored path-only object now type-checks directly — no
`as unknown as X` needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The renderer memoises loaded inline images in `loadImageMap` (skipped on
the next render once `isSuccess` is true) and resolved URLs in `urlMap`,
so an image whose file changed on disk would keep showing the stale
bitmap with no way to flush it.

Add `InlineRenderer.invalidateImageCache()` which clears both caches and
re-renders every content block (via `scrollPage.breadthFirstTraverse`),
so `loadImageAsync` runs afresh for inline images. Expose it publicly as
`Muya.invalidateImageCache()`, mirroring legacy muyajs
`StateRender.invalidateImageCache` so the desktop migration can call
`editor.invalidateImageCache()` after a watched image file changes or on
the `mt::invalidate-image-cache` IPC.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(muya): port MarkText regression cases (#4341/#4307/#4190)

Port three MarkText-specific regression cases from the legacy
`packages/muyajs` desktop specs into `@muyajs/core`'s own suite to lock
in behavioral fidelity for the migration off legacy muyajs. Tests only —
no engine changes.

- #4341 nested mixed lists (ul-in-ol / ol-in-ul): PASS. The state tree
  from MarkdownToState nests the differing-type list under the correct
  list-item (no paragraph collapse), and md -> state -> md round-trips
  identically. Ported as structural + round-trip assertions.
- #4190 table normalization (body row with more/fewer cells than the
  header): PASS. StateToMarkdown.serializeTable clamps each row to the
  header column count (extra cell dropped) and never throws. The legacy
  spec hand-built a malformed block tree for ExportMarkdown.normalizeTable;
  the muya equivalent hand-builds a malformed ITableState because a GFM
  round trip can never produce a ragged table.
- #4307 CJK strong flanking (`**"加粗"**` against a CJK boundary):
  documented engine GAP. marked@16 implements the CommonMark flanking
  rule literally and classifies CJK ideographs / Hangul as "other"
  (neither whitespace nor punctuation), so `**` adjacent to a CJK char
  with punctuation-bounded inner content does not open/close emphasis.
  Legacy muyajs shipped a custom tokenizer that treats CJK as punctuation
  for flanking; marked does not. The four CJK cases assert the CORRECT
  (legacy) behavior under `it.fails`, so the suite stays green while the
  gap exists and flips red the moment the gap closes. Sanity cases that
  already work are plain `it` so a future fix can't regress them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(muya): drop duplicate tableNormalization spec

The serializeTable ragged-row coverage in tableNormalization.spec.ts
duplicated the existing serializeTable — row width mismatch suite in
stateToMarkdown.spec.ts (both #4222/#4190): same well-formed,
extra-cell-dropped, and short-row scenarios. Keeping two suites for one
behavior risks them drifting apart, so remove the redundant file and
keep the single existing suite as the source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add createTable / insertImage / setCursor API

Complete the programmatic block-editing surface @muyajs/core exposes for
the desktop migration off legacy packages/muyajs, alongside the existing
duplicate / insertParagraph / deleteParagraph / updateParagraph methods.

- createTable({ rows, columns }): builds a GFM ITableState (first row is
  the header, every cell empty with align 'none') and replaces the block
  at the cursor with it, placing the caret in the first cell. Mirrors
  legacy createTableInFigure/createFigure and muya's table conventions.
- insertImage({ src, alt }): writes ![alt](src) at the cursor in the
  active Format block through the text setter so it dispatches a JSON op
  (state stays in sync); derives alt from the file name and percent-
  encodes plain paths the way legacy insertImage / replaceImage do.
  No-ops when there is no active formattable block.
- setCursor(cursor): resolves the target block(s) by path on the live
  tree and restores the selection the way Editor.updateContents does —
  block.setCursor for same-block, selection.setSelection with resolved
  block instances for cross-block (bare paths don't work — _setCursor
  needs a block's domNode). Accepts the anchor/focus/path and start/end
  ICursor shapes.

Adds a happy-dom vitest spec covering dimensions, cell defaults, caret
placement, image markdown/alt/encoding, and same/cross-block cursor
resolution plus the no-op guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): validate createTable dimensions and tighten insertImage data-URL check

Address Copilot review on the createTable / insertImage / setCursor API:

- createTable now coerces rows/columns to integers and clamps to a valid
  GFM shape (rows >= 2, columns >= 1). Garbage input (rows: 0, NaN,
  Infinity, fractional/negative values) previously built an invalid table
  state and could crash Table.columnCount (which reads firstChild.firstChild).

- insertImage now uses the strict DATA_URL_REG instead of a loose
  /^data:image// prefix check, aligning with legacy muyajs insertImage and
  utils/image.ts getImageSrc. A bare/malformed data:image src no longer
  embeds verbatim; it falls through to the percent-encoded plain-path branch.
  Promoted the regex to config/index.ts for reuse.

Extends createTableImageCursor.spec.ts with clamping/coercion cases and
well-formed vs malformed data-URL cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(muya): add clipboardFilePath paste hook

Port the legacy @muyajs `clipboardFilePath` option to @muyajs/core. When
the user pastes and the OS clipboard holds a file (e.g. an image copied
from a file manager rather than image bytes), the embedder resolves it to
a local path; if that path is a non-empty image file, muya inserts it as
an inline image at the cursor instead of running the default text/HTML
paste. Returning '' (or omitting the hook) preserves all existing paste
behaviour.

- Add optional `clipboardFilePath?: () => Promise<string>` to IMuyaOptions.
- Add a pure `resolveClipboardImagePath` helper in utils/paste that gates
  the hook result on IMAGE_EXT_REG (matching the legacy pasteImage guard).
- Short-circuit the clipboard paste flow when the hook yields an image
  path, splicing `![](src)` into the anchor block (src escaped like
  Format.replaceImage) and placing the cursor after it.
- Tests: unit-cover resolveClipboardImagePath and exercise pasteHandler
  end to end (hook invoked, image inserted, escaping, and fall-through
  when the hook returns '' / a non-image / is absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(muya): snapshot clipboardData before awaiting clipboardFilePath hook

`pasteHandler` awaited `resolveClipboardImagePath()` (the async
`clipboardFilePath` hook) before reading `event.clipboardData.getData()`.
Because the DOM paste listener does not await the handler, execution yields
at that first `await` and resumes after event dispatch completes — at which
point `event.clipboardData` can be detached and `getData()` returns ''. This
silently broke normal text/HTML paste whenever `clipboardFilePath` was
configured but resolved to '' / a non-image.

Snapshot text/html synchronously at the top of `pasteHandler`, before any
`await`, and thread the snapshot through the `!isSelectionInSameBlock`
recursion via optional `rawText`/`rawHtml` params so the re-entry never reads
a detached clipboard. Mirrors the legacy `@muyajs`
`pasteHandler(event, type, rawText, rawHtml)` ordering. Behaviour is
unchanged: the file-path hook still takes precedence and inserts an image,
and normal paste still works when the hook is absent/empty.

Add a regression test proving text/plain still pastes when the hook is
present-but-returns-''; update the precedence test to reflect that
clipboardData is now read synchronously up front.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Jun 8, 2026
@pull pull Bot added the ⤵️ pull label Jun 8, 2026
@pull
pull Bot merged commit efb720b into code:develop Jun 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant