act('link')}>超链接…
>
)}
- {showUpload && (
+ {(showUpload || imageSrc) && (
<>
-
+ {showUpload &&
}
+ {imageSrc &&
}
>
)}
diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx
index 172f14a..c4ae7b1 100644
--- a/src/components/EditorPane.tsx
+++ b/src/components/EditorPane.tsx
@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
-import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, type CursorInfo } from '../editor/editor'
+import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, imageInfoAtElement, updateImageAtElement, type CursorInfo, type ImageNodeInfo, type ImageNodePatch } from '../editor/editor'
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
@@ -25,6 +25,10 @@ export interface EditorPaneApi {
replaceImageSrc: (oldSrc: string, newSrc: string) => number
/** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */
imageSrcForElement: (el: HTMLElement) => string | null
+ /** 某图片元素的节点信息(地址/替代文本/尺寸/包裹链接) */
+ imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null
+ /** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */
+ updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean
}
interface Props {
@@ -56,6 +60,8 @@ const EMPTY_API: EditorPaneApi = {
clearFind: () => undefined,
listImages: () => [],
replaceImageSrc: () => 0,
+ imageInfoForElement: () => null,
+ updateImageForElement: () => false,
imageSrcForElement: () => null,
}
@@ -247,6 +253,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 }
}
const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el)
+ const imageInfoForElement = (el: HTMLElement): ImageNodeInfo | null => imageInfoAtElement(editor, el)
+ const updateImageForElement = (el: HTMLElement, patch: ImageNodePatch): boolean => updateImageAtElement(editor, el, patch)
editor.create()
.then(() => {
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
@@ -257,7 +265,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 忽略 */ }
- callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement })
+ callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement, imageInfoForElement, updateImageForElement })
if (chunkLoad) {
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
void chunkLoad.appendRest(
diff --git a/src/components/Modals.tsx b/src/components/Modals.tsx
index c537a0e..bfb6fc7 100644
--- a/src/components/Modals.tsx
+++ b/src/components/Modals.tsx
@@ -140,11 +140,19 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
)
}
-export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) => void; onCancel: () => void }) {
- const [href, setHref] = useState('https://')
+export function LinkModal({ onSubmit, onCancel, initialHref, heading, hint }: {
+ onSubmit: (href: string) => void
+ onCancel: () => void
+ /** 预填地址(编辑已有链接时用);缺省为 https:// 前缀 */
+ initialHref?: string
+ heading?: string
+ hint?: string
+}) {
+ const [href, setHref] = useState(initialHref ?? 'https://')
return (
- 插入链接
+ {heading ?? '插入链接'}
+ {hint && {hint}
}
)
}
+/**
+ * 图片大小弹窗(docs/31):宽度数值 + 单位(px / %)。
+ * 确定返回 '300' 或 '50%' 形式的字符串;「恢复原始大小」返回 null(去掉尺寸)。
+ */
+export function ImageSizeModal({ current, onSubmit, onCancel }: {
+ current: string | null
+ onSubmit: (width: string | null) => void
+ onCancel: () => void
+}) {
+ const parsed = (() => {
+ if (!current) return { num: '', unit: 'px' as const }
+ const m = /^(\d+(?:\.\d+)?)(%|px)?$/.exec(current.trim())
+ if (m) return { num: m[1], unit: (m[2] === '%' ? '%' : 'px') as '%' | 'px' }
+ return { num: '', unit: 'px' as const }
+ })()
+ const [num, setNum] = useState(parsed.num)
+ const [unit, setUnit] = useState<'px' | '%'>(parsed.unit)
+ const value = Number(num)
+ const valid = num.trim() !== '' && Number.isFinite(value) && value > 0 && (unit === '%' ? value <= 100 : value <= 10000)
+ const submit = () => { if (valid) onSubmit(unit === '%' ? `${value}%` : `${value}`) }
+ return (
+
+ 调整图片大小
+
+
setNum(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
+ placeholder={unit === '%' ? '如 50' : '如 600'}
+ />
+
+
+
+
+
+ 宽度按{unit === '%' ? '段落宽度的百分比' : '像素'}缩放,高度等比。大小会写进文档(带尺寸的图片以 HTML 形式保存)。
+
+ {current && }
+
+
+
+
+ )
+}
+
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
return (
diff --git a/src/editor/editor.ts b/src/editor/editor.ts
index 666153b..02f099c 100644
--- a/src/editor/editor.ts
+++ b/src/editor/editor.ts
@@ -12,8 +12,9 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
-import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
+import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createImageEnterPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
+import { extendImageSchema } from './imageSchema'
export interface CursorInfo { line: number; col: number }
@@ -39,6 +40,8 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
ctx.set(defaultValueCtx, initialMarkdown)
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
+ // 图片尺寸扩展(docs/31):width 属性 +
HTML 往返
+ extendImageSchema(ctx)
const lm = ctx.get(listenerCtx)
if (callbacks.onMarkdown) {
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
@@ -109,6 +112,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(math)
.use(upload)
.use(createPlainTextPastePlugin())
+ .use(createImageEnterPlugin())
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
return editor
}
@@ -221,6 +225,85 @@ export function imageSrcAtElement(editor: Editor, el: HTMLElement): string | nul
}
}
+export interface ImageNodeInfo {
+ src: string
+ alt: string
+ /** 尺寸('300' = px、'50%' = 百分比);未设置时为 null */
+ width: string | null
+ /** 图片被链接包裹时的链接地址(链接标记的 href);没有时为 null */
+ linkHref: string | null
+}
+
+export interface ImageNodePatch {
+ src?: string
+ alt?: string
+ width?: string | null
+ /** undefined = 不动链接;null/'' = 去掉链接;字符串 = 设置/更新链接 */
+ linkHref?: string | null
+}
+
+function imagePosAtElement(view: { posAtDOM: (el: Node, off: number) => number; state: { doc: { nodeAt: (pos: number) => unknown } } }, el: HTMLElement): number {
+ const pos = view.posAtDOM(el, 0)
+ const node = view.state.doc.nodeAt(pos) as { type: { name: string } } | null
+ if (!node || node.type.name !== 'image') return -1
+ return pos
+}
+
+/** 读某个
元素对应图片节点的属性(含尺寸与包裹链接),非图片返回 null。 */
+export function imageInfoAtElement(editor: Editor, el: HTMLElement): ImageNodeInfo | null {
+ try {
+ return editor.action((ctx) => {
+ const view = ctx.get(editorViewCtx)
+ const pos = imagePosAtElement(view, el)
+ if (pos < 0) return null
+ const node = view.state.doc.nodeAt(pos)
+ if (!node) return null
+ const link = node.marks.find((m) => m.type.name === 'link')
+ return {
+ src: String(node.attrs.src ?? ''),
+ alt: String(node.attrs.alt ?? ''),
+ width: (node.attrs.width as string | null) ?? null,
+ linkHref: link ? String(link.attrs.href ?? '') || null : null,
+ }
+ })
+ } catch {
+ return null
+ }
+}
+
+/**
+ * 改某个
元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接),
+ * 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [](href)。
+ */
+export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: ImageNodePatch): boolean {
+ try {
+ return editor.action((ctx) => {
+ const view = ctx.get(editorViewCtx)
+ const pos = imagePosAtElement(view, el)
+ if (pos < 0) return false
+ const node = view.state.doc.nodeAt(pos)
+ if (!node) return false
+ const tr = view.state.tr
+ const attrs = { ...node.attrs }
+ if (patch.src !== undefined) attrs.src = patch.src
+ if (patch.alt !== undefined) attrs.alt = patch.alt
+ if (patch.width !== undefined) attrs.width = patch.width
+ tr.setNodeMarkup(pos, undefined, attrs)
+ if (patch.linkHref !== undefined) {
+ const linkType = view.state.schema.marks.link
+ if (linkType) {
+ tr.removeMark(pos, pos + 1, linkType)
+ if (patch.linkHref) tr.addMark(pos, pos + 1, linkType.create({ href: patch.linkHref }))
+ }
+ }
+ if (tr.docChanged) view.dispatch(tr)
+ return true
+ })
+ } catch {
+ return false
+ }
+}
+
export function callUndo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
}
diff --git a/src/editor/imageSchema.ts b/src/editor/imageSchema.ts
new file mode 100644
index 0000000..5092d30
--- /dev/null
+++ b/src/editor/imageSchema.ts
@@ -0,0 +1,128 @@
+// 图片尺寸扩展(docs/31):给 Milkdown 图片节点加 width 属性,让「右键 →
+// 调整大小」可持久化。Markdown 本身没有图片尺寸语法,业界通行写法是
+// HTML:带 width 的图片序列化为
,无 width 时仍是
+// 标准 ,不污染普通文档。
+// - 解析:remark 阶段把孤立的
(行内或块级)mdast html 节点换成
+// image 节点并把 width 放进 data;图片 schema 的 parseMarkdown runner
+// 读出来落到节点 attrs。
+// - 序列化:toMarkdown runner 见 width 就发 html 节点,否则原样发 image。
+// 往返已实测:
解析/序列化逐字一致。
+import type { Ctx } from '@milkdown/kit/ctx'
+import { remarkPluginsCtx } from '@milkdown/kit/core'
+import { imageSchema } from '@milkdown/kit/preset/commonmark'
+
+const IMG_TAG = /^
]*\/?>$/i
+
+/** 解析一个孤立
标签的属性表;不是孤立 img 或没有 src 时返回 null。 */
+export function parseImgTag(html: string): Record | null {
+ const t = html.trim()
+ if (!IMG_TAG.test(t)) return null
+ const attrs: Record = {}
+ for (const m of t.matchAll(/([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'/g)) {
+ attrs[(m[1] ?? m[3]).toLowerCase()] = decodeAttr(m[2] ?? m[4])
+ }
+ return attrs.src ? attrs : null
+}
+
+const ATTR_ENTITIES: Record = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
+
+/** 解码 HTML 属性值里的常见实体(mdast 保留的是源码原文,不解码)。单遍替换防二次解码。 */
+function decodeAttr(v: string): string {
+ return v.replace(/&(?:quot|amp|lt|gt);|'/g, (m) => ATTR_ENTITIES[m] ?? m)
+}
+
+interface MdastNode {
+ type: string
+ value?: string
+ children?: MdastNode[]
+ [key: string]: unknown
+}
+
+/** remark 插件:把孤立
的 mdast html 节点换成带 data.width 的 image 节点。 */
+export function remarkImgSizePlugin() {
+ const toImage = (child: MdastNode): MdastNode | null => {
+ if (child?.type !== 'html') return null
+ const attrs = parseImgTag(String(child.value ?? ''))
+ if (!attrs) return null
+ return {
+ type: 'image',
+ url: attrs.src,
+ alt: attrs.alt ?? '',
+ title: attrs.title ?? '',
+ data: { width: attrs.width ?? null },
+ } as MdastNode
+ }
+ const walk = (node: MdastNode): void => {
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return
+ node.children = node.children.map((child) => {
+ const img = toImage(child)
+ if (img) {
+ // 根级是块上下文,image 是行内节点,必须包进段落;段落内原地替换
+ return node.type === 'root' ? { type: 'paragraph', children: [img] } : img
+ }
+ walk(child)
+ return child
+ })
+ }
+ return (tree: MdastNode) => walk(tree)
+}
+
+function escapeAttr(v: string): string {
+ return v.replace(/&/g, '&').replace(/"/g, '"').replace(/,与本文件
+ // 的宽松 mdast 类型对不上;运行时形状 { plugin, options } 已实测无误。
+ const entry = { plugin: remarkImgSizePlugin, options: undefined } as unknown as
+ ReturnType extends (infer T)[] ? T : never
+ ctx.update(remarkPluginsCtx, (prev) => [...prev, entry])
+ ctx.update(imageSchema.key, (factory) => (c) => {
+ const spec = factory(c)
+ return {
+ ...spec,
+ attrs: { ...spec.attrs, width: { default: null } },
+ parseMarkdown: {
+ match: (node: { type: string }) => node.type === 'image',
+ runner: (state, node, type) => {
+ const mdast = node as unknown as { url: string; alt?: string; title?: string; data?: { width?: string | null } }
+ state.addNode(type, {
+ src: mdast.url,
+ alt: mdast.alt ?? '',
+ title: mdast.title ?? '',
+ width: mdast.data?.width ?? null,
+ })
+ },
+ },
+ toMarkdown: {
+ match: (node: { type: { name: string } }) => node.type.name === 'image',
+ runner: (state, node) => {
+ const width = node.attrs.width as string | null
+ if (width) {
+ const title = node.attrs.title ? ` title="${escapeAttr(String(node.attrs.title))}"` : ''
+ state.addNode(
+ 'html',
+ undefined,
+ `
`,
+ )
+ } else {
+ state.addNode('image', undefined, undefined, {
+ title: node.attrs.title,
+ url: node.attrs.src,
+ alt: node.attrs.alt,
+ })
+ }
+ },
+ },
+ }
+ })
+}
+
+/** width 属性值 → CSS 宽度:纯数字按 px,带单位(%/px/em…)原样用。 */
+export function widthToCss(width: string | null | undefined): string | null {
+ if (!width) return null
+ const t = String(width).trim()
+ if (!t) return null
+ return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t
+}
diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts
index 1d31d83..e7dc205 100644
--- a/src/editor/plugins.ts
+++ b/src/editor/plugins.ts
@@ -2,7 +2,8 @@
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
-import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
+import { Plugin, PluginKey, NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
+import { widthToCss } from './imageSchema'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { $prose } from '@milkdown/kit/utils'
import { commandsCtx } from '@milkdown/kit/core'
@@ -416,6 +417,48 @@ export function createPlainTextPastePlugin() {
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='
+/**
+ * 图片选中时按 Enter:在图片所在段落后新建空段落并把光标移过去(docs/31)。
+ * 默认 Enter 链对图片 NodeSelection 走 splitBlock——新段落插到图片前面且
+ * 选区仍停在图片上,接着打字会把图片替换掉,用户根本没法在图片后输入。
+ * handleKeyDown 先于所有 keymap 插件执行,只接管「图片被选中」这一种情形,
+ * 其余 Enter(列表/标题/代码块/普通文本)一律放行给原有链。
+ */
+export function createImageEnterPlugin() {
+ return $prose(() => new Plugin({
+ // 用插件 view 生命周期挂捕获阶段监听:预置的 Enter keymap 插件排在
+ // 本插件之前,props.handleKeyDown 轮不到(someProp 按插件序先到先得),
+ // 捕获监听则保证先于一切编辑器内 keydown 处理。
+ view: (editorView) => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
+ if (isImeComposing(event)) return
+ const { state } = editorView
+ const sel = state.selection
+ if (!(sel instanceof NodeSelection) || sel.node.type.name !== 'image') return
+ // createParagraphNear 对行内节点直接返回 false(它只管块级选区),
+ // 自己来:在图片所在的文本块之后插入空段落并把光标放进去。
+ const $pos = state.doc.resolve(sel.from)
+ let depth = $pos.depth
+ while (depth > 0 && !$pos.node(depth).isTextblock) depth--
+ if (depth === 0) return
+ const paraType = state.schema.nodes.paragraph
+ if (!paraType) return
+ const insertAt = $pos.after(depth)
+ const tr = state.tr.insert(insertAt, paraType.create())
+ tr.setSelection(TextSelection.create(tr.doc, insertAt + 1))
+ editorView.dispatch(tr.scrollIntoView())
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ editorView.dom.addEventListener('keydown', onKeyDown, true)
+ return {
+ destroy: () => editorView.dom.removeEventListener('keydown', onKeyDown, true),
+ }
+ },
+ }))
+}
+
export function createImageDisplayPlugin(resolve: (src: string) => Promise) {
return $prose(() => new Plugin({
props: {
@@ -424,6 +467,12 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise {
+ const css = widthToCss(typeof w === 'string' ? w : null)
+ if (css) img.style.width = css
+ else img.style.removeProperty('width')
+ }
+ applyWidth(node.attrs.width)
let currentSrc = ''
const apply = (src: string) => {
currentSrc = src
@@ -442,6 +491,7 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise {
if (next.type.name !== 'image') return false
img.alt = (next.attrs.alt as string) ?? ''
+ applyWidth(next.attrs.width)
const src = (next.attrs.src as string) ?? ''
if (src !== currentSrc) apply(src)
return true
diff --git a/src/styles.css b/src/styles.css
index 24dc7c2..9a2269c 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -161,6 +161,11 @@ button { font: inherit; color: inherit; }
.modal-actions button { border: 1px solid var(--border); background: var(--bg); border-radius: 7px; padding: 6px 14px; cursor: pointer; }
.modal-actions button.primary { background: var(--accent); color: #fff; border-color: transparent; }
.modal-input { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); color: var(--text); }
+.modal-field-row { display: flex; gap: 8px; align-items: stretch; }
+.modal-field-row .modal-input { flex: 1; }
+.seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
+.seg-control button { padding: 0 12px; background: var(--bg); color: var(--text-dim); border: none; cursor: pointer; font-size: 13px; }
+.seg-control button.active { background: var(--accent); color: #fff; }
.modal-note { color: var(--text-dim); font-size: 12.5px; }
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
@@ -179,6 +184,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.milkdown .ProseMirror table { border-collapse: collapse; }
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
.milkdown .ProseMirror img { max-width: 100%; }
+.milkdown .ProseMirror img.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.milkdown .ProseMirror a { color: var(--accent); }
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
diff --git a/tests/image-enter.test.ts b/tests/image-enter.test.ts
new file mode 100644
index 0000000..363011d
--- /dev/null
+++ b/tests/image-enter.test.ts
@@ -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()
+ })
+})
diff --git a/tests/image-size.test.ts b/tests/image-size.test.ts
new file mode 100644
index 0000000..e63663c
--- /dev/null
+++ b/tests/image-size.test.ts
@@ -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>['editor']) {
+ return editor.action((ctx) => {
+ const view = ctx.get(editorViewCtx)
+ const list: Array> = []
+ 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('块级
解析为带尺寸的图片节点,序列化逐字还原', async () => {
+ const md = '前文\n\n
\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('行内
同样识别,序列化补 alt=""', async () => {
+ const { root, editor } = await makeEditor('前
后\n')
+ expect(imageAttrs(editor)[0].width).toBe('50%')
+ expect(getMarkdown(editor)).toBe('前
后\n')
+ await editor.destroy(); root.remove()
+ })
+
+ it('非 img 的 HTML 不受影响(保持 html 节点原文)', async () => {
+ const md = '前 标 后\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('
\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('
\n')
+ expect(imageAttrs(editor)[0].alt).toBe('a"b')
+ expect(getMarkdown(editor)).toBe('
\n')
+ await editor.destroy(); root.remove()
+ })
+})
+
+describe('parseImgTag / widthToCss', () => {
+ it('只认孤立 img 且必须有 src', () => {
+ expect(parseImgTag('
')).toEqual({ src: 'a.png', width: '300' })
+ expect(parseImgTag('
')).toBeNull()
+ expect(parseImgTag('
后')).toBeNull()
+ expect(parseImgTag('')).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()
+ })
+})