From ea1237a20d144324f3f9d642d26a4b50666531bc Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Thu, 5 Feb 2026 17:20:24 +0100 Subject: [PATCH 1/3] Make link in viewing mode non-editable (incl. tests) --- .../src/components/toolbar/LinkInput.test.js | 227 ++++++++++++++++++ .../src/components/toolbar/LinkInput.vue | 20 +- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/LinkInput.test.js b/packages/super-editor/src/components/toolbar/LinkInput.test.js index 4fc3718053..cdac74ecd4 100644 --- a/packages/super-editor/src/components/toolbar/LinkInput.test.js +++ b/packages/super-editor/src/components/toolbar/LinkInput.test.js @@ -555,4 +555,231 @@ describe('LinkInput - getLinkHrefAtSelection type safety and boundary checking', expect(wrapper.vm.rawUrl).toBe(''); }); }); + + describe('Viewing mode behavior', () => { + it('should detect viewing mode correctly', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + }, + }); + + await nextTick(); + + expect(wrapper.vm.isViewingMode).toBe(true); + }); + + it('should detect non-viewing mode correctly', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'editing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + }, + }); + + await nextTick(); + + expect(wrapper.vm.isViewingMode).toBe(false); + }); + + it('should show "Link details" title in viewing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + const linkMarkType = { name: 'link' }; + mockEditor.state.selection.$from.nodeAfter = { + marks: [{ type: linkMarkType, attrs: { href: 'https://example.com' } }], + }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + await nextTick(); + + const titles = wrapper.findAll('.link-title'); + expect(titles.length).toBeGreaterThan(0); + expect(titles[0].text()).toBe('Link details'); + }); + + it('should show "Edit link" title in editing mode when link exists', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'editing' }; + const linkMark = mockEditor.state.schema.marks.link; + mockEditor.state.selection.$from.nodeAfter = { + marks: [{ type: linkMark, attrs: { href: 'https://example.com' } }], + }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + await nextTick(); + + const titles = wrapper.findAll('.link-title'); + expect(titles.length).toBeGreaterThan(0); + expect(titles[0].text()).toBe('Edit link'); + }); + + it('should make text input readonly in viewing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + + const textInput = wrapper.find('input[name="text"]'); + expect(textInput.exists()).toBe(true); + expect(textInput.attributes('readonly')).toBe(''); + }); + + it('should make URL input readonly in viewing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + + const urlInput = wrapper.find('input[name="link"]'); + expect(urlInput.exists()).toBe(true); + expect(urlInput.attributes('readonly')).toBe(''); + }); + + it('should not make inputs readonly in editing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'editing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + + const textInput = wrapper.find('input[name="text"]'); + const urlInput = wrapper.find('input[name="link"]'); + expect(textInput.exists()).toBe(true); + expect(urlInput.exists()).toBe(true); + expect(textInput.attributes('readonly')).toBeUndefined(); + expect(urlInput.attributes('readonly')).toBeUndefined(); + }); + + it('should hide Apply and Remove buttons in viewing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + const linkMarkType = { name: 'link' }; + mockEditor.state.selection.$from.nodeAfter = { + marks: [{ type: linkMarkType, attrs: { href: 'https://example.com' } }], + }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + await nextTick(); + + expect(wrapper.find('.link-buttons').exists()).toBe(false); + }); + + it('should show Apply button in editing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'editing' }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + + expect(wrapper.find('.submit-btn').exists()).toBe(true); + }); + + it('should show Remove button in editing mode when editing existing link', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'editing' }; + const linkMark = mockEditor.state.schema.marks.link; + mockEditor.state.selection.$from.nodeAfter = { + marks: [{ type: linkMark, attrs: { href: 'https://example.com' } }], + }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + await nextTick(); + + expect(wrapper.find('.remove-btn').exists()).toBe(true); + }); + + it('should keep open link button functional in viewing mode', async () => { + const mockEditor = createMockEditor(); + mockEditor.options = { documentMode: 'viewing' }; + const linkMark = mockEditor.state.schema.marks.link; + mockEditor.state.selection.$from.nodeAfter = { + marks: [{ type: linkMark, attrs: { href: 'https://example.com' } }], + }; + + const wrapper = mount(LinkInput, { + props: { + editor: mockEditor, + closePopover: mockClosePopover, + showInput: true, + }, + }); + + await nextTick(); + await nextTick(); + + const openLinkBtn = wrapper.find('.open-link-icon'); + expect(openLinkBtn.exists()).toBe(true); + expect(openLinkBtn.classes()).not.toContain('disabled'); + }); + }); }); diff --git a/packages/super-editor/src/components/toolbar/LinkInput.vue b/packages/super-editor/src/components/toolbar/LinkInput.vue index 9f10b16143..86a2ac9eca 100644 --- a/packages/super-editor/src/components/toolbar/LinkInput.vue +++ b/packages/super-editor/src/components/toolbar/LinkInput.vue @@ -138,6 +138,8 @@ const isEditing = computed(() => !isAnchor.value && !!getLinkHrefAtSelection()); const isDisabled = computed(() => !validUrl.value); +const isViewingMode = computed(() => props.editor?.options?.documentMode === 'viewing'); + const openLink = () => { window.open(url.value, '_blank'); }; @@ -209,6 +211,7 @@ const handleRemove = () => {