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

', { from: 1, to: 8 }, '(').html).toBe( '

(lo

' ) }) it('replaces when the selection holds nothing but a block boundary', () => { expect(typeCharacter('

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

', selectHello, '"').html).toBe( '

"hello"

' ) expect(typeCharacter('

hello

', selectHello, "'").html).toBe( '

\'hello\'

' ) expect(typeCharacter('

hello

', selectHello, '(').html).toBe( '

(hello)

' ) }) it('wraps monospace text in straight quotes', () => { const mono = { fontFamily: "'tldraw_mono', monospace" } expect(typeCharacter('

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

', selectedText: 'hello', htmlAfterUndo: '

hello world

', }) // Code is all-or-nothing on a selection running from plain text into code. expect(typeCharacter('

say hello

', { from: 1, to: 8 }, '`').html).toBe( '

say hello

' ) }) it('turns code back into plain text when a backtick is typed over it', () => { expect(typeCharacter('

hello

', selectHello, '`').html).toBe( '

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"

') expect(typeText('

', 1, "`...args -> it's`")).toBe( '

...args -> it\'s

' ) }) it('goes back to typography once the span is closed', () => { expect(typeText('

', 1, '`x` "hi"')).toBe('

x “hi”

') }) it('does not count a backtick inside existing code as opening a span', () => { expect(typeText('

a`b say

', 8, ' "hi"')).toBe( '

a`b say “hi”

' ) }) it('keeps quotes straight in monospace text, but still applies the other rules', () => { expect(typeText('

', 1, `"it's" -- ok`, "'tldraw_mono', monospace")).toBe( `

"it's" — ok

` ) expect(typeText('

', 1, '"hi"', "'tldraw_draw', sans-serif")).toBe( '

“hi”

' ) }) it('keeps quotes typed into existing code straight', () => { expect(typeText('

hi

', 2, '"')).toBe('

h"i

') }) })