import * as React from 'react'; import { describe, it, beforeEach } from '@jest/globals'; import { render, waitFor } from '../../utils/testing/testingLibrary'; import Setting from '@joplin/lib/models/Setting'; import { createNoteAndResource, resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils'; import getWebViewWindowById from '../../utils/testing/getWebViewWindowById'; import TestProviderStack from '../testing/TestProviderStack'; import createMockReduxStore from '../../utils/testing/createMockReduxStore'; import RichTextEditor from './RichTextEditor'; import createTestEditorProps from './testing/createTestEditorProps'; import { EditorEvent, EditorEventType } from '@joplin/editor/events'; import { RefObject, useCallback, useMemo } from 'react'; import Note from '@joplin/lib/models/Note'; import shim from '@joplin/lib/shim'; import Resource from '@joplin/lib/models/Resource'; import { ResourceInfos } from '@joplin/renderer/types'; import { EditorControl, EditorLanguageType } from '@joplin/editor/types'; import attachedResources from '@joplin/lib/utils/attachedResources'; import { MarkupLanguage } from '@joplin/renderer'; import { NoteEntity } from '@joplin/lib/services/database/types'; import { EditorSettings } from './types'; import { pregQuote } from '@joplin/lib/string-utils'; import { join } from 'path'; interface WrapperProps { ref?: RefObject; noteResources?: ResourceInfos; onBodyChange: (newBody: string)=> void; onLinkClick?: (link: string)=> void; note?: NoteEntity; noteBody: string; readOnly?: boolean; } const defaultEditorProps = createTestEditorProps(); const testStore = createMockReduxStore(); const WrappedEditor: React.FC = ( { noteBody, note, onBodyChange, onLinkClick, noteResources, ref, readOnly = false, }: WrapperProps, ) => { const onEvent = useCallback((event: EditorEvent) => { if (event.kind === EditorEventType.Change) { onBodyChange(event.value); } else if (event.kind === EditorEventType.FollowLink) { if (!onLinkClick) { throw new Error('No mock function for onLinkClick registered.'); } onLinkClick(event.link); } }, [onBodyChange, onLinkClick]); const editorSettings = useMemo((): EditorSettings => { const isHtml = note?.markup_language === MarkupLanguage.Html; return { ...defaultEditorProps.editorSettings, readOnly, language: isHtml ? EditorLanguageType.Html : EditorLanguageType.Markdown, }; }, [note, readOnly]); return ; }; const getEditorWindow = async () => { return await getWebViewWindowById('RichTextEditor'); }; type EditorWindow = Window&typeof globalThis; const getEditorControl = (window: EditorWindow) => { if ('joplinRichTextEditor_' in window) { return window.joplinRichTextEditor_ as EditorControl; } throw new Error('No editor control found. Is the editor loaded?'); }; const mockTyping = (window: EditorWindow, text: string) => { const document = window.document; const editor = document.querySelector('div[contenteditable]'); for (const character of text.split('')) { editor.dispatchEvent(new window.KeyboardEvent('keydown', { key: character })); const paragraphs = editor.querySelectorAll('p'); (paragraphs[paragraphs.length - 1] ?? editor).appendChild(document.createTextNode(character)); editor.dispatchEvent(new window.KeyboardEvent('keyup', { key: character })); } }; const mockSelectionMovement = (window: EditorWindow, from: number, to?: number) => { getEditorControl(window).select(from, to ?? from); }; const findElement = async function(selector: string) { const window = await getEditorWindow(); return await waitFor(() => { const element = window.document.querySelector(selector); expect(element).toBeTruthy(); return element; }, { onTimeout: (error) => { return new Error(`Failed to find element from selector ${selector}. DOM: ${window?.document?.body?.innerHTML}. \n\nFull error: ${error}`); }, }); }; const createRemoteResourceAndNote = async (remoteClientId: number) => { await setupDatabaseAndSynchronizer(remoteClientId); await switchClient(remoteClientId); let note = await Note.save({ title: 'Note 1', parent_id: '' }); note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`); const allResources = await Resource.all(); expect(allResources.length).toBe(1); const resourceId = allResources[0].id; await synchronizerStart(); await switchClient(0); await synchronizerStart(); return { noteId: note.id, resourceId }; }; describe('RichTextEditor', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(0); await switchClient(0); Setting.setValue('editor.codeView', false); }); it('should render basic markdown', async () => { render(); const dom = (await getEditorWindow()).document; expect((await findElement('h3')).textContent).toBe('Test'); expect(dom.querySelector('p').textContent).toBe('Paragraph test'); expect(dom.querySelector('p code').textContent).toBe('test'); }); it('should dispatch events when the editor content changes', async () => { let body = '**bold** normal'; render( { body = newBody; }} />); const window = await getEditorWindow(); mockTyping(window, ' test'); await waitFor(async () => { expect(body.trim()).toBe('**bold** normal test'); }); }); it('should become non-editable when set to read-only', async () => { const table = '| A | B |\n| --- | --- |\n| C | D |'; const component = render(); const editor = await findElement('.prosemirror-editor'); expect(editor.getAttribute('contenteditable')).toBe('true'); mockSelectionMovement(await getEditorWindow(), 4); const tableToolbar = await findElement('.floating-button-bar:not(.-hidden)'); component.rerender(); await waitFor(() => { expect(editor.getAttribute('contenteditable')).toBe('false'); expect(tableToolbar.classList).toContain('-hidden'); }); }); it('should save repeated spaces using nonbreaking spaces', async () => { let body = 'Test'; render( { body = newBody; }} />); const window = await getEditorWindow(); mockTyping(window, ' test'); await waitFor(async () => { expect(body.trim()).toBe('Test \u00A0test'); }); }); it('should render clickable checkboxes', async () => { let body = '- [ ] Test\n- [x] Another test'; render( { body = newBody; }} />); const firstCheckbox = await findElement('input[type=checkbox]'); const dom = (await getEditorWindow()).document; const getCheckboxLabel = (checkbox: HTMLElement) => { const labelledByAttr = checkbox.getAttribute('aria-labelledby'); const label = dom.getElementById(labelledByAttr); return label; }; // Should have the correct labels expect(firstCheckbox.getAttribute('aria-labelledby')).toBeTruthy(); expect(getCheckboxLabel(firstCheckbox).textContent).toBe('Test'); // Should be correctly checked/unchecked expect(firstCheckbox.checked).toBe(false); // Clicking a checkbox should toggle it firstCheckbox.click(); await waitFor(async () => { expect(body.trim()).toBe('- [x] Test\n- [x] Another test'); }); }); it('should reload resource placeholders when the corresponding item downloads', async () => { Setting.setValue('sync.resourceDownloadMode', 'manual'); const { noteId, resourceId } = await createRemoteResourceAndNote(1); const note = await Note.load(noteId); const localResource = await Resource.load(resourceId); let localState = await Resource.localState(localResource); expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE); const editorRef = React.createRef(); const component = render( , ); // The resource placeholder should have rendered const placeholder = await findElement(`span[data-resource-id=${JSON.stringify(localResource.id)}]`); expect([...placeholder.classList]).toContain('not-loaded-resource'); await resourceFetcher().markForDownload([localResource.id]); await waitFor(async () => { localState = await Resource.localState(localResource.id); expect(localState).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE }); }); component.rerender( , ); editorRef.current.onResourceChanged(localResource.id); expect( await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`), ).toBeTruthy(); }); it('should render clickable internal note links', async () => { const linkTarget = await Note.save({ title: 'test' }); const body = `[link](:/${linkTarget.id})`; const onLinkClick = jest.fn(); render(); const window = await getEditorWindow(); const link = await findElement('a[href]'); expect(link.href).toBe(`:/${linkTarget.id}`); mockSelectionMovement(window, 2); const tooltipButton = await findElement('.link-tooltip:not(.-hidden) > button'); tooltipButton.click(); await waitFor(() => { expect(onLinkClick).toHaveBeenCalledWith(`:/${linkTarget.id}`); }); }); it('should avoid rendering URLs with unknown protocols', async () => { let body = '[link](unknown://test)'; render( { body = newBody; }} />); const renderedLink = await findElement('a[href][data-original-href]'); expect(renderedLink.getAttribute('href')).toBe('#'); expect(renderedLink.getAttribute('data-original-href')).toBe('unknown://test'); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { expect(body.trim()).toBe('[link](unknown://test) testing'); }); }); it.each([ MarkupLanguage.Markdown, MarkupLanguage.Html, ])('should preserve image attachments on edit (case %#)', async (markupLanguage) => { const { note, resource } = await createNoteAndResource({ markupLanguage }); let body = note.body; const resources = await attachedResources(body); render( { body = newBody; }} noteResources={resources} />); const renderedImage = await findElement(`img[data-resource-id=${JSON.stringify(resource.id)}]`); expect(renderedImage).toBeTruthy(); const window = await getEditorWindow(); mockTyping(window, ' test'); // The rendered image should still have the correct ALT and source await waitFor(async () => { const editorContent = body.trim(); if (markupLanguage === MarkupLanguage.Html) { expect(editorContent).toMatch( new RegExp(`^

]* src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test

$`), ); } else { expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`); } }); }); it('should preserve non-image attachments on edit', async () => { const { note, resource } = await createNoteAndResource({ path: join(supportDir, 'sample.txt') }); let body = note.body; const resources = await attachedResources(body); render( { body = newBody; }} noteResources={resources} />); const window = await getEditorWindow(); mockTyping(window, ' test'); await waitFor(async () => { const editorContent = body.trim(); // TODO: At present, the resource title may be included in the final Markdown // (e.g. as [sample.txt](:/id-here "sample.txt")). expect(editorContent).toMatch(new RegExp(`^\\[sample\\.txt\\]\\(:/${pregQuote(resource.id)}.*\\) test$`)); }); }); it.each([ { useValidSyntax: false }, { useValidSyntax: true }, ])('should preserve inline math on edit (%j)', async ({ useValidSyntax }) => { const macros = '\\def\\<{\\langle} \\def\\>{\\rangle}'; let inlineMath = '| \\< u, v \\> |^2 \\leq \\< u, u \\>\\< v, v \\>'; // The \\< escapes are invalid without the above custom macro definitions. // It should be possible for the editor to preserve invalid math syntax. if (useValidSyntax) { inlineMath = macros + inlineMath; } let body = `Inline math: $${inlineMath}$...`; render( { body = newBody; }} />); const renderedInlineMath = await findElement('span.joplin-editable'); expect(renderedInlineMath).toBeTruthy(); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { expect(body.trim()).toBe(`Inline math: $${inlineMath}$... testing`); }); }); it('should preserve block math on edit', async () => { let body = 'Test:\n\n$$3^2 + 4^2 = \\sqrt{625}$$\n\nTest.'; render( { body = newBody; }} />); const renderedInlineMath = await findElement('div.joplin-editable'); expect(renderedInlineMath).toBeTruthy(); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing'); }); }); it('should be possible to show an editor for math blocks', async () => { let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$'; render( { body = newBody; }} />); const window = await getEditorWindow(); // Select the math block to show the "edit" button. mockSelectionMovement(window, ''.length, '$'.length); const editButton = await findElement('button.edit-button'); editButton.click(); const editor = await findElement('dialog .cm-editor'); expect(editor).toBeTruthy(); expect(editor.textContent).toContain('3^2 + 4^2 = 5^2'); }); it.each(['-', '- [ ]'])('should save lists as single-spaced (list markers: %j)', async (marker) => { let body = [ 'Test:\n', 'this', 'is', 'a', 'test.', ].join(`\n${marker} `); render( { body = newBody; }} />); const window = await getEditorWindow(); mockTyping(window, ' Testing'); await waitFor(async () => { expect(body.trim()).toBe([ 'Test:\n', 'this', 'is', 'a', 'test. Testing', ].join(`\n${marker} `)); }); }); it.each(['-', '1.'])('should not add extra blank lines around nested lists (marker: %j)', async (marker) => { const nested = marker === '1.' ? '1.' : '-'; let body = `${marker} a\n${marker} b\n ${nested} c\n ${nested} d\n${marker} e`; render( { body = newBody; }} />); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { // Nested lists should not have extra blank lines above or below expect(body).not.toMatch(/\n\n\s*[-\d]/); }); }); it('should preserve table of contents blocks on edit', async () => { let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.'; render( { body = newBody; }} />); // Should render the [toc] as a joplin-editable const renderedTableOfContents = await findElement('div.joplin-editable'); expect(renderedTableOfContents).toBeTruthy(); // Should have a link for each heading expect(renderedTableOfContents.querySelectorAll('a[href]')).toHaveLength(2); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing'); }); }); it.each([ '**bold**', '*italic*', '$\\text{math}$', 'test', '`code`', '==highlight==ed', 'Superscript', 'Subscript', '![image](data:image/svg+xml;utf8,test)', '', ])('should preserve inline markup on edit (case %#)', async (initialBody) => { initialBody += 'test'; // Ensure that typing will add new content outside the formatting let body = initialBody; render( { body = newBody; }} />); await findElement('div.prosemirror-editor'); const window = await getEditorWindow(); mockTyping(window, ' testing'); await waitFor(async () => { expect(body.trim()).toBe(`${initialBody} testing`); }); }); });