Repository navigation
feat(muya): add createTable / insertImage / setCursor API - #4397
Conversation
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  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>
There was a problem hiding this comment.
Pull request overview
This PR expands the @muyajs/core (packages/muya) public, programmatic editing surface to support the remaining desktop menu actions needed for the migration off legacy packages/muyajs: table insertion, inline image insertion, and programmatic cursor placement.
Changes:
- Add
Muya.createTable({ rows, columns })to replace the current block with a table and move the caret into the first cell. - Add
Muya.insertImage({ src, alt })to insert inline image markdown via aFormatblock text update (JSON op-backed). - Add
Muya.setCursor(cursor)to restore selection by resolving blocks from paths (same-block vs cross-block). - Add a new Vitest happy-dom spec covering the new APIs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/muya/src/muya.ts | Adds the new public APIs (createTable, insertImage, setCursor) on Muya. |
| packages/muya/src/tests/createTableImageCursor.spec.ts | Adds unit tests covering the new editing APIs under happy-dom. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| createTable({ rows, columns }: { rows: number; columns: number }) { | ||
| const block = this._outmostBlockAtCursor(); | ||
| if (!block) | ||
| return; | ||
|
|
There was a problem hiding this comment.
Good catch — fixed in cd6cb7a. createTable now coerces rows/columns to integers and clamps them to a valid GFM shape (rows >= 2 for header + at least one body row, columns >= 1): Math.max(2, Number.isFinite(rows) ? Math.floor(rows) : 0) and the column equivalent. So rows: 0, NaN, Infinity, and fractional/negative values now yield a usable table instead of an empty-row state that would crash Table.columnCount (which reads firstChild.firstChild). Added spec cases for {rows: 0, columns: 0}, non-finite, and fractional dimensions.
| // Only percent-encode plain paths; leave full URLs / data URLs as-is. | ||
| // Mirrors legacy `insertImage` / `replaceImage` src handling. | ||
| let imgUrl: string; | ||
| if (URL_REG.test(src)) | ||
| imgUrl = encodeURI(src); | ||
| else if (/^data:image\//.test(src)) | ||
| imgUrl = src; | ||
| else | ||
| imgUrl = src.replace(/ /g, encodeURI(' ')).replace(/#/g, encodeURIComponent('#')); |
There was a problem hiding this comment.
Fixed in cd6cb7a. The loose /^data:image\// check is replaced with the strict DATA_URL_REG (/^data:image\/[\w+-]+(?:;[\w-]+=[\w-]+|;base64)*,[a-zA-Z0-9+/]+={0,2}$/), which is the exact regex legacy muyajs insertImage/imageCtrl and packages/muya/src/utils/image.ts getImageSrc already use. I promoted it to config/index.ts so it's reused rather than duplicated. A bare/malformed data:image/... src no longer embeds verbatim; it falls through to the percent-encoded plain-path branch (matching legacy). Added spec cases for a well-formed base64 data URL (embedded verbatim) vs a malformed data: src (not embedded verbatim).
…ta-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>
Motivation
The desktop app is migrating off the legacy
packages/muyajsengine ontopackages/muya(@muyajs/core). The block-editing API the desktop menusdrive (
duplicate/insertParagraph/deleteParagraph/updateParagraph)already landed in
develop; this PR adds the remaining three methods so theTable/Image/Cursor desktop actions have a public entrypoint on
@muyajs/core.All three are added on
Muya(packages/muya/src/muya.ts), adjacent to theexisting
deleteParagraph().What's added
createTable({ rows, columns })Builds the equivalent muya
ITableStateof the legacycreateTableInFigure:rowstotal rows ×columnscolumns, the first row as the header, every row atable.rowwithtable.cellchildren (meta.align: 'none', empty text).Creates it via
ScrollPage.loadBlock('table').create(...)and replaces theblock at the cursor (
_outmostBlockAtCursor()→block.replaceWith(...)),placing the caret in the first cell. Matches muya's
replaceBlockByLabel('table')/
Table.createconventions.insertImage({ src, alt })Inserts an inline image at the cursor in the active
Formatblock. Themarkdown is written through the block'stextsetter so itdispatches a JSON op (state stays in sync) rather than bypassing state sync.
Derives
altfrom the file name when none is given and percent-encodes plainpaths (leaving full URLs / data URLs intact), mirroring legacy
insertImageand muya's
replaceImage. No-ops safely when there is no active formattableblock (e.g. inside a code block, or no cursor).
setCursor(cursor)Sets the cursor programmatically. Resolves the target block(s) by path on the
live tree (
editor.scrollPage.queryBlock(path)) and restores the selection thesame way
Editor.updateContentsdoes:block.setCursor(begin, end, true)forthe same-block case, falling back to
editor.selection.setSelection({...})withresolved block instances for cross-block (passing bare paths does not work —
Selection._setCursorneeds a block'sdomNode). Accepts both the{ anchor, focus, anchorPath, focusPath }and{ start, end, path }/blockICursorshapes.Tests
packages/muya/src/__tests__/createTableImageCursor.spec.ts(happy-dom,state flushes on rAF →
vi.waitFor):createTable: correct dimensions, empty cells withalign: 'none', caret inthe first cell, no-op without a current block.
insertImage: image markdown in the document (getMarkdowncontains thesrc), alt derivation, explicit alt, space percent-encoding, no-op without an
active formattable block.
setCursor: caret positioned in the same block (bothICursorshapes),cross-block resolution, and a no-op for an unresolvable path.
Verification
All green from
packages/muya:pnpm lint— 0 errors (pre-existing warnings only)pnpm lint:types— cleanpnpm check-circular— no circular dependencypnpm test— 454 passed (+13)pnpm test:spec— 1347 passed🤖 Generated with Claude Code