diff --git a/.playwright/helpers/visual-regression.ts b/.playwright/helpers/visual-regression.ts index e949b3854..f3633dd0e 100644 --- a/.playwright/helpers/visual-regression.ts +++ b/.playwright/helpers/visual-regression.ts @@ -7,6 +7,7 @@ export const visualRegressionSelectors = { setValueButton: '[data-testid="visual-regression-set-value-button"]', editorHtmlOutput: '[data-testid="visual-regression-editor-html-output"]', htmlStyleOverride: '[data-testid="visual-regression-html-style-override"]', + textShortcutsOverride: '[data-testid="visual-regression-text-shortcuts"]', } as const; export function editorLocator(page: Page): Locator { @@ -52,3 +53,10 @@ export async function setHtmlStyleOverride( ): Promise { await page.fill(visualRegressionSelectors.htmlStyleOverride, json); } + +export async function setTextShortcutsOverride( + page: Page, + json: string +): Promise { + await page.fill(visualRegressionSelectors.textShortcutsOverride, json); +} diff --git a/.playwright/tests/textShortcuts.spec.ts b/.playwright/tests/textShortcuts.spec.ts new file mode 100644 index 000000000..a1f3a38c0 --- /dev/null +++ b/.playwright/tests/textShortcuts.spec.ts @@ -0,0 +1,253 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + focusEnrichedEditable, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, + setTextShortcutsOverride, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +const DELAY_MS = 80; + +async function typeText(page: Page, text: string): Promise { + const editor = await focusEnrichedEditable(page); + await editor.pressSequentially(text, { delay: DELAY_MS }); +} + +test.describe('text shortcuts — paragraph (default)', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('typing "- " at paragraph start creates an unordered list', async ({ + page, + }) => { + await setEditorHtml(page, '

'); + await typeText(page, '- '); + + await expect + .poll(async () => { + const html = await getSerializedHtml(page); + return /
    { + await setEditorHtml(page, '

    '); + await typeText(page, '1. '); + + await expect + .poll(async () => { + const html = await getSerializedHtml(page); + return /
      { + await setEditorHtml(page, '

      '); + // Type some text first so "- " is not at the paragraph start + await typeText(page, 'hello - '); + + await expect.poll(async () => getSerializedHtml(page)).toMatch(/hello - /); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/
        { + await setEditorHtml(page, '
        • existing item
        '); + const editor = await focusEnrichedEditable(page); + await editor.press('End'); + await editor.pressSequentially('1. ', { delay: DELAY_MS }); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/
          { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('custom "# " shortcut converts to h1', async ({ page }) => { + await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]'); + await setEditorHtml(page, '

          '); + await typeText(page, '# '); + + await expect.poll(async () => getSerializedHtml(page)).toMatch(/

          " shortcut converts to blockquote', async ({ page }) => { + await setTextShortcutsOverride( + page, + '[{"trigger":"> ","style":"blockquote"}]' + ); + await setEditorHtml(page, '

          '); + await typeText(page, '> '); + + await expect + .poll(async () => getSerializedHtml(page)) + .toMatch(/
          { + await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]'); + await setEditorHtml(page, '

          '); + await typeText(page, '# '); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/# /); + }); + + test('empty textShortcuts array disables all shortcuts', async ({ page }) => { + await setTextShortcutsOverride(page, '[]'); + await setEditorHtml(page, '

          '); + await typeText(page, '- '); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/
            { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('single-char delimiter: "*hello*" applies italic', async ({ page }) => { + await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]'); + await setEditorHtml(page, '

            '); + await typeText(page, '*hello*'); + + await expect + .poll(async () => getSerializedHtml(page)) + .toMatch(/hello<\/i>/i); + }); + + test('double-char delimiter: "**hello**" applies bold', async ({ page }) => { + await setTextShortcutsOverride(page, '[{"trigger":"**","style":"bold"}]'); + await setEditorHtml(page, '

            '); + await typeText(page, '**hello**'); + + await expect + .poll(async () => getSerializedHtml(page)) + .toMatch(/hello<\/b>/i); + }); + + test('backtick delimiter: "`hello`" applies inline code', async ({ + page, + }) => { + await setTextShortcutsOverride( + page, + '[{"trigger":"`","style":"inline_code"}]' + ); + await setEditorHtml(page, '

            '); + await typeText(page, '`hello`'); + + await expect + .poll(async () => getSerializedHtml(page)) + .toMatch(/hello<\/code>/i); + }); + + test('inline shortcut removes both delimiters from output', async ({ + page, + }) => { + await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]'); + await setEditorHtml(page, '

            '); + await typeText(page, '*hello*'); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/\*/); + }); + + test('longer trigger takes precedence: "**" does not trigger "*" shortcut', async ({ + page, + }) => { + await setTextShortcutsOverride( + page, + '[{"trigger":"*","style":"italic"},{"trigger":"**","style":"bold"}]' + ); + await setEditorHtml(page, '

            '); + await typeText(page, '**hello**'); + + const html = await getSerializedHtml(page); + // Should apply bold, NOT italic + expect(html).toMatch(/hello<\/b>/i); + expect(html).not.toMatch(//i); + }); + + test('inline shortcut does not fire when there is no matching opening delimiter', async ({ + page, + }) => { + await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]'); + await setEditorHtml(page, '

            '); + await typeText(page, 'hello*'); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(//i); + expect(html).toMatch(/hello\*/); + }); +}); + +test.describe('text shortcuts — mark clearing after inline shortcut', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('text typed immediately after bold shortcut is not bold', async ({ + page, + }) => { + await setTextShortcutsOverride(page, '[{"trigger":"**","style":"bold"}]'); + await setEditorHtml(page, '

            '); + + // Apply bold via shortcut + await typeText(page, '**hello**'); + + // Type more text right after — it should NOT be bold + const editor = await focusEnrichedEditable(page); + await editor.pressSequentially(' world', { delay: DELAY_MS }); + + await expect(toolbarButton(page, 'bold')).not.toHaveClass( + /toolbar-btn--active/ + ); + + const html = await getSerializedHtml(page); + // " world" must be outside the tag + expect(html).not.toMatch(/hello world<\/b>/i); + expect(html).toMatch(/hello<\/b>/i); + }); + + test('text typed immediately after italic shortcut is not italic', async ({ + page, + }) => { + await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]'); + await setEditorHtml(page, '

            '); + + await typeText(page, '*hello*'); + + const editor = await focusEnrichedEditable(page); + await editor.pressSequentially(' world', { delay: DELAY_MS }); + + await expect(toolbarButton(page, 'italic')).not.toHaveClass( + /toolbar-btn--active/ + ); + + const html = await getSerializedHtml(page); + expect(html).not.toMatch(/hello world<\/i>/i); + expect(html).toMatch(/hello<\/i>/i); + }); +}); diff --git a/apps/example-web/src/testScreens/VisualRegression.tsx b/apps/example-web/src/testScreens/VisualRegression.tsx index 91054e8fb..7d7e7fa09 100644 --- a/apps/example-web/src/testScreens/VisualRegression.tsx +++ b/apps/example-web/src/testScreens/VisualRegression.tsx @@ -5,6 +5,7 @@ import { type EnrichedTextInputInstance, type HtmlStyle, type OnChangeStateEvent, + type TextShortcut, } from 'react-native-enriched-html'; import { Toolbar } from '../components/Toolbar'; import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; @@ -35,6 +36,7 @@ export function VisualRegression() { ); const [editorHtml, setEditorHtml] = useState(''); const [htmlStyleOverrideJson, setHtmlStyleOverrideJson] = useState(''); + const [textShortcutsJson, setTextShortcutsJson] = useState(''); const htmlStyle = useMemo(() => { const raw = htmlStyleOverrideJson.trim(); @@ -49,6 +51,17 @@ export function VisualRegression() { } }, [htmlStyleOverrideJson]); + const textShortcuts = useMemo(() => { + const raw = textShortcutsJson.trim(); + if (!raw) return undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(raw) as TextShortcut[]; + } catch { + return undefined; + } + }, [textShortcutsJson]); + const handleSetValue = () => { ref.current?.setValue(htmlInput); }; @@ -77,6 +90,7 @@ export function VisualRegression() { onChangeState={(e) => { setEditorState(e.nativeEvent); }} + textShortcuts={textShortcuts} /> @@ -105,6 +119,16 @@ export function VisualRegression() { rows={3} style={styles.htmlStyleOverrideInput} /> +