diff --git a/.playwright/helpers/clipboard.ts b/.playwright/helpers/clipboard.ts new file mode 100644 index 00000000..311189a4 --- /dev/null +++ b/.playwright/helpers/clipboard.ts @@ -0,0 +1,21 @@ +import type { Locator } from '@playwright/test'; + +export async function copySelectionFrom(locator: Locator): Promise { + await locator.click(); + await locator.click({ clickCount: 3 }); + await locator.page().keyboard.press('ControlOrMeta+C'); +} + +export async function pasteInto(locator: Locator): Promise { + await locator.click(); + await locator.click({ clickCount: 3 }); + await locator.page().keyboard.press('ControlOrMeta+V'); +} + +export async function copyAndPasteBetween( + source: Locator, + dest: Locator +): Promise { + await copySelectionFrom(source); + await pasteInto(dest); +} diff --git a/.playwright/helpers/toolbar.ts b/.playwright/helpers/toolbar.ts new file mode 100644 index 00000000..a428758a --- /dev/null +++ b/.playwright/helpers/toolbar.ts @@ -0,0 +1,5 @@ +import type { Page } from '@playwright/test'; + +export function toolbarButton(page: Page, id: string) { + return page.locator(`[data-testid="toolbar-button-${id}"]`); +} diff --git a/.playwright/helpers/visual-regression.ts b/.playwright/helpers/visual-regression.ts new file mode 100644 index 00000000..cac5144b --- /dev/null +++ b/.playwright/helpers/visual-regression.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export const visualRegressionSelectors = { + editor: '[data-testid="visual-regression-editor"]', + editorInner: '[data-testid="visual-regression-editor"] .eti-editor', + htmlInput: '[data-testid="visual-regression-html-input"]', + setValueButton: '[data-testid="visual-regression-set-value-button"]', + editorHtmlOutput: '[data-testid="visual-regression-editor-html-output"]', + htmlStyleOverride: '[data-testid="visual-regression-html-style-override"]', +} as const; + +export function editorLocator(page: Page): Locator { + return page.locator(visualRegressionSelectors.editorInner); +} + +export async function gotoVisualRegression(page: Page): Promise { + await page.goto('/visual-regression'); + await page.waitForSelector(visualRegressionSelectors.editorInner); +} + +export async function getSerializedHtml(page: Page): Promise { + return ( + (await page + .locator(visualRegressionSelectors.editorHtmlOutput) + .textContent()) ?? '' + ); +} + +export async function setEditorHtml(page: Page, html: string): Promise { + await page.fill(visualRegressionSelectors.htmlInput, html); + await page.click(visualRegressionSelectors.setValueButton); + await expect + .poll(async () => { + const t = await getSerializedHtml(page); + return t.startsWith(''); + }) + .toBe(true); +} + +export async function setHtmlStyleOverride( + page: Page, + json: string +): Promise { + await page.fill(visualRegressionSelectors.htmlStyleOverride, json); +} diff --git a/.playwright/screenshots/codeblock-inline-styles-paste-stripped.png b/.playwright/screenshots/codeblock-inline-styles-paste-stripped.png new file mode 100644 index 00000000..40090966 Binary files /dev/null and b/.playwright/screenshots/codeblock-inline-styles-paste-stripped.png differ diff --git a/.playwright/screenshots/codeblock-inline-styles-setvalue-stripped.png b/.playwright/screenshots/codeblock-inline-styles-setvalue-stripped.png new file mode 100644 index 00000000..56d2a1e6 Binary files /dev/null and b/.playwright/screenshots/codeblock-inline-styles-setvalue-stripped.png differ diff --git a/.playwright/screenshots/inline-styles.png b/.playwright/screenshots/inline-styles.png index 8d54eac8..ff7c2f69 100644 Binary files a/.playwright/screenshots/inline-styles.png and b/.playwright/screenshots/inline-styles.png differ diff --git a/.playwright/screenshots/paragraph-styles-visual-blockquote.png b/.playwright/screenshots/paragraph-styles-visual-blockquote.png new file mode 100644 index 00000000..7738d6ab Binary files /dev/null and b/.playwright/screenshots/paragraph-styles-visual-blockquote.png differ diff --git a/.playwright/screenshots/paragraph-styles-visual-codeblock.png b/.playwright/screenshots/paragraph-styles-visual-codeblock.png new file mode 100644 index 00000000..6e01e747 Binary files /dev/null and b/.playwright/screenshots/paragraph-styles-visual-codeblock.png differ diff --git a/.playwright/screenshots/paragraph-styles-visual-headings.png b/.playwright/screenshots/paragraph-styles-visual-headings.png new file mode 100644 index 00000000..abc8741a Binary files /dev/null and b/.playwright/screenshots/paragraph-styles-visual-headings.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-blockquote.png b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-blockquote.png new file mode 100644 index 00000000..a7e7db69 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-blockquote.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-codeblock.png b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-codeblock.png new file mode 100644 index 00000000..44e5e8a9 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-lift-codeblock.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-blockquote.png b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-blockquote.png new file mode 100644 index 00000000..6732d690 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-blockquote.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-codeblock.png b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-codeblock.png new file mode 100644 index 00000000..1ce18414 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-backspace-after-merge-codeblock.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-enter-blockquote.png b/.playwright/screenshots/wrapped-block-keyboard-enter-blockquote.png new file mode 100644 index 00000000..cb525768 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-enter-blockquote.png differ diff --git a/.playwright/screenshots/wrapped-block-keyboard-enter-codeblock.png b/.playwright/screenshots/wrapped-block-keyboard-enter-codeblock.png new file mode 100644 index 00000000..2cc8b9f9 Binary files /dev/null and b/.playwright/screenshots/wrapped-block-keyboard-enter-codeblock.png differ diff --git a/.playwright/tests/codeblockInlineStyles.spec.ts b/.playwright/tests/codeblockInlineStyles.spec.ts new file mode 100644 index 00000000..52dbee8b --- /dev/null +++ b/.playwright/tests/codeblockInlineStyles.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +import { copyAndPasteBetween } from '../helpers/clipboard'; +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +const INLINE_MARK_TAG = /<\s*(b|i|u|s|code)\b/i; + +function htmlInsideCodeblock(serialized: string): string { + const m = serialized.match(/]*>([\s\S]*?)<\/codeblock>/i); + return m ? m[1] : ''; +} + +test.describe('codeblock inline styles', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('inline toolbar controls are disabled inside code block', async ({ + page, + }) => { + await setEditorHtml( + page, + '

Inside code

' + ); + + await page.locator('.eti-editor codeblock p').click(); + + for (const key of [ + 'bold', + 'italic', + 'underline', + 'strikeThrough', + 'inlineCode', + ] as const) { + await expect(toolbarButton(page, key)).toBeDisabled(); + } + }); + + test('setValue strips inline styles inside code block', async ({ page }) => { + await setEditorHtml( + page, + '

bold italic underline strike inline

' + ); + + const html = await getSerializedHtml(page); + expect(htmlInsideCodeblock(html)).not.toMatch(INLINE_MARK_TAG); + + await expect(editorLocator(page)).toHaveScreenshot( + 'codeblock-inline-styles-setvalue-stripped.png' + ); + }); + + test('paste into code block strips copied inline styles', async ({ + page, + }) => { + await setEditorHtml( + page, + '

pasteMe

placeholder

' + ); + + await copyAndPasteBetween( + page.locator('.eti-editor p').first(), + page.locator('.eti-editor codeblock p') + ); + + const htmlAfterPaste = await getSerializedHtml(page); + expect(htmlInsideCodeblock(htmlAfterPaste)).not.toMatch(INLINE_MARK_TAG); + + await expect(editorLocator(page)).toHaveScreenshot( + 'codeblock-inline-styles-paste-stripped.png' + ); + }); +}); diff --git a/.playwright/tests/conflictingBlockStyles.spec.ts b/.playwright/tests/conflictingBlockStyles.spec.ts new file mode 100644 index 00000000..5afb2f2c --- /dev/null +++ b/.playwright/tests/conflictingBlockStyles.spec.ts @@ -0,0 +1,137 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { toolbarButton } from '../helpers/toolbar'; +import { + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; + +function hasOpeningTag(html: string, tag: string): boolean { + return new RegExp(`<${tag}(?:\\s|>)`, 'i').test(html); +} + +async function waitForOpeningTagInSerializedHtml( + page: Page, + tag: string +): Promise { + await expect + .poll(async () => hasOpeningTag(await getSerializedHtml(page), tag)) + .toBe(true); +} + +type ToolbarKey = 'h1' | 'h2' | 'h3' | 'blockQuote' | 'codeBlock'; + +const CASES: readonly { + name: string; + html: string; + focusSelector: string; + click: ToolbarKey; + expectTag: string; + notTags: readonly string[]; + expectText: string; +}[] = [ + { + name: 'h1 → blockquote', + html: '

Heading

', + focusSelector: '.eti-editor h1', + click: 'blockQuote', + expectTag: 'blockquote', + notTags: ['h1'], + expectText: 'Heading', + }, + { + name: 'h1 → codeblock', + html: '

Heading

', + focusSelector: '.eti-editor h1', + click: 'codeBlock', + expectTag: 'codeblock', + notTags: ['h1'], + expectText: 'Heading', + }, + { + name: 'h2 → blockquote', + html: '

Heading

', + focusSelector: '.eti-editor h2', + click: 'blockQuote', + expectTag: 'blockquote', + notTags: ['h2'], + expectText: 'Heading', + }, + { + name: 'blockquote → h1', + html: '

Quote

', + focusSelector: '.eti-editor blockquote p', + click: 'h1', + expectTag: 'h1', + notTags: ['blockquote'], + expectText: 'Quote', + }, + { + name: 'codeblock → blockquote', + html: '

Code

', + focusSelector: '.eti-editor codeblock p', + click: 'blockQuote', + expectTag: 'blockquote', + notTags: ['codeblock'], + expectText: 'Code', + }, + { + name: 'blockquote → codeblock', + html: '

Quote

', + focusSelector: '.eti-editor blockquote p', + click: 'codeBlock', + expectTag: 'codeblock', + notTags: ['blockquote'], + expectText: 'Quote', + }, + { + name: 'h1 → h2 (heading swap)', + html: '

Heading

', + focusSelector: '.eti-editor h1', + click: 'h2', + expectTag: 'h2', + notTags: ['h1'], + expectText: 'Heading', + }, + { + name: 'h3 → blockquote', + html: '

Heading

', + focusSelector: '.eti-editor h3', + click: 'blockQuote', + expectTag: 'blockquote', + notTags: ['h3'], + expectText: 'Heading', + }, +]; + +test.describe('conflicting block styles (toolbar replaces active block)', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { + name, + html, + focusSelector, + click: toolbarKey, + expectTag, + notTags, + expectText, + } of CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, html); + + await page.locator(focusSelector).click(); + await toolbarButton(page, toolbarKey).click(); + + await waitForOpeningTagInSerializedHtml(page, expectTag); + + const out = await getSerializedHtml(page); + for (const t of notTags) { + expect(hasOpeningTag(out, t)).toBe(false); + } + expect(out).toContain(expectText); + }); + } +}); diff --git a/.playwright/tests/headingBoldFromStyle.spec.ts b/.playwright/tests/headingBoldFromStyle.spec.ts new file mode 100644 index 00000000..0b7dd5f9 --- /dev/null +++ b/.playwright/tests/headingBoldFromStyle.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test'; + +import { copyAndPasteBetween } from '../helpers/clipboard'; +import { + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, + setHtmlStyleOverride, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function h1Inner(serialized: string): string | null { + const m = serialized.match(/]*>([\s\S]*?)<\/h1>/); + return m ? m[1] : null; +} + +const HTML_STYLE_H1_BOLD_TRUE = '{ "h1": { "bold": true } }'; +const HTML_STYLE_H1_BOLD_FALSE = '{ "h1": { "bold": false } }'; + +test.describe('h1 bold from htmlStyle (h1.bold true)', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + await setHtmlStyleOverride(page, HTML_STYLE_H1_BOLD_TRUE); + }); + + test('bold toolbar is disabled inside h1 when htmlStyle h1.bold is true', async ({ + page, + }) => { + await setEditorHtml(page, '

Heading

'); + + await page.locator('.eti-editor h1').click(); + + await expect(toolbarButton(page, 'bold')).toBeDisabled(); + }); + + test('setValue strips redundant inside h1', async ({ page }) => { + await setEditorHtml(page, '

Hello

'); + + const inner = h1Inner(await getSerializedHtml(page)); + expect(inner).not.toBeNull(); + expect(inner).not.toContain(''); + expect(inner).toContain('Hello'); + }); + + test('paste into h1 strips copied bold mark', async ({ page }) => { + await setEditorHtml( + page, + '

pasteMe

placeholder

' + ); + + await copyAndPasteBetween( + page.locator('.eti-editor p').first(), + page.locator('.eti-editor h1') + ); + + await expect + .poll(async () => { + const inner = h1Inner(await getSerializedHtml(page)); + return inner?.includes('pasteMe') ?? false; + }) + .toBe(true); + + const inner = h1Inner(await getSerializedHtml(page)); + expect(inner).not.toBeNull(); + expect(inner).not.toContain(''); + expect(inner).toContain('pasteMe'); + }); +}); + +test.describe('h1 bold from htmlStyle (h1.bold false)', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + await setHtmlStyleOverride(page, HTML_STYLE_H1_BOLD_FALSE); + }); + + test('bold toolbar is enabled and can be active inside h1 when htmlStyle h1.bold is false', async ({ + page, + }) => { + await setEditorHtml(page, '

Heading

'); + + await page.locator('.eti-editor h1').click(); + + const boldBtn = toolbarButton(page, 'bold'); + await expect(boldBtn).toBeEnabled(); + await boldBtn.click(); + await expect(boldBtn).toHaveClass(/toolbar-btn--active/); + }); + + test('setValue keeps inside h1 when htmlStyle h1.bold is false', async ({ + page, + }) => { + await setEditorHtml(page, '

Hello

'); + + const inner = h1Inner(await getSerializedHtml(page)); + expect(inner).not.toBeNull(); + expect(inner).toContain(''); + expect(inner).toContain('Hello'); + }); + + test('paste into h1 keeps copied bold mark when htmlStyle h1.bold is false', async ({ + page, + }) => { + await setEditorHtml( + page, + '

pasteMe

placeholder

' + ); + + await copyAndPasteBetween( + page.locator('.eti-editor p').first(), + page.locator('.eti-editor h1') + ); + + await expect + .poll(async () => { + const inner = h1Inner(await getSerializedHtml(page)); + return inner?.includes('pasteMe') ?? false; + }) + .toBe(true); + + const inner = h1Inner(await getSerializedHtml(page)); + expect(inner).not.toBeNull(); + expect(inner).toContain(''); + expect(inner).toContain('pasteMe'); + }); +}); diff --git a/.playwright/tests/inlineStyles.spec.ts b/.playwright/tests/inlineStyles.spec.ts index 4a96066d..4bf21dc9 100644 --- a/.playwright/tests/inlineStyles.spec.ts +++ b/.playwright/tests/inlineStyles.spec.ts @@ -1,9 +1,10 @@ import { test, expect } from '@playwright/test'; -const EDITOR = '[data-testid="visual-regression-editor"]'; -const EDITOR_INNER = `${EDITOR} .eti-editor`; -const HTML_INPUT = '[data-testid="visual-regression-html-input"]'; -const SET_VALUE_BTN = '[data-testid="visual-regression-set-value-button"]'; +import { + editorLocator, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; const ALL_INLINE_STYLES = [ '', @@ -17,14 +18,8 @@ const ALL_INLINE_STYLES = [ ].join(''); test('inline styles visual regression', async ({ page }) => { - await page.goto('/visual-regression'); - await page.waitForSelector(EDITOR_INNER); + await gotoVisualRegression(page); + await setEditorHtml(page, ALL_INLINE_STYLES); - await page.fill(HTML_INPUT, ALL_INLINE_STYLES); - await page.click(SET_VALUE_BTN); - await page.waitForTimeout(300); - - await expect(page.locator(EDITOR_INNER)).toHaveScreenshot( - 'inline-styles.png' - ); + await expect(editorLocator(page)).toHaveScreenshot('inline-styles.png'); }); diff --git a/.playwright/tests/paragraphStylesVisual.spec.ts b/.playwright/tests/paragraphStylesVisual.spec.ts new file mode 100644 index 00000000..15c72f6b --- /dev/null +++ b/.playwright/tests/paragraphStylesVisual.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +import { + editorLocator, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; + +test.describe('paragraph styles visual', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('headings h1–h6 display correctly', async ({ page }) => { + const html = + '

H1

H2

H3

H4

H5
H6
'; + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toContainText('H1'); + await expect(editor).toContainText('H6'); + + await expect(editor).toHaveScreenshot( + 'paragraph-styles-visual-headings.png' + ); + }); + + test('blockquote displays correctly', async ({ page }) => { + const html = + '

Blockquote smoke

'; + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toContainText('Blockquote smoke'); + + await expect(editor).toHaveScreenshot( + 'paragraph-styles-visual-blockquote.png' + ); + }); + + test('codeblock displays correctly', async ({ page }) => { + const html = '

Codeblock smoke

'; + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toContainText('Codeblock smoke'); + + await expect(editor).toHaveScreenshot( + 'paragraph-styles-visual-codeblock.png' + ); + }); +}); diff --git a/.playwright/tests/strictMarks.spec.ts b/.playwright/tests/strictMarks.spec.ts index 83075bc5..db334fe9 100644 --- a/.playwright/tests/strictMarks.spec.ts +++ b/.playwright/tests/strictMarks.spec.ts @@ -1,12 +1,14 @@ import { test, expect, type Page } from '@playwright/test'; -const EDITOR_INNER = '[data-testid="visual-regression-editor"] .eti-editor'; -const BOLD_TOOLBAR_BUTTON = '[data-testid="toolbar-button-bold"]'; -const INLINE_CODE_TOOLBAR_BUTTON = '[data-testid="toolbar-button-inlineCode"]'; +import { + editorLocator, + gotoVisualRegression, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; -async function typeBoldText(page: Page, text: string) { - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); - const editor = page.locator(EDITOR_INNER); +async function typeBoldText(page: Page, text: string): Promise { + const boldBtn = toolbarButton(page, 'bold'); + const editor = editorLocator(page); await editor.click(); await boldBtn.click(); @@ -23,10 +25,10 @@ async function typeBoldThenPlainText( ) { await typeBoldText(page, boldText); - const editor = page.locator(EDITOR_INNER); + const editor = editorLocator(page); await editor.click(); - await expect(page.locator(BOLD_TOOLBAR_BUTTON)).not.toHaveClass( + await expect(toolbarButton(page, 'bold')).not.toHaveClass( /toolbar-btn--active/ ); await editor.pressSequentially(plainText, { delay: 80 }); @@ -37,8 +39,8 @@ async function typeInlineCodeThenPlainText( codeText: string, plainText: string ) { - const inlineCodeBtn = page.locator(INLINE_CODE_TOOLBAR_BUTTON); - const editor = page.locator(EDITOR_INNER); + const inlineCodeBtn = toolbarButton(page, 'inlineCode'); + const editor = editorLocator(page); await editor.click(); await inlineCodeBtn.click(); @@ -51,14 +53,13 @@ async function typeInlineCodeThenPlainText( test.describe('strict marks', () => { test.beforeEach(async ({ page }) => { - await page.goto('/visual-regression'); - await page.waitForSelector(EDITOR_INNER); + await gotoVisualRegression(page); }); test('inline style deactivates after deleting inline-styled text char by char', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); + const editor = editorLocator(page); await typeBoldText(page, 'hello world'); @@ -68,20 +69,20 @@ test.describe('strict marks', () => { } await expect(editor).toHaveText(''); - await expect(page.locator(BOLD_TOOLBAR_BUTTON)).not.toHaveClass( + await expect(toolbarButton(page, 'bold')).not.toHaveClass( /toolbar-btn--active/ ); }); test('inline style deactivates after cmd+a and delete', async ({ page }) => { - const editor = page.locator(EDITOR_INNER); + const editor = editorLocator(page); await typeBoldText(page, 'hello world'); await editor.press('Meta+A'); await editor.press('Backspace'); - await expect(page.locator(BOLD_TOOLBAR_BUTTON)).not.toHaveClass( + await expect(toolbarButton(page, 'bold')).not.toHaveClass( /toolbar-btn--active/ ); }); @@ -89,8 +90,8 @@ test.describe('strict marks', () => { test('inline style is inactive at document start and typed text is plain', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await typeBoldText(page, 'hello'); @@ -104,8 +105,8 @@ test.describe('strict marks', () => { test('pressing Enter after the last inline code character keeps code when the rest of the line is plain', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const inlineCodeBtn = page.locator(INLINE_CODE_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const inlineCodeBtn = toolbarButton(page, 'inlineCode'); await typeInlineCodeThenPlainText(page, 'code', ' plain'); @@ -122,8 +123,8 @@ test.describe('strict marks', () => { test('pressing Enter in the middle of a styled segment carries style to the new line', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await editor.click(); await boldBtn.click(); @@ -143,8 +144,8 @@ test.describe('strict marks', () => { test('pressing Enter after the last bold character keeps bold when the rest of the line is plain', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await typeBoldThenPlainText(page, 'hello', ' something'); @@ -163,7 +164,7 @@ test.describe('strict marks', () => { test('inline style stays active at boundary between styled and plain text after deletion', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); + const editor = editorLocator(page); await typeBoldThenPlainText(page, 'hello', ' world'); @@ -175,7 +176,7 @@ test.describe('strict marks', () => { await editor.press('Backspace'); - await expect(page.locator(BOLD_TOOLBAR_BUTTON)).toHaveClass( + await expect(toolbarButton(page, 'bold')).toHaveClass( /toolbar-btn--active/ ); }); @@ -183,8 +184,8 @@ test.describe('strict marks', () => { test('inline code stays active at boundary between code and plain text after deletion', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const inlineCodeBtn = page.locator(INLINE_CODE_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const inlineCodeBtn = toolbarButton(page, 'inlineCode'); await typeInlineCodeThenPlainText(page, 'hello', ' world'); @@ -202,8 +203,8 @@ test.describe('strict marks', () => { test('explicit style survives multiple Enter and Backspace keystrokes on empty lines', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await editor.click(); await boldBtn.click(); @@ -224,8 +225,8 @@ test.describe('strict marks', () => { test('style clears when deleting the last character of a specific line', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await editor.click(); await editor.pressSequentially('Line 1'); @@ -247,8 +248,8 @@ test.describe('strict marks', () => { test('can toggle inline style off when cursor is inside styled text', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await typeBoldText(page, 'hello'); @@ -267,8 +268,8 @@ test.describe('strict marks', () => { test('style inherits from previous block when clearing a newly created line', async ({ page, }) => { - const editor = page.locator(EDITOR_INNER); - const boldBtn = page.locator(BOLD_TOOLBAR_BUTTON); + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); await editor.click(); await boldBtn.click(); diff --git a/.playwright/tests/wrappedBlockKeyboard.spec.ts b/.playwright/tests/wrappedBlockKeyboard.spec.ts new file mode 100644 index 00000000..9c8d6f51 --- /dev/null +++ b/.playwright/tests/wrappedBlockKeyboard.spec.ts @@ -0,0 +1,133 @@ +import { test, expect } from '@playwright/test'; + +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function countOpeningTag(html: string, tagName: string): number { + const re = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, 'gi'); + return (html.match(re) ?? []).length; +} + +const WRAPPED_BLOCKS = [ + { + label: 'blockquote', + tag: 'blockquote', + wrapperSelector: '.eti-editor blockquote', + toolbarTestId: 'blockQuote', + }, + { + label: 'codeblock', + tag: 'codeblock', + wrapperSelector: '.eti-editor codeblock', + toolbarTestId: 'codeBlock', + }, +] as const; + +for (const { label, tag, wrapperSelector, toolbarTestId } of WRAPPED_BLOCKS) { + test.describe(`wrapped block keyboard (${label})`, () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('Enter splits inside wrapper and keeps block active', async ({ + page, + }) => { + const editor = editorLocator(page); + const wrapper = page.locator(wrapperSelector); + const paragraphs = wrapper.locator('p'); + + await setEditorHtml(page, `<${tag}>

Line

`); + + await editor.click(); + await paragraphs.first().click(); + await editor.press('End'); + + const enters = 3; + for (let i = 0; i < enters; i++) { + await editor.press('Enter', { delay: 60 }); + } + + await page.waitForTimeout(200); + await expect(editor).toHaveScreenshot( + `wrapped-block-keyboard-enter-${label}.png` + ); + }); + + test('Backspace at line start lifts paragraph then merges backward', async ({ + page, + }) => { + const editor = editorLocator(page); + const wrapper = page.locator(wrapperSelector); + const paragraphsInWrapper = wrapper.locator('p'); + + await setEditorHtml( + page, + `<${tag}>

first

second

` + ); + + const secondP = paragraphsInWrapper.nth(1); + await secondP.click(); + await editor.press('End'); + for (let i = 0; i < 'second'.length; i++) { + await editor.press('ArrowLeft', { delay: 60 }); + } + + await editor.press('Backspace', { delay: 60 }); + + await page.waitForTimeout(200); + await expect(editor).toHaveScreenshot( + `wrapped-block-keyboard-backspace-after-lift-${label}.png` + ); + + await editor.press('Backspace', { delay: 60 }); + + await page.waitForTimeout(200); + await expect(editor).toHaveScreenshot( + `wrapped-block-keyboard-backspace-after-merge-${label}.png` + ); + }); + + test('heading round-trip on middle line merges to single wrapper in HTML', async ({ + page, + }) => { + const editor = editorLocator(page); + const toolbarBtn = toolbarButton(page, toolbarTestId); + const wrapper = page.locator(wrapperSelector); + const h1Btn = toolbarButton(page, 'h1'); + + await setEditorHtml( + page, + `<${tag}>

line1

line2

line3

` + ); + + await wrapper.locator('p').nth(1).click(); + await expect(toolbarBtn).toHaveClass(/toolbar-btn--active/); + + await h1Btn.click(); + + await expect + .poll(async () => (await getSerializedHtml(page)).includes(' countOpeningTag(await getSerializedHtml(page), tag)) + .toBe(1); + + const out = await getSerializedHtml(page); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + expect(out).toContain('line3'); + + await editor.click(); + await wrapper.locator('p').first().click(); + await expect(toolbarBtn).toHaveClass(/toolbar-btn--active/); + }); + }); +} diff --git a/apps/example-web/src/App.css b/apps/example-web/src/App.css index 9a81382e..ae8ef4a1 100644 --- a/apps/example-web/src/App.css +++ b/apps/example-web/src/App.css @@ -21,7 +21,7 @@ body { } .container { - max-width: 720px; + max-width: 760px; margin: 0 auto; padding: 60px 16px 40px; display: flex; diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 9c23eb46..fb6c3311 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -9,8 +9,8 @@ import { type FocusEvent, type BlurEvent, type EnrichedInputStyle, - type HtmlStyle, } from 'react-native-enriched'; +import { defaultHtmlStyle } from './defaultHtmlStyle'; import type { NativeSyntheticEvent } from 'react-native'; import { EditorActions } from './components/EditorActions'; import { SetValueModal } from './components/SetValueModal'; @@ -78,7 +78,7 @@ function App() { onChangeSelection={handleChangeSelection} onChangeHtml={handleOnChangeHtml} onChangeState={handleChangeState} - htmlStyle={htmlStyle} + htmlStyle={defaultHtmlStyle} /> @@ -129,11 +129,4 @@ const enrichedInputStyle: EnrichedInputStyle = { fontSize: 18, }; -const htmlStyle: HtmlStyle = { - code: { - color: 'purple', - backgroundColor: 'yellow', - }, -}; - export default App; diff --git a/apps/example-web/src/components/Toolbar.css b/apps/example-web/src/components/Toolbar.css index fe78cbef..b8db3eaa 100644 --- a/apps/example-web/src/components/Toolbar.css +++ b/apps/example-web/src/components/Toolbar.css @@ -10,9 +10,12 @@ display: flex; align-items: center; flex-wrap: nowrap; + min-width: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; + -webkit-overflow-scrolling: touch; + touch-action: pan-x; } .toolbar-fill { @@ -33,6 +36,8 @@ justify-content: center; cursor: pointer; color: #fff; + touch-action: pan-x; + user-select: none; } .toolbar-btn--active { diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index d4db551b..ead1156e 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -29,11 +29,17 @@ function ToolbarButton({ }: ToolbarButtonProps) { return (