exit-link-on-space.test.ts4.2 KBView on GitHub /**
* Regression tests for `ExitLinkOnSpace`.
*
* Concentrate reported "link formatting broken in drafts". The cause was this extension:
* it bound Space globally and called `unsetLink()` whenever the caret was anywhere inside a
* link. `unsetLink()` extends over the whole mark range on a collapsed selection, so editing
* a word in the middle of a hyperlink and pressing space stripped the href off the entire
* link — and the next autosave pushed the de-linked body to Gmail.
*
* A real Tiptap editor is built (jsdom) so the actual keymap runs: our handler first, and
* ProseMirror's default insertion when it declines.
*/
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Link from '@tiptap/extension-link';
import { ExitLinkOnSpace } from '@/modules/drafting/components/extensions';
const HREF = 'https://cedarcopilot.com/pricing';
const LINKED = `<p><a href="${HREF}">our pricing page</a> follows</p>`;
const editors: Editor[] = [];
function makeEditor(content: string): Editor {
const element = document.createElement('div');
document.body.appendChild(element);
const editor = new Editor({
element,
extensions: [
Document,
Paragraph,
Text,
// autolink: true mirrors the composer — and it is load-bearing here: TipTap's Link mark
// reports `inclusive() { return this.options.autolink }`, so with autolink on the mark IS
// inclusive and text typed at the end of a link really is absorbed into it. That is the
// case this extension exists for.
Link.configure({ openOnClick: false, autolink: true }),
ExitLinkOnSpace,
],
content,
});
editors.push(editor);
return editor;
}
/** Position of the character offset `n` inside the first paragraph (doc starts at 1). */
const posInFirstParagraph = (n: number) => n + 1;
const pressSpace = (editor: Editor) => {
const handler = (
ExitLinkOnSpace.config.addKeyboardShortcuts as unknown as (this: {
editor: Editor;
}) => Record<string, (props: { editor: Editor }) => boolean>
).call({ editor });
return handler.Space!({ editor });
};
afterEach(() => {
while (editors.length) editors.pop()?.destroy();
});
describe('ExitLinkOnSpace', () => {
it('leaves the link intact when the caret is in the MIDDLE of it', () => {
const editor = makeEditor(LINKED);
// "our pricing page" — put the caret after "our".
editor.commands.setTextSelection(posInFirstParagraph(3));
expect(editor.isActive('link')).toBe(true);
const handled = pressSpace(editor);
// Declines, so the space types normally as part of the link text.
expect(handled).toBe(false);
expect(editor.getHTML()).toContain(`href="${HREF}"`);
});
it('still carries the href after a mid-link edit and space', () => {
const editor = makeEditor(LINKED);
editor.commands.setTextSelection(posInFirstParagraph(3));
pressSpace(editor);
editor.commands.insertContent('x');
expect(editor.getHTML()).toContain(`href="${HREF}"`);
});
it('breaks out of the link when the caret is at its END', () => {
const editor = makeEditor(`<p><a href="${HREF}">pricing</a></p>`);
editor.commands.setTextSelection(posInFirstParagraph(7)); // after "pricing"
expect(editor.isActive('link')).toBe(true);
const handled = pressSpace(editor);
expect(handled).toBe(true);
// The link survives; the newly typed text is outside it.
expect(editor.getHTML()).toContain(`href="${HREF}"`);
editor.commands.insertContent('later');
expect(editor.getHTML()).not.toContain(`>pricing later</a>`);
expect(editor.getText()).toContain('pricing later');
});
it('declines outside a link entirely', () => {
const editor = makeEditor('<p>no links here</p>');
editor.commands.setTextSelection(posInFirstParagraph(3));
expect(pressSpace(editor)).toBe(false);
});
it('declines when text is selected rather than a bare caret', () => {
const editor = makeEditor(LINKED);
editor.commands.setTextSelection({ from: posInFirstParagraph(1), to: posInFirstParagraph(5) });
expect(pressSpace(editor)).toBe(false);
expect(editor.getHTML()).toContain(`href="${HREF}"`);
});
});