import { fixture, expect, oneEvent } from '@open-wc/testing'; import { html } from 'lit'; import { TextSelection } from 'prosemirror-state'; import '../index'; import type { NileWysiwygEditor } from './nile-wysiwyg-editor'; async function editorFixture(template = html``) { const el = await fixture(template); await el.updateComplete; return el; } function selectAll(el: NileWysiwygEditor) { const view = el.pmView!; const selection = TextSelection.create(view.state.doc, 1, view.state.doc.content.size - 1); view.dispatch(view.state.tr.setSelection(selection)); } describe('nile-wysiwyg-editor', () => { it('mounts a ProseMirror view inside its shadow root', async () => { const el = await editorFixture(); expect(el.pmView).to.exist; expect(el.shadowRoot!.querySelector('.ProseMirror')).to.exist; }); it('round-trips value in and out', async () => { const el = await editorFixture(); el.value = '

hello world

'; await el.updateComplete; expect(el.getHTML()).to.equal('

hello world

'); }); it('sanitizes hostile content set via value', async () => { const el = await editorFixture(); el.value = '

ok

click

'; await el.updateComplete; expect(el.getHTML()).to.not.include('script'); expect(el.getHTML()).to.not.include('onclick'); }); it('emits nile-input on document changes', async () => { const el = await editorFixture(); const view = el.pmView!; setTimeout(() => view.dispatch(view.state.tr.insertText('typed', 1))); const event = await oneEvent(el, 'nile-input'); expect(event.detail.value).to.equal('

typed

'); expect(event.detail.content).to.equal('

typed

'); expect(el.value).to.equal('

typed

'); }); it('emits a debounced nile-change', async () => { const el = await editorFixture(); const view = el.pmView!; setTimeout(() => view.dispatch(view.state.tr.insertText('x', 1))); const event = await oneEvent(el, 'nile-change'); expect(event.detail.value).to.equal('

x

'); }); it('applies commands via execCommand', async () => { const el = await editorFixture(); el.value = '

format me

'; await el.updateComplete; selectAll(el); expect(el.execCommand('bold')).to.equal(true); expect(el.getHTML()).to.equal('

format me

'); expect(el.isCommandActive('bold')).to.equal(true); }); it('insertHTML inserts sanitized content at the selection', async () => { const el = await editorFixture(); el.insertHTML('safe'); expect(el.getHTML()).to.equal('

safe

'); }); it('respects disabled and readonly', async () => { const el = await editorFixture( html`` ); const content = el.shadowRoot!.querySelector('.ProseMirror')!; expect(content.getAttribute('contenteditable')).to.equal('false'); el.disabled = false; el.readonly = true; await el.updateComplete; expect(content.getAttribute('contenteditable')).to.equal('false'); // Readonly hides the toolbar. expect(el.shadowRoot!.querySelector('nile-wysiwyg-toolbar')).to.not.exist; el.readonly = false; await el.updateComplete; expect(content.getAttribute('contenteditable')).to.equal('true'); }); it('hides the toolbar with toolbar="none"', async () => { const el = await editorFixture( html`` ); expect(el.shadowRoot!.querySelector('nile-wysiwyg-toolbar')).to.not.exist; }); it('renders placeholder decoration when empty', async () => { const el = await editorFixture( html`` ); const empty = el.shadowRoot!.querySelector('.nile-wysiwyg__empty'); expect(empty).to.exist; expect(empty!.getAttribute('data-placeholder')).to.equal('Type here'); }); it('keeps single-line documents to one paragraph', async () => { const el = await editorFixture( html`` ); el.value = '

one

two

three

'; await el.updateComplete; expect(el.pmView!.state.doc.childCount).to.equal(1); }); it('executes toolbar commands dispatched as wysiwyg-command events', async () => { const el = await editorFixture(); el.value = '

toolbar

'; await el.updateComplete; selectAll(el); const toolbar = el.shadowRoot!.querySelector('nile-wysiwyg-toolbar')!; toolbar.dispatchEvent( new CustomEvent('wysiwyg-command', { detail: { name: 'italic' }, bubbles: true, composed: true, }) ); expect(el.getHTML()).to.equal('

toolbar

'); }); it('participates in forms with name, required, and reset', async () => { const form = await fixture(html`
`); const el = form.querySelector('nile-wysiwyg-editor')!; await el.updateComplete; expect(el.checkValidity()).to.equal(false); el.setContent('

filled

'); expect(el.checkValidity()).to.equal(true); expect(new FormData(form).get('notes')).to.equal('

filled

'); form.reset(); await el.updateComplete; expect(el.getHTML()).to.equal(''); expect(el.checkValidity()).to.equal(false); }); it('uploads image files through uploadHandler with a placeholder', async () => { const el = await editorFixture(); let uploadedName = ''; el.uploadHandler = async file => { uploadedName = file.name; // Placeholder should be visible while the upload is pending. await new Promise(resolve => setTimeout(resolve, 30)); return { src: 'https://cdn.x.dev/uploaded.png', alt: 'uploaded' }; }; const file = new File(['x'], 'pic.png', { type: 'image/png' }); const pending = el.insertImageFile(file); await new Promise(resolve => setTimeout(resolve, 10)); expect( el.shadowRoot!.querySelector('.nile-wysiwyg__upload-placeholder'), 'placeholder while uploading' ).to.exist; await pending; expect(uploadedName).to.equal('pic.png'); expect(el.getHTML()).to.include('https://cdn.x.dev/uploaded.png'); expect(el.shadowRoot!.querySelector('.nile-wysiwyg__upload-placeholder')).to.not.exist; }); it('lets nile-image-upload-request listeners own the upload', async () => { const el = await editorFixture(); el.addEventListener('nile-image-upload-request', (e: Event) => { e.preventDefault(); (e as CustomEvent).detail.insert('https://custom.x.dev/own.png', 'mine'); }); await el.insertImageFile(new File(['x'], 'a.png', { type: 'image/png' })); expect(el.getHTML()).to.include('https://custom.x.dev/own.png'); expect(el.getHTML()).to.include('alt="mine"'); }); it('falls back to base64 embedding without a handler', async () => { const el = await editorFixture(); await el.insertImageFile(new File(['x'], 'a.png', { type: 'image/png' })); expect(el.getHTML()).to.include('data:image/png;base64'); }); it('emits nile-image-upload-error when the handler rejects', async () => { const el = await editorFixture(); el.uploadHandler = async () => { throw new Error('boom'); }; setTimeout(() => el.insertImageFile(new File(['x'], 'a.png', { type: 'image/png' }))); const event = await oneEvent(el, 'nile-image-upload-error'); expect(event.detail.error.message).to.equal('boom'); expect(el.getHTML()).to.equal(''); }); it('inserts tables from the table picker command event', async () => { const el = await editorFixture(); const toolbar = el.shadowRoot!.querySelector('nile-wysiwyg-toolbar')!; toolbar.dispatchEvent( new CustomEvent('wysiwyg-command', { detail: { name: 'insertTable', attrs: { rows: 2, cols: 3 } }, bubbles: true, composed: true, }) ); expect(el.getHTML()).to.include(' { const el = await editorFixture(); el.mentions = { '@': [ { key: 'u1', label: 'Ada' }, { key: 'u2', label: 'Alan' }, { key: 'u3', label: 'Grace' }, ], }; const view = el.pmView!; view.dispatch(view.state.tr.insertText('@a', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); expect(list.items.map(i => i.label)).to.deep.equal(['Ada', 'Alan', 'Grace']); view.dispatch(view.state.tr.insertText('d')); await el.updateComplete; expect(list.items.map(i => i.label)).to.deep.equal(['Ada']); setTimeout(() => list.selectCurrent()); const selected = await oneEvent(el, 'nile-mention-select'); expect(selected.detail).to.deep.include({ trigger: '@', key: 'u1', label: 'Ada' }); expect(el.getHTML()).to.include('data-mention-key="u1"'); expect(el.getHTML()).to.include('@Ada'); }); it('inserts the highlighted mention on Enter (keymap must not steal it)', async () => { const el = await editorFixture(); el.mentions = { '@': [ { key: 'u1', label: 'Ada' }, { key: 'u2', label: 'Alan' }, ], }; const view = el.pmView!; view.dispatch(view.state.tr.insertText('@a', 1)); await el.updateComplete; // Navigate with ArrowDown, then commit with Enter — real key events. const press = (key: string) => view.dom.dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) ); press('ArrowDown'); await el.updateComplete; press('Enter'); await el.updateComplete; expect(el.getHTML()).to.include('data-mention-key="u2"'); expect(el.getHTML()).to.include('@Alan'); // Enter must not have split the paragraph. expect(el.pmView!.state.doc.childCount).to.equal(1); }); it('applies the highlighted slash command on Enter', async () => { const el = await editorFixture(); const view = el.pmView!; view.dispatch(view.state.tr.insertText('/head', 1)); await el.updateComplete; view.dom.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) ); await el.updateComplete; expect(el.pmView!.state.doc.firstChild!.type.name).to.equal('heading'); expect(el.pmView!.state.doc.childCount).to.equal(1); }); it('dismisses the suggestion popup on Escape', async () => { const el = await editorFixture(); el.mentions = { '@': [{ key: 'u1', label: 'Ada' }] }; const view = el.pmView!; view.dispatch(view.state.tr.insertText('@a', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); view.dom.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) ); await el.updateComplete; expect(list.open).to.equal(false); // Enter now behaves normally again (splits the paragraph). view.dom.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) ); expect(el.pmView!.state.doc.childCount).to.equal(2); }); it('lets nile-mention-query listeners provide async items', async () => { const el = await editorFixture(); el.mentions = { '@': [] }; el.addEventListener('nile-mention-query', (e: Event) => { const { query, provide } = (e as CustomEvent).detail; provide([{ key: `srv-${query}`, label: `Server ${query}` }]); }); const view = el.pmView!; view.dispatch(view.state.tr.insertText('@bo', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); expect(list.items).to.deep.equal([{ key: 'srv-bo', label: 'Server bo' }]); }); it('opens slash commands at the start of a block and applies one', async () => { const el = await editorFixture(); const view = el.pmView!; view.dispatch(view.state.tr.insertText('/head', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); expect(list.items[0].label).to.equal('Heading 1'); list.selectCurrent(); await el.updateComplete; expect(el.pmView!.state.doc.firstChild!.type.name).to.equal('heading'); // The "/head" query text was consumed. expect(el.getText()).to.equal(''); }); it('honors no-slash-commands', async () => { const el = await editorFixture( html`` ); const view = el.pmView!; view.dispatch(view.state.tr.insertText('/head', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(false); }); it('applies markdown mark input rules while typing', async () => { const el = await editorFixture(); el.value = '

**bold*

'; await el.updateComplete; const view = el.pmView!; const end = view.state.doc.content.size - 1; view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); const handled = view.someProp('handleTextInput', f => f(view, end, end, '*', () => view.state.tr.insertText('*', end, end)) ); expect(handled).to.equal(true); expect(el.getHTML()).to.equal('

bold

'); }); it('finds and replaces through the public API', async () => { const el = await editorFixture(); el.value = '

red fox, red barn

'; await el.updateComplete; expect(el.find('red')).to.equal(2); el.replace('blue'); expect(el.getHTML()).to.include('blue fox, red barn'); el.replaceAll('green'); expect(el.getHTML()).to.include('blue fox, green barn'); }); it('toggles source view and re-sanitizes applied HTML', async () => { const el = await editorFixture(); el.value = '

original

'; await el.updateComplete; el.toggleSourceView(); await el.updateComplete; const source = el.shadowRoot!.querySelector('nile-wysiwyg-source-view')!; expect(source).to.exist; await source.updateComplete; expect(source.currentValue).to.equal('

original

'); // Simulate editing the textarea (its input event drives the draft). const textarea = source.shadowRoot!.querySelector('textarea')!; textarea.value = '

edited

'; textarea.dispatchEvent(new Event('input')); el.toggleSourceView(); await el.updateComplete; expect(el.shadowRoot!.querySelector('nile-wysiwyg-source-view')).to.not.exist; expect(el.getHTML()).to.equal('

edited

'); }); it('emits nile-word-count and supports getWordCount', async () => { const el = await editorFixture(); const view = el.pmView!; setTimeout(() => view.dispatch(view.state.tr.insertText('three little words', 1))); const event = await oneEvent(el, 'nile-word-count'); expect(event.detail).to.deep.equal({ words: 3, characters: 18 }); expect(el.getWordCount()).to.deep.equal({ words: 3, characters: 18 }); }); it('emits nile-autosave on the configured interval', async () => { const el = await editorFixture( html`` ); const view = el.pmView!; view.dispatch(view.state.tr.insertText('save me', 1)); const event = await oneEvent(el, 'nile-autosave'); expect(event.detail.value).to.equal('

save me

'); }); it('renders checklist items with working checkboxes', async () => { const el = await editorFixture(); el.value = '
  • buy milk

'; await el.updateComplete; const checkbox = el.shadowRoot!.querySelector( '.nile-wysiwyg__task-item input[type="checkbox"]' )!; expect(checkbox).to.exist; expect(checkbox.checked).to.equal(false); checkbox.checked = true; checkbox.dispatchEvent(new Event('change')); await el.updateComplete; expect(el.getHTML()).to.include('data-checked="true"'); }); it('opens the emoji picker on ":xx" and inserts the character', async () => { const el = await editorFixture(); const view = el.pmView!; view.dispatch(view.state.tr.insertText('great :fir', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); expect(list.items[0].key).to.equal('fire'); list.selectCurrent(); await el.updateComplete; expect(el.getText()).to.equal('great 🔥'); expect(list.open).to.equal(false); }); it('respects no-emoji', async () => { const el = await editorFixture(html``); const view = el.pmView!; view.dispatch(view.state.tr.insertText(':fir', 1)); await el.updateComplete; expect(el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!.open).to.equal(false); }); it('blocks typing past maxlength and shows the counter', async () => { const el = await editorFixture( html`` ); const view = el.pmView!; view.dispatch(view.state.tr.insertText('12345', 1)); await el.updateComplete; const counter = el.shadowRoot!.querySelector('.nile-wysiwyg__counter')!; expect(counter.textContent!.trim()).to.equal('5 / 5'); expect(counter.classList.contains('over')).to.equal(true); // Further input is rejected at the transaction level. view.someProp('handleTextInput', f => f(view, 6, 6, 'x', () => view.state.tr.insertText('x', 6, 6)) ); view.dispatch; // no-op; just read state expect(el.getText()).to.equal('12345'); }); it('offers snippets in the slash menu and inserts their HTML', async () => { const el = await editorFixture(); el.snippets = [ { key: 'sig', label: 'Signature', html: '

Regards,
Birat

' }, ]; const view = el.pmView!; view.dispatch(view.state.tr.insertText('/sig', 1)); await el.updateComplete; const list = el.shadowRoot!.querySelector('nile-wysiwyg-suggest-list')!; expect(list.open).to.equal(true); expect(list.items[0]).to.deep.include({ key: 'snippet:sig', label: 'Signature' }); list.selectCurrent(); await el.updateComplete; expect(el.getHTML()).to.include('Birat'); expect(el.getText()).to.not.include('/sig'); }); it('reflects sticky-toolbar for the sticky CSS hook', async () => { const el = await editorFixture( html`` ); expect(el.hasAttribute('sticky-toolbar')).to.equal(true); const toolbar = el.shadowRoot!.querySelector('nile-wysiwyg-toolbar')!; expect(getComputedStyle(toolbar).position).to.equal('sticky'); }); it('renders highlighted tokens inside code blocks', async () => { const el = await editorFixture(); el.value = '
const x = "hello"; // note
'; await el.updateComplete; const tokens = el.shadowRoot!.querySelectorAll('.ProseMirror pre [class*="hljs-"]'); expect(tokens.length).to.be.greaterThan(2); expect(el.shadowRoot!.querySelector('.hljs-string')!.textContent).to.equal('"hello"'); }); it('shows the language picker inside a code block and applies changes', async () => { const el = await editorFixture(); el.value = '
select 1 from dual
'; await el.updateComplete; const view = el.pmView!; view.dom.dispatchEvent(new FocusEvent('focus')); view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 3))); await el.updateComplete; const menu = el.shadowRoot!.querySelector('nile-wysiwyg-code-menu')!; expect(menu.open).to.equal(true); menu.dispatchEvent( new CustomEvent('wysiwyg-command', { detail: { name: 'setCodeBlockLanguage', attrs: { language: 'sql' } }, bubbles: true, composed: true, }) ); expect(el.getHTML()).to.include('data-language="sql"'); }); it('keeps the language picker open while it has focus', async () => { const el = await editorFixture(); el.value = '
select 1
'; await el.updateComplete; const view = el.pmView!; view.dom.dispatchEvent(new FocusEvent('focus')); view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2))); await el.updateComplete; const menu = el.shadowRoot!.querySelector('nile-wysiwyg-code-menu')!; expect(menu.open).to.equal(true); // Clicking the picker blurs the editor with focus moving into chrome — // the menu must stay open so the option list can show. view.dom.dispatchEvent(new FocusEvent('blur', { relatedTarget: menu })); await el.updateComplete; expect(menu.open).to.equal(true); // Blurring to outside the editor closes it. view.dom.dispatchEvent(new FocusEvent('blur')); await el.updateComplete; expect(menu.open).to.equal(false); }); it('inserts embeds through the panel flow', async () => { const el = await editorFixture(); const panel = el.shadowRoot!.querySelector('nile-wysiwyg-embed-panel')!; panel.dispatchEvent( new CustomEvent('wysiwyg-embed-apply', { detail: { src: 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', provider: 'youtube' }, bubbles: true, composed: true, }) ); expect(el.getHTML()).to.include('data-embed-provider="youtube"'); expect(el.shadowRoot!.querySelector('.nile-wysiwyg__embed iframe')).to.exist; }); it('makes embeds interactive once selected (click-to-select, then play)', async () => { const el = await editorFixture(); el.value = '

before

'; await el.updateComplete; const iframe = el.shadowRoot!.querySelector('.nile-wysiwyg__embed iframe')!; expect(getComputedStyle(iframe).pointerEvents).to.equal('none'); // Select the embed node — the iframe becomes interactive. const view = el.pmView!; const { NodeSelection } = await import('prosemirror-state'); let embedPos = -1; view.state.doc.descendants((node, pos) => { if (node.type.name === 'embed' && embedPos < 0) embedPos = pos; return embedPos < 0; }); view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, embedPos))); await el.updateComplete; expect(getComputedStyle(iframe).pointerEvents).to.equal('auto'); }); it('turns pasted provider URLs into embeds', async () => { const el = await editorFixture(); const view = el.pmView!; const event = new ClipboardEvent('paste', { clipboardData: new DataTransfer() }); event.clipboardData!.setData('text/plain', 'https://vimeo.com/123456789'); const handled = view.someProp('handlePaste', f => f(view, event, view.state.tr.doc.slice(0))); expect(handled).to.equal(true); expect(el.getHTML()).to.include('player.vimeo.com/video/123456789'); }); it('strips hostile iframes set via value but keeps provider embeds', async () => { const el = await editorFixture(); el.value = '' + '
'; await el.updateComplete; const html = el.getHTML(); expect(html).to.not.include('evil.example.com'); expect(html).to.include('player.vimeo.com'); }); it('setContent accepts ProseMirror JSON', async () => { const el = await editorFixture(); el.setContent({ type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'from json' }], }, ], }); expect(el.getHTML()).to.equal('

from json

'); expect(el.getText()).to.equal('from json'); }); describe('export & import', () => { it('getInlineStyledHTML folds styles inline and drops classes', async () => { const el = await editorFixture(); el.setContent('

bold plain

'); await el.updateComplete; const out = el.getInlineStyledHTML(); expect(out).to.include(' { const el = await editorFixture(); expect(el.getInlineStyledHTML()).to.equal(''); }); it('exportToWord and exportToPdf are no-ops on an empty document', async () => { const el = await editorFixture(); let clicked = false; const orig = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function () { clicked = true; }; try { el.exportToWord(); el.exportInlineHtml(); } finally { HTMLAnchorElement.prototype.click = orig; } expect(clicked).to.be.false; }); it('exportToWord triggers a download for non-empty content', async () => { const el = await editorFixture(); el.setContent('

hello

'); await el.updateComplete; let downloadName = ''; const orig = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function () { downloadName = this.getAttribute('download') ?? ''; }; try { el.name = 'my-report'; el.exportToWord(); } finally { HTMLAnchorElement.prototype.click = orig; } expect(downloadName).to.equal('my-report.doc'); }); it('importFromWord normalizes Word list paragraphs into real lists', async () => { const el = await editorFixture(); const wordHtml = '

' + '1.First

' + '

' + '2.Second

'; el.importFromWord(wordHtml); await el.updateComplete; const out = el.getHTML(); expect(out).to.include(' { it('changeCase transforms the selection', async () => { const el = await editorFixture(); el.setContent('

hello world

'); await el.updateComplete; selectAll(el); el.changeCase('upper'); expect(el.getHTML()).to.equal('

HELLO WORLD

'); }); it('insertMergeField inserts a placeholder chip', async () => { const el = await editorFixture(); el.setContent('

Hi

'); await el.updateComplete; const view = el.pmView!; view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 4))); el.insertMergeField('first_name', 'First name'); expect(el.getHTML()).to.include('data-merge-field="first_name"'); }); it('insertMath inserts a math node with LaTeX', async () => { const el = await editorFixture(); el.setContent('

'); await el.updateComplete; const view = el.pmView!; view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1))); el.insertMath('E = mc^2', 'inline'); expect(el.getHTML()).to.include('data-math="E = mc^2"'); }); it('mathRenderer draws formulas as HTML in the view', async () => { const el = await editorFixture(); el.mathRenderer = (latex: string) => `${latex}`; el.setContent('

x^2

'); await el.updateComplete; expect(el.shadowRoot!.querySelector('.nile-wysiwyg__math .rendered')).to.exist; }); it('toggleFormatPainter arms and reflects in toolbar state', async () => { const el = await editorFixture(); el.setContent('

bold plain

'); await el.updateComplete; const view = el.pmView!; view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1, 5))); el.toggleFormatPainter(); // Selecting "plain" applies the copied bold and disarms. view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 6, 11))); await el.updateComplete; expect(el.getHTML()).to.equal('

bold plain

'); }); it('emits nile-autosave after the interval when content changed', async () => { const el = await editorFixture(); el.autosaveInterval = 30; await el.updateComplete; el.setContent('

changed

'); const event = await oneEvent(el, 'nile-autosave'); expect(event.detail.value).to.include('changed'); el.autosaveInterval = 0; }); }); });