feat(editor): 图片可选中换行、可调大小、右键链接针对图片(docs/31)
用户反馈三件:图片选不中没法在后面打字、右键不能改大小、图片的 超链接是空的。无头 Chrome 真实点击复现根因:点击能形成节点选区 但无任何视觉提示;图片选中时按 Enter 走 splitBlock,新段落插到 图片前面且选区仍停在图片上,接着打字会把图片替换掉。 - Enter 修复:捕获阶段 keydown 只接管图片选区,在图片所在文本块后 插空段落并移入光标(createParagraphNear 对行内节点无效,自己写); 选中图片加主题色描边。 - 大小:图片节点加 width 属性,带尺寸时序列化为 <img … width>、 否则标准语法;remark 插件把孤立 <img> 解析回图片节点(实体解码、 块级包段落),往返逐字一致;右键「调整图片大小…」弹窗 px/% 可调。 - 链接:右键图片的「超链接…」改为图片模式,预填包裹链接、提交对 图片节点挂/摘 link 标记([](href) 可往返)。 验证:vitest 148+2(新增 image-size/image-enter 共 12 例)、构建通过、 无头 Chrome 实测点击/Enter/打字全流程。
This commit is contained in:
44
tests/image-enter.test.ts
Normal file
44
tests/image-enter.test.ts
Normal 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\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('')
|
||||
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
108
tests/image-size.test.ts
Normal 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('前文  后文\n')
|
||||
expect(imageAttrs(editor)).toEqual([{ src: 'a.png', alt: '图', title: '', width: null }])
|
||||
expect(getMarkdown(editor)).toBe('前文  后文\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('\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('\n')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
|
||||
it('alt 里的引号在 HTML 序列化时转义、解析时还原', async () => {
|
||||
const { root, editor } = await makeEditor('<img src="a.png" alt="a"b" width="10">\n')
|
||||
expect(imageAttrs(editor)[0].alt).toBe('a"b')
|
||||
expect(getMarkdown(editor)).toBe('<img src="a.png" alt="a"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:设置包裹链接 → [](href),清空则还原', async () => {
|
||||
const { root, editor } = await makeEditor('\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('[](https://example.com)\n')
|
||||
expect(updateImageAtElement(editor, img2, { linkHref: null })).toBe(true)
|
||||
expect(getMarkdown(editor)).toBe('\n')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
|
||||
it('已有包裹链接的文档解析后能读出 href', async () => {
|
||||
const { root, editor } = await makeEditor('[](https://x.dev)\n')
|
||||
const img = root.querySelector('img')!
|
||||
expect(imageInfoAtElement(editor, img)?.linkHref).toBe('https://x.dev')
|
||||
await editor.destroy(); root.remove()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user