import { Editor as TextEditor, Extensions } from '@tiptap/core' import { getTipTapDefaultExtensions } from './richText' import { defaultWrappingPairs, WrapSelectionExtension } from './wrapSelection' // The test DOM doesn't compute font-family, which is how monospace text is detected. function mockFontFamily(fontFamily: string) { vi.spyOn(window, 'getComputedStyle').mockReturnValue({ fontFamily } as CSSStyleDeclaration) } afterEach(() => { vi.restoreAllMocks() }) // Drives the real editor rather than the plugin in isolation, so that the ordering against // Typography's input rules — which fire on a non-empty selection too — is covered. function typeCharacter( content: string, selection: { from: number; to: number } | 'all', text: string, { extensions = getTipTapDefaultExtensions(), fontFamily, }: { extensions?: Extensions; fontFamily?: string } = {} ) { const textEditor = new TextEditor({ extensions, // Matches how RichTextArea builds the editor. enableCoreExtensions: { textDirection: false }, content, }) if (fontFamily) mockFontFamily(fontFamily) try { if (selection === 'all') { textEditor.commands.selectAll() } else { textEditor.commands.setTextSelection(selection) } const { from, to } = textEditor.state.selection const replaceSelection = () => textEditor.state.tr.insertText(text, from, to) const handled = textEditor.view.someProp('handleTextInput', (handler) => handler(textEditor.view, from, to, text, replaceSelection) ) if (!handled) { textEditor.view.dispatch(replaceSelection()) } const html = textEditor.getHTML() const selectedText = textEditor.state.doc.textBetween( textEditor.state.selection.from, textEditor.state.selection.to ) textEditor.commands.undo() return { html, selectedText, htmlAfterUndo: textEditor.getHTML() } } finally { textEditor.destroy() } } const selectHello = { from: 1, to: 6 } describe('WrapSelectionExtension', () => { it('wraps the selection in a symmetrical pair', () => { expect(typeCharacter('
hello
', selectHello, '(').html).toBe('(hello)
') expect(typeCharacter('hello
', selectHello, '«').html).toBe('«hello»
') }) it('wraps the selection in a pair that opens and closes differently', () => { expect(typeCharacter('hello
', selectHello, '¡').html).toBe('¡hello!
') expect(typeCharacter('hello
', selectHello, '¿').html).toBe('¿hello?
') }) it('wraps a straight quote in curly quotes, matching what Typography does to a typed one', () => { expect(typeCharacter('hello
', selectHello, '"').html).toBe('“hello”
') expect(typeCharacter('hello
', selectHello, "'").html).toBe('‘hello’
') }) it('wraps only the selected part of a paragraph', () => { expect(typeCharacter('hello world
', { from: 7, to: 12 }, '“').html).toBe( 'hello “world”
' ) }) it('keeps the original text selected so wraps can be stacked', () => { const { selectedText } = typeCharacter('hello
', selectHello, '(') expect(selectedText).toBe('hello') }) it('wraps in the whole pair when the closing character is typed', () => { // `¡` and `¿` are out of reach on most layouts, so `!` and `?` have to wrap too. expect(typeCharacter('hello
', selectHello, '!').html).toBe('¡hello!
') expect(typeCharacter('hello
', selectHello, '?').html).toBe('¿hello?
') expect(typeCharacter('hello
', selectHello, ')').html).toBe('(hello)
') expect(typeCharacter('hello
', selectHello, '»').html).toBe('«hello»
') }) it('picks the first pair when two are closed by the same character', () => { // `”` closes both `“ ”` and the Polish `„ ”`. expect(typeCharacter('hello
', selectHello, '”').html).toBe('“hello”
') expect(typeCharacter('hello
', selectHello, '’').html).toBe('‘hello’
') }) it('replaces the selection as usual for a character in no pair', () => { expect(typeCharacter('hello
', selectHello, 'a').html).toBe('a
') expect(typeCharacter('hello
', selectHello, '@').html).toBe('@
') }) it('types the character as itself when nothing is selected', () => { expect(typeCharacter('hello
', { from: 6, to: 6 }, '(').html).toBe( 'hello(
' ) }) it('gives the delimiters the marks that run the length of the selection', () => { expect(typeCharacter('hello
', selectHello, '(').html).toBe( '(hello)
' ) // `world` is plain, so its brackets are too, even though bold text precedes it. expect( typeCharacter('hello world
', { from: 7, to: 12 }, '(').html ).toBe('hello (world)
') // A selection that starts bold and ends plain gets plain brackets, not a stray bold one. expect( typeCharacter('hello world
', { from: 1, to: 12 }, '(').html ).toBe('(hello world)
') }) it('does not carry a link mark out to a delimiter beyond the link', () => { expect( typeCharacter('link tail
', { from: 1, to: 10 }, '(') .html ).toBe( '(link tail)
' ) }) it('wraps a select-all the way drag-selecting the same text does', () => { expect(typeCharacter('hello
', 'all', '(').html).toBe('(hello)
') expect(typeCharacter('hello
world
', 'all', '(').html).toBe( '(hello
world)
' ) }) it('replaces rather than wraps when the character retypes the selection itself', () => { // The browser leaves the DOM alone here, and prosemirror-view reports the input as the // selected text, so without a guard the `(` would wrap itself into `(()`. expect(typeCharacter('(
', { from: 1, to: 2 }, '(').html).toBe('(
') }) it('replaces when the selection runs from plain text into code', () => { expect(typeCharacter('say hello
(lo
a
b
', { from: 2, to: 4 }, '(').html).toBe( 'a(b
' ) }) it('wraps code in straight quotes, since characters in code are literal', () => { expect(typeCharacter('hello
"hello"
hello
\'hello\'
hello
(hello)
hello
', selectHello, '"', mono).html).toBe( '"hello"
' ) expect(typeCharacter('hello
', selectHello, '(', mono).html).toBe( '(hello)
' ) }) it('marks the selection as code when a backtick is typed', () => { expect(typeCharacter('hello world
', selectHello, '`')).toEqual({ html: 'hello world
hello world
', }) // Code is all-or-nothing on a selection running from plain text into code. expect(typeCharacter('say hello
say hello
hello
hello
' ) }) it('wraps in one undo step', () => { expect(typeCharacter('hello
', selectHello, '(').htmlAfterUndo).toBe( 'hello
' ) }) it('takes its pairs from the extension options', () => { const extensions = [ ...getTipTapDefaultExtensions().filter((e) => e.name !== 'wrapSelection'), WrapSelectionExtension.configure({ pairs: { ...defaultWrappingPairs, '"': ['"', '"'] }, }), ] expect(typeCharacter('hello
', selectHello, '"', { extensions }).html).toBe( '"hello"
' ) }) it('wraps text selected across paragraphs', () => { expect(typeCharacter('hello
world
', { from: 1, to: 13 }, '(').html).toBe( '(hello
world)
' ) }) }) describe('Typography', () => { it('converts a typed straight quote into a curly one', () => { expect(typeCharacter('', { from: 1, to: 1 }, '"').html).toBe('“
') }) }) describe('LiteralTypingExtension', () => { function typeText(content: string, at: number, text: string, fontFamily?: string) { const textEditor = new TextEditor({ extensions: getTipTapDefaultExtensions(), enableCoreExtensions: { textDirection: false }, content, }) if (fontFamily) mockFontFamily(fontFamily) try { textEditor.commands.setTextSelection(at) for (const char of text) { const { from, to } = textEditor.state.selection const insert = () => textEditor.state.tr.insertText(char, from, to) const handled = textEditor.view.someProp('handleTextInput', (handler) => handler(textEditor.view, from, to, char, insert) ) if (!handled) textEditor.view.dispatch(insert()) } return textEditor.getHTML() } finally { textEditor.destroy() } } it('keeps typed punctuation literal until the closing backtick makes it code', () => { expect(typeText('', 1, '`say "hi"`')).toBe('say "hi"
...args -> it\'s
x “hi”
a`b say
a`b say “hi”
"it's" — ok
` ) expect(typeText('', 1, '"hi"', "'tldraw_draw', sans-serif")).toBe( '“hi”
' ) }) it('keeps quotes typed into existing code straight', () => { expect(typeText('hi
h"i