Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 285 additions & 1 deletion packages/super-editor/src/components/toolbar/LinkInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ describe('LinkInput - getLinkHrefAtSelection type safety and boundary checking',
}),
},
doc: {
resolve: vi.fn(),
resolve: vi.fn(() => ({
parent: { inlineContent: true },
min: vi.fn(function (other) { return this; }),
max: vi.fn(function (other) { return this; }),
})),
},
},
dispatch: vi.fn(),
Expand Down Expand Up @@ -555,4 +559,284 @@ 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');
});

it('should handle submit in editing mode', 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();

wrapper.vm.handleSubmit();

// Verify that link modification commands were called
expect(mockEditor.commands.toggleLink).toHaveBeenCalled();
expect(mockClosePopover).toHaveBeenCalled();
});

it('should not handle submit 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();

wrapper.vm.handleSubmit();

// Verify that link modification commands were not called
expect(mockEditor.commands.toggleLink).not.toHaveBeenCalled();
expect(mockEditor.commands.unsetLink).not.toHaveBeenCalled();
expect(mockClosePopover).not.toHaveBeenCalled();
});
});
});
23 changes: 21 additions & 2 deletions packages/super-editor/src/components/toolbar/LinkInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
};
Expand Down Expand Up @@ -169,6 +171,9 @@ onMounted(() => {

// --- Link logic moved here ---
const handleSubmit = () => {
// Prevent form submission in viewing mode
if (isViewingMode.value) return;

const editor = props.editor;
if (!editor) return;

Expand Down Expand Up @@ -209,14 +214,15 @@ const handleRemove = () => {
<template>
<div class="link-input-ctn" :class="{ 'high-contrast': isHighContrastMode }">
<div class="link-title" v-if="isAnchor">Page anchor</div>
<div class="link-title" v-else-if="isViewingMode">Link details</div>
<div class="link-title" v-else-if="isEditing">Edit link</div>
<div class="link-title" v-else>Add link</div>

<div v-if="showInput && !isAnchor" class="link-input-wrapper">
<!-- Text input -->
<div class="input-row text-input-row">
<div class="input-icon text-input-icon">T</div>
<input type="text" name="text" placeholder="Text" v-model="text" @keydown.enter.stop.prevent="handleSubmit" />
<input type="text" name="text" placeholder="Text" v-model="text" :readonly="isViewingMode" @keydown.enter.stop.prevent="!isViewingMode && handleSubmit" />
</div>

<!-- URL input -->
Expand All @@ -228,6 +234,7 @@ const handleRemove = () => {
placeholder="Type or paste a link"
:class="{ error: urlError }"
v-model="rawUrl"
:readonly="isViewingMode"
@keydown.enter.stop.prevent="handleSubmit"
@keydown="urlError = false"
/>
Expand All @@ -240,7 +247,7 @@ const handleRemove = () => {
data-item="btn-link-open"
></div>
</div>
<div class="input-row link-buttons">
<div class="input-row link-buttons" v-if="!isViewingMode">
<button class="remove-btn" @click="handleRemove" v-if="isEditing" data-item="btn-link-remove">
<div class="remove-btn__icon" v-html="toolbarIcons.removeLink"></div>
Remove
Expand Down Expand Up @@ -307,6 +314,18 @@ const handleRemove = () => {
outline: none;
border: 1px solid #1355ff;
}

&[readonly] {
background-color: #f5f5f5;
cursor: default;
color: #888;
border-color: #e0e0e0;

&:active,
&:focus {
border-color: #e0e0e0;
}
}
}
}

Expand Down
Loading