Skip to content
Merged
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
86 changes: 86 additions & 0 deletions packages/super-editor/src/components/toolbar/LinkInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -843,4 +843,90 @@ describe('LinkInput - getLinkHrefAtSelection type safety and boundary checking',
expect(mockClosePopover).not.toHaveBeenCalled();
});
});

describe('URL normalization', () => {
it('defaults bare domains to https when submitting a new link', async () => {
const mockEditor = createMockEditor();
mockEditor.options = { documentMode: 'editing' };

const wrapper = mount(LinkInput, {
props: {
editor: mockEditor,
closePopover: mockClosePopover,
showInput: true,
},
});

await nextTick();
await nextTick();

await wrapper.find('input[name="link"]').setValue('example.com');
await nextTick();

wrapper.vm.handleSubmit();

expect(mockEditor.commands.toggleLink).toHaveBeenCalledWith(
expect.objectContaining({ href: 'https://example.com' }),
);
});

it('preserves explicit http links when submitting an 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: 'http://example.com' } }],
};

const wrapper = mount(LinkInput, {
props: {
editor: mockEditor,
closePopover: mockClosePopover,
showInput: true,
},
});

await nextTick();
await nextTick();

wrapper.vm.handleSubmit();

expect(mockEditor.commands.toggleLink).toHaveBeenCalledWith(
expect.objectContaining({ href: 'http://example.com' }),
);
});

it('blocks unsafe schemes in both submit and open-link flows', async () => {
const mockEditor = createMockEditor();
mockEditor.options = { documentMode: 'editing' };
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);

const wrapper = mount(LinkInput, {
props: {
editor: mockEditor,
closePopover: mockClosePopover,
showInput: true,
},
});

await nextTick();
await nextTick();

await wrapper.find('input[name="link"]').setValue('javascript:foo.bar()');
await nextTick();

const openLinkBtn = wrapper.find('.open-link-icon');
expect(openLinkBtn.classes()).toContain('disabled');

wrapper.vm.handleSubmit();
await openLinkBtn.trigger('click');

expect(wrapper.vm.urlError).toBe(true);
expect(mockEditor.commands.toggleLink).not.toHaveBeenCalled();
expect(mockClosePopover).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();

openSpy.mockRestore();
});
});
});
34 changes: 22 additions & 12 deletions packages/super-editor/src/components/toolbar/LinkInput.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { sanitizeHref } from '@superdoc/url-validation';
import { toolbarIcons } from './toolbarIcons.js';
import { useHighContrastMode } from '../../composables/use-high-contrast-mode';
import { TextSelection } from 'prosemirror-state';
Expand Down Expand Up @@ -118,21 +119,22 @@ const text = ref('');
const rawUrl = ref('');
const isAnchor = ref(false);

// Prepend http if missing
const HAS_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;

// Default to https:// when no scheme is specified. Validation stays centralized in sanitizeHref.
const url = computed(() => {
if (!rawUrl.value) return '';
if (!rawUrl.value.startsWith('http') && !rawUrl.value.startsWith('#')) return 'http://' + rawUrl.value;
return rawUrl.value;
if (rawUrl.value.startsWith('#') || HAS_PROTOCOL.test(rawUrl.value)) return rawUrl.value;
return 'https://' + rawUrl.value;
});

const validUrl = computed(() => {
// anchors (starting with #) are always considered valid
if (url.value.startsWith('#')) return true;

const urlSplit = url.value.split('.').filter(Boolean);
return url.value.includes('.') && urlSplit.length > 1;
const sanitizedUrl = computed(() => {
if (!url.value) return null;
return sanitizeHref(url.value);
});

const validUrl = computed(() => sanitizedUrl.value !== null);

// --- CASE LOGIC ---
const isEditing = computed(() => !isAnchor.value && !!getLinkHrefAtSelection());

Expand All @@ -141,7 +143,9 @@ const isDisabled = computed(() => !validUrl.value);
const isViewingMode = computed(() => props.editor?.options?.documentMode === 'viewing');

const openLink = () => {
window.open(url.value, '_blank');
const href = sanitizedUrl.value?.href;
if (!href) return;
window.open(href, '_blank');
};

const updateFromEditor = () => {
Expand Down Expand Up @@ -189,10 +193,16 @@ const handleSubmit = () => {
return;
}

const finalText = text.value || url.value;
const href = sanitizedUrl.value?.href;
if (!href) {
urlError.value = true;
return;
}

const finalText = text.value || href;

if (editor.commands?.toggleLink) {
editor.commands.toggleLink({ href: url.value, text: finalText });
editor.commands.toggleLink({ href, text: finalText });
}

// Move cursor to end of link and refocus editor.
Expand Down
Loading