From b214bafe60e04f0cd38c412f82b7ad6c35e689ba Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 7 Jul 2026 19:27:50 +0700 Subject: [PATCH] fix(markdown-editor): key the link popover on the link start, not its full range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link edit popover was keyed on `${range.from}-${range.to}`. Typing inside a link grows range.to on every keystroke (the character inherits the link mark), so unmounted and remounted each keystroke and re-seeded its URL field from initialUrl — silently discarding an in-progress URL edit and churning the popover DOM. Key on range.from alone: it is stable while the caret stays in the same link, and still changes when the selection moves onto a different link. Co-Authored-By: Claude Opus 4.8 --- .../markdown-editor-link-handle.test.ts | 32 +++++++++++++++++++ .../markdown-editor-link-handle.tsx | 5 ++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.test.ts diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.test.ts new file mode 100644 index 00000000..bdb65b0c --- /dev/null +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.test.ts @@ -0,0 +1,32 @@ +import { Editor, getMarkRange } from '@tiptap/core'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { createMarkdownExtensions } from './markdown-editor-extensions'; +import { setupEditorJsdom } from './markdown-editor-test-setup'; + +beforeAll(setupEditorJsdom); + +describe('link handle popover key is stable while typing inside a link (LINK-REMOUNT)', () => { + it('keeps range.from fixed as range.to grows, so a from-based key does not remount', () => { + const editor = new Editor({ + content: '[label](https://example.com)', + contentType: 'markdown', + extensions: createMarkdownExtensions(), + }); + const linkType = editor.schema.marks.link!; + + editor.commands.setTextSelection(3); + const before = getMarkRange(editor.state.selection.$from, linkType); + + editor.commands.insertContent('X'); + const after = getMarkRange(editor.state.selection.$from, linkType); + + editor.destroy(); + + expect(before && after).toBeTruthy(); + // The fix keys the popover on range.from alone: stable here, so the edit form is not remounted. + expect(after!.from).toBe(before!.from); + // range.to grew by the inserted char — the old `${from}-${to}` key would have remounted every keystroke. + expect(after!.to).toBe(before!.to + 1); + }); +}); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx index 4fba7411..406e6a0f 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-link-handle.tsx @@ -97,7 +97,10 @@ function useLinkHandle(editor: Editor) { const rect = posToDOMRect(editor.view, range.from, range.to); const href = (editor.getAttributes('link').href as string | undefined) ?? ''; - setTarget({ href, key: `${range.from}-${range.to}`, rect }); + // Key on the link's START only. Typing inside the link grows range.to every keystroke, and + // would then unmount/remount and re-seed its URL field from initialUrl, + // silently discarding an in-progress edit. from is stable while the caret stays in the same link. + setTarget({ href, key: `${range.from}`, rect }); }; const clear = () => setTarget(null);