feat(editor): 图片可选中换行、可调大小、右键链接针对图片(docs/31)

用户反馈三件:图片选不中没法在后面打字、右键不能改大小、图片的
超链接是空的。无头 Chrome 真实点击复现根因:点击能形成节点选区
但无任何视觉提示;图片选中时按 Enter 走 splitBlock,新段落插到
图片前面且选区仍停在图片上,接着打字会把图片替换掉。
- Enter 修复:捕获阶段 keydown 只接管图片选区,在图片所在文本块后
  插空段落并移入光标(createParagraphNear 对行内节点无效,自己写);
  选中图片加主题色描边。
- 大小:图片节点加 width 属性,带尺寸时序列化为 <img … width>、
  否则标准语法;remark 插件把孤立 <img> 解析回图片节点(实体解码、
  块级包段落),往返逐字一致;右键「调整图片大小…」弹窗 px/% 可调。
- 链接:右键图片的「超链接…」改为图片模式,预填包裹链接、提交对
  图片节点挂/摘 link 标记([![alt](src)](href) 可往返)。
验证:vitest 148+2(新增 image-size/image-enter 共 12 例)、构建通过、
无头 Chrome 实测点击/Enter/打字全流程。
This commit is contained in:
Muse
2026-11-15 09:41:24 +08:00
parent fb51276bc5
commit 5f4a80fa01
11 changed files with 628 additions and 17 deletions

44
tests/image-enter.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { editorViewCtx } from '@milkdown/kit/core'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
describe('图片选中时按 Enter(docs/31)', () => {
it('在图片段落后新建空段落并把光标移入,图片保留', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, '前文\n\n![图](a.png)\n\n后文\n')
await editor.create()
const after = editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let pos = -1
view.state.doc.descendants((n, p) => { if (n.type.name === 'image') pos = p; return true })
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
const blocks: string[] = []
view.state.doc.forEach((n) => blocks.push(n.type.name + ':' + n.textContent + (n.content.size && n.firstChild?.type.name === 'image' ? '[img]' : '')))
return { blocks, selType: view.state.selection.constructor.name, selFrom: view.state.selection.from }
})
expect(after.blocks).toEqual(['paragraph:前文', 'paragraph:[img]', 'paragraph:', 'paragraph:后文'])
expect(after.selType).toContain('TextSelection')
expect(getMarkdown(editor)).toContain('![图](a.png)')
await editor.destroy(); root.remove()
})
it('普通文本处按 Enter 不被接管(仍是原有分段行为)', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, '你好世界\n')
await editor.create()
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
// 光标放文本中间(非图片选区),捕获监听应放行——这里只断言监听不误伤:
// jsdom 里默认 Enter 链未必完整执行,至少文档不能被插入多余段落到图片逻辑位置
view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
return null
})
// 不抛错、编辑器仍可用即可(默认链行为由无头浏览器实测覆盖)
expect(getMarkdown(editor)).toContain('你好世界')
await editor.destroy(); root.remove()
})
})

