From 1c0dd0008813109d2ffe5dea48e2dfb2b863c61a Mon Sep 17 00:00:00 2001 From: Muse Date: Sun, 15 Nov 2026 10:06:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E5=9B=BE=E7=89=87=E8=B6=85?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=BC=B9=E7=AA=97=E5=B8=A6=E5=87=BA=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=9C=B0=E5=9D=80=EF=BC=88docs/31=20=E6=9B=B4?= =?UTF-8?q?=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一笔把图片「超链接」做成预填包裹链接,用户指出「没有带出来 这张图片的 url」——要的是图片自己的地址出现在弹窗里。改版 ImageLinkModal:主字段「图片地址」打开即带出该图 src(本地相对 路径或网络地址),可查看可改;包裹链接降为第二字段「点击时打开 的链接(可空)」。提交时一条事务同时处理 src 与 link 标记, 没改动则不发事务。新增弹窗预填与 src patch 测试。 --- docs/31-图片编辑体验.md | 11 +++++--- src/App.tsx | 23 +++++++++-------- src/components/Modals.tsx | 43 +++++++++++++++++++++++++++++++ src/styles.css | 2 ++ tests/image-link-modal.test.tsx | 45 +++++++++++++++++++++++++++++++++ tests/image-size.test.ts | 10 ++++++++ 6 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 tests/image-link-modal.test.tsx diff --git a/docs/31-图片编辑体验.md b/docs/31-图片编辑体验.md index ea48fd0..33d4f46 100644 --- a/docs/31-图片编辑体验.md +++ b/docs/31-图片编辑体验.md @@ -68,10 +68,13 @@ Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts) `[![alt](src)](href)`——实测这在本编辑器里可往返(link 标记挂在 图片节点上,解析/序列化都保留)。 -修法:右键命中图片时,「超链接…」打开图片模式的链接弹窗——预填 -图片当前的包裹链接(没有就是空),提交后用一条事务对图片节点 -addMark/removeMark(editor.ts 的 `updateImageAtElement`,与尺寸 -共用);清空即移除链接。文本选区的链接流程不变。 +修法(第一版理解偏了):先做了「预填包裹链接」的弹窗,用户随即 +指出「没有带出来这张图片的 url」——用户要的是**图片自己的地址** +出现在弹窗里。更正后的 `ImageLinkModal`:主字段「图片地址」打开 +即带出该图的 src(本地相对路径或网络地址),可查看可改,提交后 +patch 到节点;包裹链接降为第二字段「点击时打开的链接(可空)」, +提交时一条事务同时处理 src 与 link 标记(`updateImageAtElement`)。 +文本选区的链接流程不变。 配套的编辑器接口(EditorPaneApi):`imageInfoForElement`(读 src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点 diff --git a/src/App.tsx b/src/App.tsx index 64289e6..0985af2 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, 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 { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' 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) /** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(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 openEditorMenu = (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -1135,7 +1135,7 @@ export default function App() { const tid = editorMenu?.tabId if (el && tid) { 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') break } @@ -2068,16 +2068,19 @@ export default function App() { /> )} {imageLinkTarget && ( - setImageLinkTarget(null)} - onSubmit={(href) => { + onSubmit={({ src, linkHref }) => { const t = imageLinkTarget setImageLinkTarget(null) - const ok = paneApisRef.current.get(t.tabId)?.updateImageForElement(t.el, { linkHref: href || null }) - if (ok) flash(href ? '图片链接已更新' : '图片链接已移除') + const patch: { src?: string; linkHref?: string | null } = {} + 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('图片已不在文档中,链接未更新。') }} /> diff --git a/src/components/Modals.tsx b/src/components/Modals.tsx index bfb6fc7..aa591f6 100644 --- a/src/components/Modals.tsx +++ b/src/components/Modals.tsx @@ -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 ( + +

图片链接

+ + setAddr(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submit() }} + placeholder="图片地址(本地相对路径或 https://…)" + /> + + setLink(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submit() }} + placeholder="可空:填了之后点击图片会跳转到这个地址" + /> +
+ + +
+
+ ) +} + export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) { return ( diff --git a/src/styles.css b/src/styles.css index fb1bdd2..3926731 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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.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-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 .modal-input { flex: 1; } .seg-control { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; } diff --git a/tests/image-link-modal.test.tsx b/tests/image-link-modal.test.tsx new file mode 100644 index 0000000..ef9a7c8 --- /dev/null +++ b/tests/image-link-modal.test.tsx @@ -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( undefined} />) + }) + const inputs = container.querySelectorAll('.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( undefined} onCancel={() => undefined} />) + }) + const inputs = container.querySelectorAll('.modal-input') + expect(inputs[0].value).toBe('https://img.example.com/b.png') + expect(inputs[1].value).toBe('') + root.unmount(); container.remove() + }) +}) diff --git a/tests/image-size.test.ts b/tests/image-size.test.ts index e63663c..6644874 100644 --- a/tests/image-size.test.ts +++ b/tests/image-size.test.ts @@ -99,6 +99,16 @@ describe('图片链接标记(docs/31)', () => { 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 () => { const { root, editor } = await makeEditor('[![图](a.png)](https://x.dev)\n') const img = root.querySelector('img')!