fix(editor): 图片超链接弹窗带出图片地址(docs/31 更正)

上一笔把图片「超链接」做成预填包裹链接,用户指出「没有带出来
这张图片的 url」——要的是图片自己的地址出现在弹窗里。改版
ImageLinkModal:主字段「图片地址」打开即带出该图 src(本地相对
路径或网络地址),可查看可改;包裹链接降为第二字段「点击时打开
的链接(可空)」。提交时一条事务同时处理 src 与 link 标记,
没改动则不发事务。新增弹窗预填与 src patch 测试。
This commit is contained in:
Muse
2026-11-15 10:06:47 +08:00
parent 0c09d94501
commit 1c0dd00088
6 changed files with 120 additions and 14 deletions

View File

@@ -68,10 +68,13 @@ Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts)
`[![alt](src)](href)`——实测这在本编辑器里可往返(link 标记挂在 `[![alt](src)](href)`——实测这在本编辑器里可往返(link 标记挂在
图片节点上,解析/序列化都保留)。 图片节点上,解析/序列化都保留)。
修法:右键命中图片时,「超链接…」打开图片模式的链接弹窗——预填 修法(第一版理解偏了):先做了「预填包裹链接」的弹窗,用户随即
图片当前的包裹链接(没有就是空),提交后用一条事务对图片节点 指出「没有带出来这张图片的 url」——用户要的是**图片自己的地址**
addMark/removeMark(editor.ts 的 `updateImageAtElement`,与尺寸 出现在弹窗里。更正后的 `ImageLinkModal`:主字段「图片地址」打开
共用);清空即移除链接。文本选区的链接流程不变。 即带出该图的 src(本地相对路径或网络地址),可查看可改,提交后
patch 到节点;包裹链接降为第二字段「点击时打开的链接(可空)」,
提交时一条事务同时处理 src 与 link 标记(`updateImageAtElement`)。
文本选区的链接流程不变。
配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读 配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读
src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点 src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点

View File

@@ -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, ImageSizeModal } from './components/Modals' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal } 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'
@@ -1088,7 +1088,7 @@ export default function App() {
// ——— 编辑区自绘右键菜单 ——— // ——— 编辑区自绘右键菜单 ———
const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean; imageSrc: string | null; imageEl: HTMLElement | null; tabId: 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) */ /** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; href: string } | null>(null) const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | 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
@@ -1135,7 +1135,7 @@ export default function App() {
const tid = editorMenu?.tabId const tid = editorMenu?.tabId
if (el && tid) { if (el && tid) {
const info = paneApisRef.current.get(tid)?.imageInfoForElement(el) const info = paneApisRef.current.get(tid)?.imageInfoForElement(el)
setImageLinkTarget({ tabId: tid, el, href: info?.linkHref ?? '' }) setImageLinkTarget({ tabId: tid, el, src: info?.src ?? '', href: info?.linkHref ?? '' })
} else setModal('link') } else setModal('link')
break break
} }
@@ -2068,16 +2068,19 @@ export default function App() {
/> />
)} )}
{imageLinkTarget && ( {imageLinkTarget && (
<LinkModal <ImageLinkModal
heading="图片链接" src={imageLinkTarget.src}
hint="给图片加链接,点开图片时跳转到这个地址;清空则移除链接。" linkHref={imageLinkTarget.href}
initialHref={imageLinkTarget.href}
onCancel={() => setImageLinkTarget(null)} onCancel={() => setImageLinkTarget(null)}
onSubmit={(href) => { onSubmit={({ src, linkHref }) => {
const t = imageLinkTarget const t = imageLinkTarget
setImageLinkTarget(null) setImageLinkTarget(null)
const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { linkHref: href || null }) const patch: { src?: string; linkHref?: string | null } = {}
if (ok) flash(href ? '图片链接已更新' : '图片链接已移除') if (src !== t.src) patch.src = src
if (linkHref !== t.href) patch.linkHref = linkHref || null
if (Object.keys(patch).length === 0) return
const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, patch)
if (ok) flash('图片链接已更新')
else setError('图片已不在文档中,链接未更新。') else setError('图片已不在文档中,链接未更新。')
}} }}
/> />

View File

@@ -217,6 +217,49 @@ export function ImageSizeModal({ current, onSubmit, onCancel }: {
) )
} }
/**
* 图片链接弹窗(docs/31 更正):右键图片 →「超链接…」。
* 主字段是图片自己的地址(src)——打开就带出来,可查看也可改;
* 第二字段是可选的「点击图片时打开的链接」(包裹链接,可空)。
*/
export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
src: string
linkHref: string
onSubmit: (next: { src: string; linkHref: string }) => void
onCancel: () => void
}) {
const [addr, setAddr] = useState(src)
const [link, setLink] = useState(linkHref)
const valid = addr.trim() !== ''
const submit = () => { if (valid) onSubmit({ src: addr.trim(), linkHref: link.trim() }) }
return (
<Shell onCancel={onCancel}>
<h3>图片链接</h3>
<label className="modal-field-label">图片地址</label>
<input
className="modal-input"
value={addr}
autoFocus
onChange={(e) => setAddr(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
placeholder="图片地址(本地相对路径或 https://…)"
/>
<label className="modal-field-label">点击时打开的链接(可空)</label>
<input
className="modal-input"
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
placeholder="可空:填了之后点击图片会跳转到这个地址"
/>
<div className="modal-actions">
<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}>

View File

@@ -161,6 +161,8 @@ 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-label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 10px 0 4px; }
.modal-field-label:first-of-type { margin-top: 0; }
.modal-field-row { display: flex; gap: 8px; align-items: stretch; } .modal-field-row { display: flex; gap: 8px; align-items: stretch; }
.modal-field-row .modal-input { flex: 1; } .modal-field-row .modal-input { flex: 1; }
.seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; } .seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }

