From de5fcf5c2b777987df8020ddeb876bf17c367eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Tue, 30 Jun 2026 09:22:04 +0200 Subject: [PATCH 1/6] feat(web): add textShortcuts support --- src/web/EnrichedTextInput.tsx | 11 + src/web/formats/EnrichedOrderedList.ts | 12 - src/web/formats/EnrichedUnorderedList.ts | 13 +- src/web/pmPlugins/TextShortcutsPlugin.ts | 327 +++++++++++++++++++++++ 4 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 src/web/pmPlugins/TextShortcutsPlugin.ts diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 3f7e35281..0a98e1cb0 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -74,6 +74,7 @@ import { } from './pmPlugins/MentionPlugin'; import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin'; import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin'; +import { TextShortcutsPlugin } from './pmPlugins/TextShortcutsPlugin'; import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint'; import { AutolinkPlugin } from './pmPlugins/AutolinkPlugin'; @@ -116,6 +117,7 @@ export const EnrichedTextInput = ({ linkRegex, htmlStyle, useHtmlNormalizer, + textShortcuts = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.textShortcuts, }: EnrichedTextInputProps) => { const tiptapContent = defaultValue != null @@ -142,6 +144,11 @@ export const EnrichedTextInput = ({ mentionIndicatorsRef.current = mentionIndicators; }, [mentionIndicators]); + const textShortcutsRef = useRef(textShortcuts); + useEffect(() => { + textShortcutsRef.current = textShortcuts; + }, [textShortcuts]); + const mentionCallbacksRef = useRef({ onStartMention, onChangeMention, @@ -244,6 +251,10 @@ export const EnrichedTextInput = ({ ShortcutPlugin.configure({ getHtmlStyle: () => htmlStyleRef.current, }), + TextShortcutsPlugin.configure({ + getTextShortcuts: () => textShortcutsRef.current, + getHtmlStyle: () => htmlStyleRef.current, + }), AutolinkPlugin.configure({ getLinkEmitter: () => linkEmitterRef.current, }), diff --git a/src/web/formats/EnrichedOrderedList.ts b/src/web/formats/EnrichedOrderedList.ts index e438a3199..450be3794 100644 --- a/src/web/formats/EnrichedOrderedList.ts +++ b/src/web/formats/EnrichedOrderedList.ts @@ -1,20 +1,8 @@ -import { wrappingInputRule } from '@tiptap/core'; import { OrderedList } from '@tiptap/extension-list'; import { applyWrappingListToSelection } from './applyWrappingListToSelection'; -const ORDERED_LIST_INPUT_REGEX = /^1\.\s$/; - export const EnrichedOrderedList = OrderedList.extend({ - addInputRules() { - return [ - wrappingInputRule({ - find: ORDERED_LIST_INPUT_REGEX, - type: this.type, - }), - ]; - }, - addKeyboardShortcuts() { return {}; }, diff --git a/src/web/formats/EnrichedUnorderedList.ts b/src/web/formats/EnrichedUnorderedList.ts index 1b12d2796..4d8818e13 100644 --- a/src/web/formats/EnrichedUnorderedList.ts +++ b/src/web/formats/EnrichedUnorderedList.ts @@ -1,4 +1,4 @@ -import { wrappingInputRule, type CommandProps } from '@tiptap/core'; +import { type CommandProps } from '@tiptap/core'; import { BulletList } from '@tiptap/extension-list'; import { applyWrappingListToSelection } from './applyWrappingListToSelection'; @@ -11,20 +11,9 @@ declare module '@tiptap/core' { } } -const BULLET_LIST_INPUT_REGEX = /^\s*-\s$/; - export const EnrichedUnorderedList = BulletList.extend({ name: 'unorderedList', - addInputRules() { - return [ - wrappingInputRule({ - find: BULLET_LIST_INPUT_REGEX, - type: this.type, - }), - ]; - }, - addKeyboardShortcuts() { return {}; }, diff --git a/src/web/pmPlugins/TextShortcutsPlugin.ts b/src/web/pmPlugins/TextShortcutsPlugin.ts new file mode 100644 index 000000000..29fe31a8a --- /dev/null +++ b/src/web/pmPlugins/TextShortcutsPlugin.ts @@ -0,0 +1,327 @@ +import { Extension, type Editor } from '@tiptap/core'; +import type { MarkType, Node } from '@tiptap/pm/model'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import type { TextShortcut, TextShortcutStyle, HtmlStyle } from '../../types'; +import { + isAnyParagraphFormatActive, + isFormatBlocked, +} from '../formats/formatRules'; + +export interface TextShortcutsPluginOptions { + getTextShortcuts: () => TextShortcut[]; + getHtmlStyle: () => Required; +} + +const INLINE_STYLES = new Set([ + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'inline_code', +]); + +// Maps TextShortcutStyle names to TipTap/ProseMirror mark names +const INLINE_MARK_NAME: Partial> = { + bold: 'bold', + italic: 'italic', + underline: 'underline', + strikethrough: 'strike', + inline_code: 'code', +}; + +function applyParagraphCommand(style: string, editor: Editor): boolean { + switch (style) { + case 'h1': + return editor.commands.toggleHeading({ level: 1 }); + case 'h2': + return editor.commands.toggleHeading({ level: 2 }); + case 'h3': + return editor.commands.toggleHeading({ level: 3 }); + case 'h4': + return editor.commands.toggleHeading({ level: 4 }); + case 'h5': + return editor.commands.toggleHeading({ level: 5 }); + case 'h6': + return editor.commands.toggleHeading({ level: 6 }); + case 'blockquote': + return editor.commands.toggleBlockquote(); + case 'codeblock': + return editor.commands.toggleCodeBlock(); + case 'unordered_list': + return editor.commands.toggleUnorderedList(); + case 'ordered_list': + return editor.commands.toggleOrderedList(); + case 'checkbox_list': + return editor.commands.toggleCheckboxList(false); + default: + return false; + } +} + +/** + * Returns the text content of the text block that + * contains the given document position, along with the absolute start + * position of that block's first character. + */ +function getBlockContext( + doc: Node, + pos: number +): { blockText: string; blockStart: number } | null { + const $pos = doc.resolve(pos); + + if (!$pos.parent.isTextblock) return null; + + return { + blockText: $pos.parent.textContent, + blockStart: $pos.start(), + }; +} + +/** + * Checks whether the opening delimiter found at paragraph-relative index + * [delimIdx] is actually part of a longer inline trigger (e.g. `*` inside + * `**`). + */ +function isDelimPartOfLongerTrigger( + trigger: string, + delimIdx: number, + blockText: string, + inlineShortcuts: TextShortcut[] +): boolean { + const delimEnd = delimIdx + trigger.length; + + return inlineShortcuts.some(({ trigger: longerTrigger }) => { + if (longerTrigger.length <= trigger.length) return false; + if (!longerTrigger.endsWith(trigger)) return false; + + const longerStart = delimEnd - longerTrigger.length; + + return ( + longerStart >= 0 && + blockText.slice(longerStart, delimEnd) === longerTrigger + ); + }); +} + +/** + * Handles a paragraph-level shortcut (e.g. `"- "` → bullet list, `"# "` → H1). + * + * Fires only when the trigger is anchored at the very start of the current + * text block and no paragraph style is already active on that block. + */ +function tryParagraphShortcut( + view: EditorView, + from: number, + text: string, + editor: Editor, + shortcuts: TextShortcut[], + htmlStyle: Required +): boolean { + if (isAnyParagraphFormatActive(editor)) return false; + + const ctx = getBlockContext(view.state.doc, from); + if (!ctx) return false; + + const { blockStart } = ctx; + const offsetInBlock = from - blockStart; + + for (const { trigger, style } of shortcuts) { + if (INLINE_STYLES.has(style)) continue; + if (!trigger) continue; + + const lastChar = trigger[trigger.length - 1]!; + if (text !== lastChar) continue; + + const prefixLen = trigger.length - 1; + + // Trigger must be anchored at paragraph start + if (offsetInBlock !== prefixLen) continue; + + // Verify the prefix characters already in the doc match the trigger prefix + if (prefixLen > 0) { + const docPrefix = view.state.doc.textBetween(blockStart, from); + if (docPrefix !== trigger.slice(0, prefixLen)) continue; + } + + if (isFormatBlocked(style, editor, htmlStyle)) continue; + + // Delete the prefix that is already in the doc (the last char - text - + // hasn't been inserted yet, so we only remove the prefix portion). + const { tr } = view.state; + if (prefixLen > 0) { + tr.delete(blockStart, from); + } + view.dispatch(tr); + + applyParagraphCommand(style, editor); + return true; + } + + return false; +} + +/** + * Handles an inline shortcut (e.g. `**text**` → bold). + * + * Inline shortcuts use symmetric delimiter pairs. When the closing delimiter + * is completed, we search backwards for a matching opening delimiter and apply + * the mark to the content between them, removing both delimiters. + */ +function tryInlineShortcut( + view: EditorView, + from: number, + to: number, + text: string, + editor: Editor, + shortcuts: TextShortcut[], + htmlStyle: Required +): boolean { + const ctx = getBlockContext(view.state.doc, from); + if (!ctx) return false; + + const { blockText, blockStart } = ctx; + const offsetInBlock = from - blockStart; + + // Sort inline shortcuts longest-first so `**` is not pre-empted by `*` + const inlineShortcuts = shortcuts + .filter( + ({ trigger, style }) => INLINE_STYLES.has(style) && trigger.length > 0 + ) + .sort((a, b) => b.trigger.length - a.trigger.length); + + for (const { trigger, style } of inlineShortcuts) { + const markName = INLINE_MARK_NAME[style]; + if (!markName) continue; + + const lastChar = trigger[trigger.length - 1]!; + if (text !== lastChar) continue; + + // Verify the characters before the cursor complete the closing delimiter + const prefixLen = trigger.length - 1; + if (offsetInBlock < prefixLen) continue; + + if (prefixLen > 0) { + const beforeCursor = blockText.slice( + offsetInBlock - prefixLen, + offsetInBlock + ); + if (beforeCursor !== trigger.slice(0, prefixLen)) continue; + } + + // Search backwards in the paragraph for an opening delimiter. + // Only search up to where the closing prefix begins + const searchIn = blockText.slice(0, offsetInBlock - prefixLen); + const openIdx = searchIn.lastIndexOf(trigger); + if (openIdx < 0) continue; + + if ( + isDelimPartOfLongerTrigger(trigger, openIdx, blockText, inlineShortcuts) + ) { + continue; + } + + const contentStart = openIdx + trigger.length; + const closeDelimPrefixStart = offsetInBlock - prefixLen; + + if (closeDelimPrefixStart <= contentStart) continue; + + if (isFormatBlocked(markName, editor, htmlStyle)) continue; + + const markType: MarkType | undefined = view.state.schema.marks[markName]; + if (!markType) continue; + + // Convert paragraph-relative indices to absolute doc positions + const openDocStart = blockStart + openIdx; + const contentDocStart = blockStart + contentStart; + const closeDelimPrefixDocStart = blockStart + closeDelimPrefixStart; + + const { tr } = view.state; + + // delete closing delimiter + tr.delete(closeDelimPrefixDocStart, to); + + // delete opening delimiter + tr.delete(openDocStart, openDocStart + trigger.length); + + // mark the content + const contentLength = closeDelimPrefixDocStart - contentDocStart; + const finalStart = openDocStart; + const finalEnd = openDocStart + contentLength; + tr.addMark(finalStart, finalEnd, markType.create()); + + // place cursor at end of content + tr.setSelection(TextSelection.create(tr.doc, finalEnd)); + + view.dispatch(tr); + view.dispatch(view.state.tr.setStoredMarks([])); + return true; + } + + return false; +} + +export const TextShortcutsPlugin = Extension.create( + { + name: 'textShortcutsPlugin', + + addOptions() { + return { + getTextShortcuts: () => [], + getHtmlStyle: () => { + throw new Error( + 'TextShortcutsPlugin.configure({ getHtmlStyle }) is required' + ); + }, + }; + }, + + addProseMirrorPlugins() { + const getTextShortcuts = () => this.options.getTextShortcuts(); + const getHtmlStyle = () => this.options.getHtmlStyle(); + const getEditor = () => this.editor; + + return [ + new Plugin({ + key: new PluginKey('textShortcuts'), + props: { + handleTextInput( + view: EditorView, + from: number, + to: number, + text: string + ): boolean { + if (!view.editable) return false; + + const shortcuts = getTextShortcuts(); + if (shortcuts.length === 0) return false; + + const editor = getEditor(); + const htmlStyle = getHtmlStyle(); + + return ( + tryParagraphShortcut( + view, + from, + text, + editor, + shortcuts, + htmlStyle + ) || + tryInlineShortcut( + view, + from, + to, + text, + editor, + shortcuts, + htmlStyle + ) + ); + }, + }, + }), + ]; + }, + } +); From 61b66e3ae047683aa5b0583ae9d6c1bd722a71e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Tue, 30 Jun 2026 10:54:06 +0200 Subject: [PATCH 2/6] test: add tests for textShortcuts --- .playwright/helpers/visual-regression.ts | 8 + .playwright/tests/textShortcuts.spec.ts | 253 ++++++++++++++++++ .../src/testScreens/VisualRegression.tsx | 24 ++ src/index.tsx | 1 + src/web/formats/EnrichedOrderedList.ts | 4 + src/web/formats/EnrichedUnorderedList.ts | 4 + 6 files changed, 294 insertions(+) create mode 100644 .playwright/tests/textShortcuts.spec.ts 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..ac7824f14 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} /> +