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}"`);
  });
});