View File

@@ -0,0 +1,45 @@
// 图片链接弹窗(docs/31 更正):打开就带出图片地址,包裹链接在第二字段。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { ImageLinkModal } from '../src/components/Modals'
function setInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('ImageLinkModal', () => {
it('预填图片地址与已有包裹链接;改地址后提交回传两者', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onSubmit = vi.fn()
await act(async () => {
root.render(<ImageLinkModal src="doc.assets/a.png" linkHref="https://x.dev" onSubmit={onSubmit} onCancel={() => undefined} />)
})
const inputs = container.querySelectorAll<HTMLInputElement>('.modal-input')
expect(inputs.length).toBe(2)
expect(inputs[0].value).toBe('doc.assets/a.png')
expect(inputs[1].value).toBe('https://x.dev')
await act(async () => { setInput(inputs[0], 'https://cdn.example.com/a.png') })
const ok = [...container.querySelectorAll('button')].find((b) => b.textContent === '确定')!
await act(async () => { ok.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onSubmit).toHaveBeenCalledWith({ src: 'https://cdn.example.com/a.png', linkHref: 'https://x.dev' })
root.unmount(); container.remove()
})
it('无包裹链接时第二字段为空', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<ImageLinkModal src="https://img.example.com/b.png" linkHref="" onSubmit={() => undefined} onCancel={() => undefined} />)
})
const inputs = container.querySelectorAll<HTMLInputElement>('.modal-input')
expect(inputs[0].value).toBe('https://img.example.com/b.png')
expect(inputs[1].value).toBe('')
root.unmount(); container.remove()
})
})

View File

@@ -99,6 +99,16 @@ describe('图片链接标记(docs/31)', () => {
await editor.destroy(); root.remove() await editor.destroy(); root.remove()
}) })
it('改图片地址(src):info 带出原地址,patch 后序列化与回读都是新地址', async () => {
const { root, editor } = await makeEditor('![图](doc.assets/a.png)\n')
const img = root.querySelector('img')!
expect(imageInfoAtElement(editor, img)?.src).toBe('doc.assets/a.png')
expect(updateImageAtElement(editor, img, { src: 'https://cdn.example.com/a.png' })).toBe(true)
expect(getMarkdown(editor)).toBe('![图](https://cdn.example.com/a.png)\n')
expect(imageInfoAtElement(editor, root.querySelector('img')!)?.src).toBe('https://cdn.example.com/a.png')
await editor.destroy(); root.remove()
})
it('已有包裹链接的文档解析后能读出 href', async () => { it('已有包裹链接的文档解析后能读出 href', async () => {
const { root, editor } = await makeEditor('[![图](a.png)](https://x.dev)\n') const { root, editor } = await makeEditor('[![图](a.png)](https://x.dev)\n')
const img = root.querySelector('img')! const img = root.querySelector('img')!