From 5f4a80fa0147979aac06122f7ee01205afe430ff Mon Sep 17 00:00:00 2001 From: Muse Date: Sun, 15 Nov 2026 09:41:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=9B=BE=E7=89=87=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E6=8D=A2=E8=A1=8C=E3=80=81=E5=8F=AF=E8=B0=83?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E3=80=81=E5=8F=B3=E9=94=AE=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E9=92=88=E5=AF=B9=E5=9B=BE=E7=89=87=EF=BC=88docs/31=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈三件:图片选不中没法在后面打字、右键不能改大小、图片的 超链接是空的。无头 Chrome 真实点击复现根因:点击能形成节点选区 但无任何视觉提示;图片选中时按 Enter 走 splitBlock,新段落插到 图片前面且选区仍停在图片上,接着打字会把图片替换掉。 - Enter 修复:捕获阶段 keydown 只接管图片选区,在图片所在文本块后 插空段落并移入光标(createParagraphNear 对行内节点无效,自己写); 选中图片加主题色描边。 - 大小:图片节点加 width 属性,带尺寸时序列化为 、 否则标准语法;remark 插件把孤立 解析回图片节点(实体解码、 块级包段落),往返逐字一致;右键「调整图片大小…」弹窗 px/% 可调。 - 链接:右键图片的「超链接…」改为图片模式,预填包裹链接、提交对 图片节点挂/摘 link 标记([![alt](src)](href) 可往返)。 验证:vitest 148+2(新增 image-size/image-enter 共 12 例)、构建通过、 无头 Chrome 实测点击/Enter/打字全流程。 --- docs/31-图片编辑体验.md | 71 +++++++++++++++ src/App.tsx | 68 ++++++++++++-- src/components/EditorContextMenu.tsx | 9 +- src/components/EditorPane.tsx | 12 ++- src/components/Modals.tsx | 62 ++++++++++++- src/editor/editor.ts | 85 +++++++++++++++++- src/editor/imageSchema.ts | 128 +++++++++++++++++++++++++++ src/editor/plugins.ts | 52 ++++++++++- src/styles.css | 6 ++ tests/image-enter.test.ts | 44 +++++++++ tests/image-size.test.ts | 108 ++++++++++++++++++++++ 11 files changed, 628 insertions(+), 17 deletions(-) create mode 100644 docs/31-图片编辑体验.md create mode 100644 src/editor/imageSchema.ts create mode 100644 tests/image-enter.test.ts create mode 100644 tests/image-size.test.ts diff --git a/docs/31-图片编辑体验.md b/docs/31-图片编辑体验.md new file mode 100644 index 0000000..cbaab04 --- /dev/null +++ b/docs/31-图片编辑体验.md @@ -0,0 +1,71 @@ +# 31 图片编辑体验(选中 / 换行 / 大小 / 链接) + +用户真机反馈三件事:①右键图片无法编辑大小;②右键图片点「超链接」是 +空的;③图片无法选中、没法在图片后面输入文本(按回车只能在前面换行)。 + +## ③ 选中与换行(根因) + +无头 Chrome 实测复现(真实鼠标点击): + +- 点击图片本身能形成 NodeSelection、点图片右侧也能把光标落在图片后—— + 但选中态**没有任何视觉提示**(PM 给的 `ProseMirror-selectednode` + 类没有样式),用户感知就是「选不中」。 +- 真正的死结在 Enter:图片被选中时按 Enter,默认命令链走 splitBlock, + 空段落插到图片**前面**、选区仍停在图片上;接着打字会把图片**替换掉**。 + 这就是「只能在前面的文本处换行」的由来。 + +修法(`createImageEnterPlugin`,plugins.ts): + +- 插件 view 生命周期里给编辑器 DOM 挂**捕获阶段** keydown 监听(预置 + Enter keymap 插件排在本插件之前,props.handleKeyDown 轮不到); + 只接管「图片 NodeSelection + 无修饰 Enter」:在图片所在文本块之后 + 插入空段落并把光标移进去,其余 Enter 全部放行。 +- 注意 `createParagraphNear` 用不上:它对行内节点直接返回 false + (只处理块级选区),故插入逻辑自己写(resolve 到文本块深度、 + after(depth) 处插段落)。 +- CSS 给 `img.ProseMirror-selectednode` 加 2px 主题色描边,选中可见。 + +实测(无头 Chrome,真实点击):点击→NodeSelection+描边;Enter→新段落 +在图片之后、光标在其中;打字落在新段落、图片完好。 + +## ① 图片大小 + +Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts): + +- 包裹 preset 的 imageSchema:节点加 `width` 属性(字符串,`300`=px、 + `50%`=百分比)。 +- 解析:remark 插件(条目形状必须是 `{ plugin, options }`,裸函数不 + 生效)把孤立的 `` mdast html 节点换成 image 节点,width 存 + data;根级(块级)要包一层 paragraph,行内原地替换。属性值做一遍 + 实体解码(mdast 保留源码原文,如 `"`)。 +- 序列化:有 width 时发 `` html 节点(属性 + 转义),无 width 时仍发标准 `![alt](src)`,普通文档零影响。 +- NodeView 按 width 设置 img 的 CSS 宽度(纯数字补 px)。 +- UI:右键图片 →「调整图片大小…」弹窗(数值 + 像素/百分比分段钮, + 可恢复原始大小)。带尺寸的图片在源文件里就是 HTML 形式,弹窗里 + 有明示。 + +往返实测:`乙` 解析/序列化逐字 +一致;普通图片、非 img 的 HTML 均不受影响(tests/image-size.test.ts)。 + +## ② 图片超链接 + +原「超链接」是文本链接:对选区套 link 标记,预填只有 `https://`, +对着图片点开自然是「空的」。图片的链接在 Markdown 里是包裹式 +`[![alt](src)](href)`——实测这在本编辑器里可往返(link 标记挂在 +图片节点上,解析/序列化都保留)。 + +修法:右键命中图片时,「超链接…」打开图片模式的链接弹窗——预填 +图片当前的包裹链接(没有就是空),提交后用一条事务对图片节点 +addMark/removeMark(editor.ts 的 `updateImageAtElement`,与尺寸 +共用);清空即移除链接。文本选区的链接流程不变。 + +配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读 +src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点 +改属性,右键菜单打开时记住元素与标签 id)。 + +## 待真机验 + +- WebView2 里点击图片的选中手感(本机为 Chromium 实测); +- 调整大小后图片在文档中的实际观感与保存后重开的还原; +- 图片在列表/引用/表格里时 Enter 新段落的位置是否符合预期。 diff --git a/src/App.tsx b/src/App.tsx index d5f363b..64289e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor')) import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { insertPlainText, insertLinkText } from './editor/paste' import { isImeComposing } from './utils/ime' -import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals' +import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal } from './components/Modals' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' @@ -1086,7 +1086,10 @@ export default function App() { }, []) // ——— 编辑区自绘右键菜单 ——— - const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean; imageSrc: string | null } | null>(null) + const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean; imageSrc: string | null; imageEl: HTMLElement | null; tabId: string | null } | null>(null) + /** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */ + const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; href: string } | null>(null) + const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null) const openEditorMenu = (e: React.MouseEvent) => { const target = e.target as HTMLElement const inEditor = sourceMode @@ -1096,6 +1099,8 @@ export default function App() { e.preventDefault() let hasSelection = false let imageSrc: string | null = null + let imageEl: HTMLElement | null = null + let imageTabId: string | null = null if (sourceMode) { hasSelection = sourceApiRef.current?.hasSelection() ?? false } else if (tab) { @@ -1103,11 +1108,15 @@ export default function App() { if (api?.editor) { try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false } } - // 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」 + // 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」; + // 同时记住图片元素与标签,大小/链接编辑要按元素定位回节点 const img = target.closest('img') - if (img && api) imageSrc = api.imageSrcForElement(img) + if (img && api) { + imageSrc = api.imageSrcForElement(img) + if (imageSrc) { imageEl = img; imageTabId = tab.id } + } } - setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc }) + setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc, imageEl, tabId: imageTabId }) } const runEditorMenuAction = (action: EditorMenuAction) => { switch (action) { @@ -1119,7 +1128,26 @@ export default function App() { case 'italic': withEditor(editorCommands.italic); break case 'strikethrough': withEditor(editorCommands.strikethrough); break case 'inlineCode': withEditor(editorCommands.inlineCode); break - case 'link': setModal('link'); break + case 'link': { + // 右键的是图片:链接弹窗切图片模式——预填图片当前的包裹链接, + // 提交后把链接标记挂到图片节点上(序列化为 [![alt](src)](href)) + const el = editorMenu?.imageEl + const tid = editorMenu?.tabId + if (el && tid) { + const info = paneApisRef.current.get(tid)?.imageInfoForElement(el) + setImageLinkTarget({ tabId: tid, el, href: info?.linkHref ?? '' }) + } else setModal('link') + break + } + case 'imageSize': { + const el = editorMenu?.imageEl + const tid = editorMenu?.tabId + if (el && tid) { + const info = paneApisRef.current.get(tid)?.imageInfoForElement(el) + setImageSizeTarget({ tabId: tid, el, width: info?.width ?? null }) + } + break + } case 'toggleSource': toggleSourceMode(); break case 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); break } } @@ -2039,6 +2067,34 @@ export default function App() { onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }} /> )} + {imageLinkTarget && ( + setImageLinkTarget(null)} + onSubmit={(href) => { + const t = imageLinkTarget + setImageLinkTarget(null) + const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { linkHref: href || null }) + if (ok) flash(href ? '图片链接已更新' : '图片链接已移除') + else setError('图片已不在文档中,链接未更新。') + }} + /> + )} + {imageSizeTarget && ( + setImageSizeTarget(null)} + onSubmit={(width) => { + const t = imageSizeTarget + setImageSizeTarget(null) + const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { width }) + if (ok) flash(width ? '图片大小已更新' : '图片已恢复原始大小') + else setError('图片已不在文档中,大小未更新。') + }} + /> + )} {error && setError(null)} />} {encodingPrompt && ( { onClose(); onAction(a) } const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc)) // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) - const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0)) + const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0) + (imageSrc ? 40 : 0)) return (
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 + } +} + +/** + * 改某个 元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接), + * 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [![alt](src)](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 时仍是 +// 标准 ![alt](src),不污染普通文档。 +// - 解析: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, + `${escapeAttr(String(node.attrs.alt))}`, + ) + } 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![图](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() + }) +}) 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('前文 ![图](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('块级 解析为带尺寸的图片节点,序列化逐字还原', 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('![图](a.png)\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('![图](a.png)\n') + await editor.destroy(); root.remove() + }) + + it('alt 里的引号在 HTML 序列化时转义、解析时还原', async () => { + const { root, editor } = await makeEditor('a"b\n') + expect(imageAttrs(editor)[0].alt).toBe('a"b') + expect(getMarkdown(editor)).toBe('a"b\n') + await editor.destroy(); root.remove() + }) +}) + +describe('parseImgTag / widthToCss', () => { + it('只认孤立 img 且必须有 src', () => { + expect(parseImgTag('')).toEqual({ src: 'a.png', width: '300' }) + expect(parseImgTag('x')).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:设置包裹链接 → [![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() + }) +})