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:
71
docs/31-图片编辑体验.md
Normal file
71
docs/31-图片编辑体验.md
Normal file
@@ -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 }`,裸函数不
|
||||||
|
生效)把孤立的 `<img …>` mdast html 节点换成 image 节点,width 存
|
||||||
|
data;根级(块级)要包一层 paragraph,行内原地替换。属性值做一遍
|
||||||
|
实体解码(mdast 保留源码原文,如 `"`)。
|
||||||
|
- 序列化:有 width 时发 `<img src alt title width>` html 节点(属性
|
||||||
|
转义),无 width 时仍发标准 ``,普通文档零影响。
|
||||||
|
- NodeView 按 width 设置 img 的 CSS 宽度(纯数字补 px)。
|
||||||
|
- UI:右键图片 →「调整图片大小…」弹窗(数值 + 像素/百分比分段钮,
|
||||||
|
可恢复原始大小)。带尺寸的图片在源文件里就是 HTML 形式,弹窗里
|
||||||
|
有明示。
|
||||||
|
|
||||||
|
往返实测:`<img src="b.png" alt="乙" width="300">` 解析/序列化逐字
|
||||||
|
一致;普通图片、非 img 的 HTML 均不受影响(tests/image-size.test.ts)。
|
||||||
|
|
||||||
|
## ② 图片超链接
|
||||||
|
|
||||||
|
原「超链接」是文本链接:对选区套 link 标记,预填只有 `https://`,
|
||||||
|
对着图片点开自然是「空的」。图片的链接在 Markdown 里是包裹式
|
||||||
|
`[](href)`——实测这在本编辑器里可往返(link 标记挂在
|
||||||
|
图片节点上,解析/序列化都保留)。
|
||||||
|
|
||||||
|
修法:右键命中图片时,「超链接…」打开图片模式的链接弹窗——预填
|
||||||
|
图片当前的包裹链接(没有就是空),提交后用一条事务对图片节点
|
||||||
|
addMark/removeMark(editor.ts 的 `updateImageAtElement`,与尺寸
|
||||||
|
共用);清空即移除链接。文本选区的链接流程不变。
|
||||||
|
|
||||||
|
配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读
|
||||||
|
src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点
|
||||||
|
改属性,右键菜单打开时记住元素与标签 id)。
|
||||||
|
|
||||||
|
## 待真机验
|
||||||
|
|
||||||
|
- WebView2 里点击图片的选中手感(本机为 Chromium 实测);
|
||||||
|
- 调整大小后图片在文档中的实际观感与保存后重开的还原;
|
||||||
|
- 图片在列表/引用/表格里时 Enter 新段落的位置是否符合预期。
|
||||||
68
src/App.tsx
68
src/App.tsx
@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
|||||||
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
||||||
import { insertPlainText, insertLinkText } from './editor/paste'
|
import { insertPlainText, insertLinkText } from './editor/paste'
|
||||||
import { isImeComposing } from './utils/ime'
|
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 { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
|
||||||
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
||||||
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
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 openEditorMenu = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const inEditor = sourceMode
|
const inEditor = sourceMode
|
||||||
@@ -1096,6 +1099,8 @@ export default function App() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
let hasSelection = false
|
let hasSelection = false
|
||||||
let imageSrc: string | null = null
|
let imageSrc: string | null = null
|
||||||
|
let imageEl: HTMLElement | null = null
|
||||||
|
let imageTabId: string | null = null
|
||||||
if (sourceMode) {
|
if (sourceMode) {
|
||||||
hasSelection = sourceApiRef.current?.hasSelection() ?? false
|
hasSelection = sourceApiRef.current?.hasSelection() ?? false
|
||||||
} else if (tab) {
|
} else if (tab) {
|
||||||
@@ -1103,11 +1108,15 @@ export default function App() {
|
|||||||
if (api?.editor) {
|
if (api?.editor) {
|
||||||
try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
|
try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
|
||||||
}
|
}
|
||||||
// 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」
|
// 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」;
|
||||||
|
// 同时记住图片元素与标签,大小/链接编辑要按元素定位回节点
|
||||||
const img = target.closest('img')
|
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) => {
|
const runEditorMenuAction = (action: EditorMenuAction) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -1119,7 +1128,26 @@ export default function App() {
|
|||||||
case 'italic': withEditor(editorCommands.italic); break
|
case 'italic': withEditor(editorCommands.italic); break
|
||||||
case 'strikethrough': withEditor(editorCommands.strikethrough); break
|
case 'strikethrough': withEditor(editorCommands.strikethrough); break
|
||||||
case 'inlineCode': withEditor(editorCommands.inlineCode); break
|
case 'inlineCode': withEditor(editorCommands.inlineCode); break
|
||||||
case 'link': setModal('link'); break
|
case 'link': {
|
||||||
|
// 右键的是图片:链接弹窗切图片模式——预填图片当前的包裹链接,
|
||||||
|
// 提交后把链接标记挂到图片节点上(序列化为 [](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 'toggleSource': toggleSourceMode(); break
|
||||||
case 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); 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)) }}
|
onSubmit={(href) => { setModal(null); if (href) withEditor((e) => editorCommands.link(e, href)) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{imageLinkTarget && (
|
||||||
|
<LinkModal
|
||||||
|
heading="图片链接"
|
||||||
|
hint="给图片加链接,点开图片时跳转到这个地址;清空则移除链接。"
|
||||||
|
initialHref={imageLinkTarget.href}
|
||||||
|
onCancel={() => 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 && (
|
||||||
|
<ImageSizeModal
|
||||||
|
current={imageSizeTarget.width}
|
||||||
|
onCancel={() => 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 && <ErrorModal message={error} onClose={() => setError(null)} />}
|
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
|
||||||
{encodingPrompt && (
|
{encodingPrompt && (
|
||||||
<EncodingConvertModal
|
<EncodingConvertModal
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { isUploadableImageSrc } from '../services/s3'
|
|||||||
export type EditorMenuAction =
|
export type EditorMenuAction =
|
||||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||||
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
|
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
|
||||||
| 'toggleSource' | 'uploadS3'
|
| 'toggleSource' | 'uploadS3' | 'imageSize'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
@@ -31,7 +31,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
|
|||||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||||
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
|
||||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
|
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="tree-context-menu editor-context-menu"
|
className="tree-context-menu editor-context-menu"
|
||||||
@@ -55,10 +55,11 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
|
|||||||
<button onClick={() => act('link')}>超链接…</button>
|
<button onClick={() => act('link')}>超链接…</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showUpload && (
|
{(showUpload || imageSrc) && (
|
||||||
<>
|
<>
|
||||||
<div className="tree-menu-divider" />
|
<div className="tree-menu-divider" />
|
||||||
<button onClick={() => act('uploadS3')}>上传到 S3</button>
|
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
|
||||||
|
{imageSrc && <button onClick={() => act('imageSize')}>调整图片大小…</button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="tree-menu-divider" />
|
<div className="tree-menu-divider" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
|
|||||||
import type { Editor } from '@milkdown/kit/core'
|
import type { Editor } from '@milkdown/kit/core'
|
||||||
import { editorViewCtx } from '@milkdown/kit/core'
|
import { editorViewCtx } from '@milkdown/kit/core'
|
||||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
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 { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
|
||||||
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
|
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
|
||||||
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
|
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
|
||||||
@@ -25,6 +25,10 @@ export interface EditorPaneApi {
|
|||||||
replaceImageSrc: (oldSrc: string, newSrc: string) => number
|
replaceImageSrc: (oldSrc: string, newSrc: string) => number
|
||||||
/** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */
|
/** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */
|
||||||
imageSrcForElement: (el: HTMLElement) => string | null
|
imageSrcForElement: (el: HTMLElement) => string | null
|
||||||
|
/** 某图片元素的节点信息(地址/替代文本/尺寸/包裹链接) */
|
||||||
|
imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null
|
||||||
|
/** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */
|
||||||
|
updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -56,6 +60,8 @@ const EMPTY_API: EditorPaneApi = {
|
|||||||
clearFind: () => undefined,
|
clearFind: () => undefined,
|
||||||
listImages: () => [],
|
listImages: () => [],
|
||||||
replaceImageSrc: () => 0,
|
replaceImageSrc: () => 0,
|
||||||
|
imageInfoForElement: () => null,
|
||||||
|
updateImageForElement: () => false,
|
||||||
imageSrcForElement: () => null,
|
imageSrcForElement: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +253,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 }
|
try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 }
|
||||||
}
|
}
|
||||||
const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el)
|
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()
|
editor.create()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
||||||
@@ -257,7 +265,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
|||||||
try {
|
try {
|
||||||
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
|
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
|
||||||
} catch { /* 忽略 */ }
|
} 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) {
|
if (chunkLoad) {
|
||||||
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
|
setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
|
||||||
void chunkLoad.appendRest(
|
void chunkLoad.appendRest(
|
||||||
|
|||||||
@@ -140,11 +140,19 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) => void; onCancel: () => void }) {
|
export function LinkModal({ onSubmit, onCancel, initialHref, heading, hint }: {
|
||||||
const [href, setHref] = useState('https://')
|
onSubmit: (href: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
/** 预填地址(编辑已有链接时用);缺省为 https:// 前缀 */
|
||||||
|
initialHref?: string
|
||||||
|
heading?: string
|
||||||
|
hint?: string
|
||||||
|
}) {
|
||||||
|
const [href, setHref] = useState(initialHref ?? 'https://')
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onCancel}>
|
<Shell onCancel={onCancel}>
|
||||||
<h3>插入链接</h3>
|
<h3>{heading ?? '插入链接'}</h3>
|
||||||
|
{hint && <p className="modal-note">{hint}</p>}
|
||||||
<input
|
<input
|
||||||
className="modal-input"
|
className="modal-input"
|
||||||
value={href}
|
value={href}
|
||||||
@@ -161,6 +169,54 @@ export function LinkModal({ onSubmit, onCancel }: { onSubmit: (href: string) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片大小弹窗(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 (
|
||||||
|
<Shell onCancel={onCancel}>
|
||||||
|
<h3>调整图片大小</h3>
|
||||||
|
<div className="modal-field-row">
|
||||||
|
<input
|
||||||
|
className="modal-input"
|
||||||
|
value={num}
|
||||||
|
autoFocus
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(e) => setNum(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
|
||||||
|
placeholder={unit === '%' ? '如 50' : '如 600'}
|
||||||
|
/>
|
||||||
|
<div className="seg-control" role="group" aria-label="单位">
|
||||||
|
<button className={unit === 'px' ? 'active' : ''} onClick={() => setUnit('px')}>像素</button>
|
||||||
|
<button className={unit === '%' ? 'active' : ''} onClick={() => setUnit('%')}>百分比</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="modal-note">宽度按{unit === '%' ? '段落宽度的百分比' : '像素'}缩放,高度等比。大小会写进文档(带尺寸的图片以 HTML 形式保存)。</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
{current && <button onClick={() => onSubmit(null)}>恢复原始大小</button>}
|
||||||
|
<button onClick={onCancel}>取消</button>
|
||||||
|
<button className="primary" disabled={!valid} onClick={submit}>确定</button>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<Shell onCancel={onClose}>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
|
|||||||
import { indent } from '@milkdown/kit/plugin/indent'
|
import { indent } from '@milkdown/kit/plugin/indent'
|
||||||
import { math } from '@milkdown/plugin-math'
|
import { math } from '@milkdown/plugin-math'
|
||||||
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
|
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 { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
|
||||||
|
import { extendImageSchema } from './imageSchema'
|
||||||
|
|
||||||
export interface CursorInfo { line: number; col: number }
|
export interface CursorInfo { line: number; col: number }
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
|||||||
ctx.set(defaultValueCtx, initialMarkdown)
|
ctx.set(defaultValueCtx, initialMarkdown)
|
||||||
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
|
// 序列化风格对齐 Typora 习惯(阶段 0 定稿):无序列表用 `-`
|
||||||
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
||||||
|
// 图片尺寸扩展(docs/31):width 属性 + <img> HTML 往返
|
||||||
|
extendImageSchema(ctx)
|
||||||
const lm = ctx.get(listenerCtx)
|
const lm = ctx.get(listenerCtx)
|
||||||
if (callbacks.onMarkdown) {
|
if (callbacks.onMarkdown) {
|
||||||
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
||||||
@@ -109,6 +112,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
|||||||
.use(math)
|
.use(math)
|
||||||
.use(upload)
|
.use(upload)
|
||||||
.use(createPlainTextPastePlugin())
|
.use(createPlainTextPastePlugin())
|
||||||
|
.use(createImageEnterPlugin())
|
||||||
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
|
if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay))
|
||||||
return editor
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读某个 <img> 元素对应图片节点的属性(含尺寸与包裹链接),非图片返回 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改某个 <img> 元素对应图片节点的属性(地址/替代文本/尺寸/包裹链接),
|
||||||
|
* 一次事务完成。链接以标记形式挂在图片节点上,序列化为 [](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) {
|
export function callUndo(editor: Editor) {
|
||||||
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/editor/imageSchema.ts
Normal file
128
src/editor/imageSchema.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// 图片尺寸扩展(docs/31):给 Milkdown 图片节点加 width 属性,让「右键 →
|
||||||
|
// 调整大小」可持久化。Markdown 本身没有图片尺寸语法,业界通行写法是
|
||||||
|
// HTML:带 width 的图片序列化为 <img src alt width>,无 width 时仍是
|
||||||
|
// 标准 ,不污染普通文档。
|
||||||
|
// - 解析:remark 阶段把孤立的 <img …>(行内或块级)mdast html 节点换成
|
||||||
|
// image 节点并把 width 放进 data;图片 schema 的 parseMarkdown runner
|
||||||
|
// 读出来落到节点 attrs。
|
||||||
|
// - 序列化:toMarkdown runner 见 width 就发 html 节点,否则原样发 image。
|
||||||
|
// 往返已实测:<img src="b.png" alt="乙" width="300"> 解析/序列化逐字一致。
|
||||||
|
import type { Ctx } from '@milkdown/kit/ctx'
|
||||||
|
import { remarkPluginsCtx } from '@milkdown/kit/core'
|
||||||
|
import { imageSchema } from '@milkdown/kit/preset/commonmark'
|
||||||
|
|
||||||
|
const IMG_TAG = /^<img\s+[^>]*\/?>$/i
|
||||||
|
|
||||||
|
/** 解析一个孤立 <img> 标签的属性表;不是孤立 img 或没有 src 时返回 null。 */
|
||||||
|
export function parseImgTag(html: string): Record<string, string> | null {
|
||||||
|
const t = html.trim()
|
||||||
|
if (!IMG_TAG.test(t)) return null
|
||||||
|
const attrs: Record<string, string> = {}
|
||||||
|
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<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||||
|
|
||||||
|
/** 解码 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 插件:把孤立 <img> 的 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(/</g, '<')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在编辑器 config 阶段调用:注册 remark 插件 + 包裹图片 schema。 */
|
||||||
|
export function extendImageSchema(ctx: Ctx): void {
|
||||||
|
// remarkPluginsCtx 的条目类型是 unified 的 Plugin<[..], Root>,与本文件
|
||||||
|
// 的宽松 mdast 类型对不上;运行时形状 { plugin, options } 已实测无误。
|
||||||
|
const entry = { plugin: remarkImgSizePlugin, options: undefined } as unknown as
|
||||||
|
ReturnType<typeof remarkPluginsCtx.create> 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,
|
||||||
|
`<img src="${escapeAttr(String(node.attrs.src))}" alt="${escapeAttr(String(node.attrs.alt))}"${title} width="${escapeAttr(String(width))}">`,
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
|
// 1) syntaxReveal —— Typora 式语法标记显隐:仅光标所在块显示 Markdown 标记,其余只呈现排版结果。
|
||||||
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
|
// 实现为纯 Decoration(只影响 DOM,不进文档模型、不进历史、序列化零影响,有自动化测试断言)。
|
||||||
// 2) codeBlockTab —— 代码块内 Tab 缩进 / Shift+Tab 反缩进(其余位置 Tab 行为不变)。
|
// 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 { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||||
import { $prose } from '@milkdown/kit/utils'
|
import { $prose } from '@milkdown/kit/utils'
|
||||||
import { commandsCtx } from '@milkdown/kit/core'
|
import { commandsCtx } from '@milkdown/kit/core'
|
||||||
@@ -416,6 +417,48 @@ export function createPlainTextPastePlugin() {
|
|||||||
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
|
// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。
|
||||||
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw='
|
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<string | null>) {
|
export function createImageDisplayPlugin(resolve: (src: string) => Promise<string | null>) {
|
||||||
return $prose(() => new Plugin({
|
return $prose(() => new Plugin({
|
||||||
props: {
|
props: {
|
||||||
@@ -424,6 +467,12 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
|
|||||||
const img = document.createElement('img')
|
const img = document.createElement('img')
|
||||||
img.alt = (node.attrs.alt as string) ?? ''
|
img.alt = (node.attrs.alt as string) ?? ''
|
||||||
if (node.attrs.title) img.title = node.attrs.title as string
|
if (node.attrs.title) img.title = node.attrs.title as string
|
||||||
|
const applyWidth = (w: unknown) => {
|
||||||
|
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 = ''
|
let currentSrc = ''
|
||||||
const apply = (src: string) => {
|
const apply = (src: string) => {
|
||||||
currentSrc = src
|
currentSrc = src
|
||||||
@@ -442,6 +491,7 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
|
|||||||
update: (next) => {
|
update: (next) => {
|
||||||
if (next.type.name !== 'image') return false
|
if (next.type.name !== 'image') return false
|
||||||
img.alt = (next.attrs.alt as string) ?? ''
|
img.alt = (next.attrs.alt as string) ?? ''
|
||||||
|
applyWidth(next.attrs.width)
|
||||||
const src = (next.attrs.src as string) ?? ''
|
const src = (next.attrs.src as string) ?? ''
|
||||||
if (src !== currentSrc) apply(src)
|
if (src !== currentSrc) apply(src)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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 { 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-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-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; }
|
.modal-note { color: var(--text-dim); font-size: 12.5px; }
|
||||||
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
.shortcuts-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||||
.shortcuts-table td { padding: 5px 6px; border-bottom: 1px solid var(--border); }
|
.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 table { border-collapse: collapse; }
|
||||||
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
|
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
|
||||||
.milkdown .ProseMirror img { max-width: 100%; }
|
.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 a { color: var(--accent); }
|
||||||
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
|
.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; }
|
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
|
||||||
|
|||||||
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