Skip to content
8 changes: 8 additions & 0 deletions .playwright/helpers/visual-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,3 +53,10 @@ export async function setHtmlStyleOverride(
): Promise<void> {
await page.fill(visualRegressionSelectors.htmlStyleOverride, json);
}

export async function setTextShortcutsOverride(
page: Page,
json: string
): Promise<void> {
await page.fill(visualRegressionSelectors.textShortcutsOverride, json);
}
253 changes: 253 additions & 0 deletions .playwright/tests/textShortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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, '<html><p></p></html>');
await typeText(page, '- ');

await expect
.poll(async () => {
const html = await getSerializedHtml(page);
return /<ul/i.test(html) && /<li/i.test(html);
})
.toBe(true);
});

test('typing "1. " at paragraph start creates an ordered list', async ({
page,
}) => {
await setEditorHtml(page, '<html><p></p></html>');
await typeText(page, '1. ');

await expect
.poll(async () => {
const html = await getSerializedHtml(page);
return /<ol/i.test(html) && /<li/i.test(html);
})
.toBe(true);
});

test('typing "- " in the middle of text does not trigger list shortcut', async ({
page,
}) => {
await setEditorHtml(page, '<html><p></p></html>');
// 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(/<ul/i);
});

test('typing "- " inside an existing list does not create a new list', async ({
page,
}) => {
await setEditorHtml(page, '<html><ul><li>existing item</li></ul></html>');
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(/<ol/i);
});
});

test.describe('text shortcuts — paragraph (custom)', () => {
test.beforeEach(async ({ page }) => {
await gotoVisualRegression(page);
});

test('custom "# " shortcut converts to h1', async ({ page }) => {
await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]');
await setEditorHtml(page, '<html><p></p></html>');
await typeText(page, '# ');

await expect.poll(async () => getSerializedHtml(page)).toMatch(/<h1/i);
});

test('custom "> " shortcut converts to blockquote', async ({ page }) => {
await setTextShortcutsOverride(
page,
'[{"trigger":"> ","style":"blockquote"}]'
);
await setEditorHtml(page, '<html><p></p></html>');
await typeText(page, '> ');

await expect
.poll(async () => getSerializedHtml(page))
.toMatch(/<blockquote/i);
});

test('custom paragraph shortcut trigger text is removed from the output', async ({
page,
}) => {
await setTextShortcutsOverride(page, '[{"trigger":"# ","style":"h1"}]');
await setEditorHtml(page, '<html><p></p></html>');
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, '<html><p></p></html>');
await typeText(page, '- ');

const html = await getSerializedHtml(page);
expect(html).not.toMatch(/<ul/i);
expect(html).toMatch(/- /);
});
});

test.describe('text shortcuts — inline (custom)', () => {
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, '<html><p></p></html>');
await typeText(page, '*hello*');

await expect
.poll(async () => getSerializedHtml(page))
.toMatch(/<i>hello<\/i>/i);
});

test('double-char delimiter: "**hello**" applies bold', async ({ page }) => {
await setTextShortcutsOverride(page, '[{"trigger":"**","style":"bold"}]');
await setEditorHtml(page, '<html><p></p></html>');
await typeText(page, '**hello**');

await expect
.poll(async () => getSerializedHtml(page))
.toMatch(/<b>hello<\/b>/i);
});

test('backtick delimiter: "`hello`" applies inline code', async ({
page,
}) => {
await setTextShortcutsOverride(
page,
'[{"trigger":"`","style":"inline_code"}]'
);
await setEditorHtml(page, '<html><p></p></html>');
await typeText(page, '`hello`');

await expect
.poll(async () => getSerializedHtml(page))
.toMatch(/<code>hello<\/code>/i);
});

test('inline shortcut removes both delimiters from output', async ({
page,
}) => {
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
await setEditorHtml(page, '<html><p></p></html>');
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, '<html><p></p></html>');
await typeText(page, '**hello**');

const html = await getSerializedHtml(page);
// Should apply bold, NOT italic
expect(html).toMatch(/<b>hello<\/b>/i);
expect(html).not.toMatch(/<i>/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, '<html><p></p></html>');
await typeText(page, 'hello*');

const html = await getSerializedHtml(page);
expect(html).not.toMatch(/<i>/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, '<html><p></p></html>');

// 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 <b> tag
expect(html).not.toMatch(/<b>hello world<\/b>/i);
expect(html).toMatch(/<b>hello<\/b>/i);
});

test('text typed immediately after italic shortcut is not italic', async ({
page,
}) => {
await setTextShortcutsOverride(page, '[{"trigger":"*","style":"italic"}]');
await setEditorHtml(page, '<html><p></p></html>');

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(/<i>hello world<\/i>/i);
expect(html).toMatch(/<i>hello<\/i>/i);
});
});
24 changes: 24 additions & 0 deletions apps/example-web/src/testScreens/VisualRegression.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +36,7 @@ export function VisualRegression() {
);
const [editorHtml, setEditorHtml] = useState('');
const [htmlStyleOverrideJson, setHtmlStyleOverrideJson] = useState('');
const [textShortcutsJson, setTextShortcutsJson] = useState('');

const htmlStyle = useMemo<HtmlStyle>(() => {
const raw = htmlStyleOverrideJson.trim();
Expand All @@ -49,6 +51,17 @@ export function VisualRegression() {
}
}, [htmlStyleOverrideJson]);

const textShortcuts = useMemo<TextShortcut[] | undefined>(() => {
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);
};
Expand Down Expand Up @@ -77,6 +90,7 @@ export function VisualRegression() {
onChangeState={(e) => {
setEditorState(e.nativeEvent);
}}
textShortcuts={textShortcuts}
/>
</div>

Expand Down Expand Up @@ -105,6 +119,16 @@ export function VisualRegression() {
rows={3}
style={styles.htmlStyleOverrideInput}
/>
<textarea
data-testid="visual-regression-text-shortcuts"
value={textShortcutsJson}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setTextShortcutsJson(e.target.value);
}}
placeholder={'e.g. [{"trigger":"*","style":"italic"}]'}
rows={2}
style={styles.htmlStyleOverrideInput}
/>
<textarea
data-testid="visual-regression-html-input"
value={htmlInput}
Expand Down
6 changes: 3 additions & 3 deletions docs/INPUT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,9 @@ Default value:
];
```

| Type | Default Value | Platform |
| ---------------- | ------------- | -------- |
| `TextShortcut[]` | see above | Both |
| Type | Default Value | Platform |
| ---------------- | ------------- | ----------------- |
| `TextShortcut[]` | see above | iOS, Android, Web |

> [!NOTE]
> Pass an empty array to disable all shortcuts.
Expand Down
2 changes: 1 addition & 1 deletion docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Web support is still experimental. APIs and behavior can change in future releas
- Keyboard shortcuts for formatting
- `useHtmlNormalizer`
- Setting text alignment via `setTextAlignment()`
- `textShortcuts`

### Keyboard shortcuts

Expand All @@ -32,7 +33,6 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
- **Context menu**: `contextMenuItems` is ignored.
- **RN layout ref methods**: `measure`, `measureInWindow`, `measureLayout`, and `setNativeProps` are no-ops.
- **`ViewProps`**: Props inherited from `View` beyond the implemented subset are not forwarded.
- **`textShortcuts`**: ignored on web.

## Enriched Text

Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export type {
EnrichedTextHtmlStyle,
OnMentionPressEvent,
OnLinkPressEvent,
TextShortcut,
} from './types';
Loading
Loading