108
tests/image-size.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { editorViewCtx } from '@milkdown/kit/core'
import { createMarkdownEditor, getMarkdown, imageInfoAtElement, updateImageAtElement } from '../src/editor/editor'
import { parseImgTag, widthToCss } from '../src/editor/imageSchema'
async function makeEditor(md: string) {
const root = document.createElement('div')
document.body.appendChild(root)
const editor = createMarkdownEditor(root, md)
await editor.create()
return { root, editor }
}
function imageAttrs(editor: Awaited<ReturnType<typeof makeEditor>>['editor']) {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const list: Array<Record<string, unknown>> = []
view.state.doc.descendants((n) => { if (n.type.name === 'image') list.push({ ...n.attrs }); return true })
return list
})
}
describe('图片尺寸往返(docs/31)', () => {
it('普通 markdown 图片不带尺寸、原文不变', async () => {
const { root, editor } = await makeEditor('前文 ![图](a.png) 后文\n')
expect(imageAttrs(editor)).toEqual([{ src: 'a.png', alt: '图', title: '', width: null }])
expect(getMarkdown(editor)).toBe('前文 ![图](a.png) 后文\n')
await editor.destroy(); root.remove()
})
it('块级 <img width> 解析为带尺寸的图片节点,序列化逐字还原', async () => {
const md = '前文\n\n<img src="b.png" alt="乙" width="300">\n\n后文\n'
const { root, editor } = await makeEditor(md)
expect(imageAttrs(editor)).toEqual([{ src: 'b.png', alt: '乙', title: '', width: '300' }])
expect(getMarkdown(editor)).toBe(md)
await editor.destroy(); root.remove()
})
it('行内 <img width="50%"> 同样识别,序列化补 alt=""', async () => {
const { root, editor } = await makeEditor('前 <img src="a.png" width="50%"> 后\n')
expect(imageAttrs(editor)[0].width).toBe('50%')
expect(getMarkdown(editor)).toBe('前 <img src="a.png" alt="" width="50%"> 后\n')
await editor.destroy(); root.remove()
})
it('非 img 的 HTML 不受影响(保持 html 节点原文)', async () => {
const md = '前 <span class="x">标</span> 后\n'
const { root, editor } = await makeEditor(md)
expect(imageAttrs(editor)).toEqual([])
expect(getMarkdown(editor)).toBe(md)
await editor.destroy(); root.remove()
})
it('给图片设尺寸 → 序列化为 HTML;清掉尺寸 → 回到 markdown 语法', async () => {
const { root, editor } = await makeEditor('![图](a.png)\n')
const img = root.querySelector('img')!
expect(updateImageAtElement(editor, img, { width: '200' })).toBe(true)
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="图" width="200">\n')
expect(updateImageAtElement(editor, img, { width: null })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](a.png)\n')
await editor.destroy(); root.remove()
})
it('alt 里的引号在 HTML 序列化时转义、解析时还原', async () => {
const { root, editor } = await makeEditor('<img src="a.png" alt="a&quot;b" width="10">\n')
expect(imageAttrs(editor)[0].alt).toBe('a"b')
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="a&quot;b" width="10">\n')
await editor.destroy(); root.remove()
})
})
describe('parseImgTag / widthToCss', () => {
it('只认孤立 img 且必须有 src', () => {
expect(parseImgTag('<img src="a.png" width="300">')).toEqual({ src: 'a.png', width: '300' })
expect(parseImgTag('<img alt="x">')).toBeNull()
expect(parseImgTag('<img src="a.png"> 后')).toBeNull()
expect(parseImgTag('<div><img src="a.png"></div>')).toBeNull()
})
it('纯数字补 px,带单位原样', () => {
expect(widthToCss('300')).toBe('300px')
expect(widthToCss('50%')).toBe('50%')
expect(widthToCss(null)).toBeNull()
expect(widthToCss('')).toBeNull()
})
})
describe('图片链接标记(docs/31)', () => {
it('info/update:设置包裹链接 → [![alt](src)](href),清空则还原', async () => {
const { root, editor } = await makeEditor('![图](a.png)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)).toEqual({ src: 'a.png', alt: '图', width: null, linkHref: null })
expect(updateImageAtElement(editor, img, { linkHref: 'https://example.com' })).toBe(true)
// 无 NodeView 的测试环境里标记变化会重渲染 img 元素,重新取一次
const img2 = root.querySelector('img')!
expect(imageInfoAtElement(editor, img2)?.linkHref).toBe('https://example.com')
expect(getMarkdown(editor)).toBe('[![图](a.png)](https://example.com)\n')
expect(updateImageAtElement(editor, img2, { linkHref: null })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](a.png)\n')
await editor.destroy(); root.remove()
})
it('已有包裹链接的文档解析后能读出 href', async () => {
const { root, editor } = await makeEditor('[![图](a.png)](https://x.dev)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)?.linkHref).toBe('https://x.dev')
await editor.destroy(); root.remove()
})
})