Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .playwright/helpers/visual-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export function editorLocator(page: Page): Locator {
return page.locator(visualRegressionSelectors.editorInner);
}

export async function focusEnrichedEditable(page: Page): Promise<Locator> {
const editor = editorLocator(page);
await editor.click();
await expect(
editor.locator('[contenteditable="true"]').first()
).toBeFocused();
return editor;
}

export async function gotoVisualRegression(page: Page): Promise<void> {
await page.goto('/visual-regression');
await page.waitForSelector(visualRegressionSelectors.editorInner);
Expand Down
7 changes: 2 additions & 5 deletions .playwright/tests/images.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test, expect } from '@playwright/test';
import { toolbarButton } from '../helpers/toolbar';
import {
editorLocator,
focusEnrichedEditable,
getSerializedHtml,
gotoVisualRegression,
setEditorHtml,
Expand Down Expand Up @@ -166,12 +167,8 @@ test.describe('images', () => {
timeout: VISIBILITY_TIMEOUT_MS,
});

const editor = editorLocator(page);
for (const key of toolbarOrder) {
await editor.click();
await expect(
editor.locator('[contenteditable="true"]').first()
).toBeFocused();
const editor = await focusEnrichedEditable(page);
await editor.press('Meta+A');
await toolbarButton(page, key).click();
await expect
Expand Down
79 changes: 72 additions & 7 deletions .playwright/tests/inputShortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, expect, type Page } from '@playwright/test';

import {
editorLocator,
focusEnrichedEditable,
getSerializedHtml,
gotoVisualRegression,
setEditorHtml,
Expand All @@ -10,15 +10,80 @@ import {
const TYPE_SHORTCUT_DELAY_MS = 80;

async function typeShortcut(page: Page, text: string) {
const editor = editorLocator(page);
await editor.click();
await expect(
editor.locator('[contenteditable="true"]').first()
).toBeFocused();

const editor = await focusEnrichedEditable(page);
await editor.pressSequentially(text, { delay: TYPE_SHORTCUT_DELAY_MS });
}

test.describe('keyboard shortcuts (Slack/Docs chords)', () => {
test.beforeEach(async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoVisualRegression(page);
});

test('bold: Cmd/Ctrl+B wraps selection', async ({ page }) => {
await setEditorHtml(page, '<html><p>hi</p></html>');
await focusEnrichedEditable(page);
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('ControlOrMeta+KeyB');
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<b[\s>]/i);
});

test('italic: Cmd/Ctrl+I wraps selection', async ({ page }) => {
await setEditorHtml(page, '<html><p>hi</p></html>');
await focusEnrichedEditable(page);
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('ControlOrMeta+KeyI');
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<i[\s>]/i);
});

test('heading: Cmd/Ctrl+Alt+Digit2 sets h2', async ({ page }) => {
await setEditorHtml(page, '<html><p>x</p></html>');
await focusEnrichedEditable(page);
await page.keyboard.press('ControlOrMeta+Alt+Digit2');
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<h2[\s>]/i);
});

test('bulleted list: Cmd/Ctrl+Shift+Digit8', async ({ page }) => {
await setEditorHtml(page, '<html><p></p></html>');
await focusEnrichedEditable(page);
await page.keyboard.press('ControlOrMeta+Shift+Digit8');
await expect
.poll(async () => {
const html = await getSerializedHtml(page);
return /<ul/i.test(html) && /<li/i.test(html);
})
.toBe(true);
});

test('paste plain: Cmd/Ctrl+Shift+V inserts text/plain only', async ({
page,
}) => {
await setEditorHtml(page, '<html><p></p></html>');
await focusEnrichedEditable(page);

await page.evaluate(async () => {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob(['PLAIN_PASTE'], { type: 'text/plain' }),
'text/html': new Blob(['<strong>HTML_STRONG</strong>'], {
type: 'text/html',
}),
}),
]);
});

await page.keyboard.press('ControlOrMeta+Shift+KeyV');

await expect
.poll(async () => getSerializedHtml(page))
.toMatch(/PLAIN_PASTE/);

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

test.describe('list input shortcuts', () => {
test.beforeEach(async ({ page }) => {
await gotoVisualRegression(page);
Expand Down
19 changes: 19 additions & 0 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ Web support is still experimental. APIs and behavior can change in future releas
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
- Submit props: `submitBehavior` and `onSubmitEditing`. `returnKeyType` is only a hint, it maps to [enterkeyhint](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint) (`done`, `go`, `next`, `previous`, `search`, `send`, `default`/`enter`). Not all values of `ReturnKeyTypeOptions` are supported, the behavior of this prop is heavily dependent on the browser's capabilities.
- Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props
- Keyboard shortcuts for formatting

## Keyboard shortcuts

| Action | Mac | Windows/Linux |
| --- | --- | --- |
| Bold | ⌘ B | Ctrl+B |
| Italic | ⌘ I | Ctrl+I |
| Underline | ⌘ U | Ctrl+U |
| Strikethrough | ⌘ Shift+X | Ctrl+Shift+X |
| Inline code | ⌘ Shift+C | Ctrl+Shift+C |
| Code block | ⌘ Alt Shift+C | Ctrl+Alt+Shift+C |
| Normal paragraph | ⌘ Alt+0 | Ctrl+Alt+0 |
| Heading `n` (h1–h6) | ⌘ Alt+1 … ⌘ Alt+6 | Ctrl+Alt+1 … Ctrl+Alt+6 |
| Numbered list | ⌘ Shift+7 | Ctrl+Shift+7 |
| Bulleted list | ⌘ Shift+8 | Ctrl+Shift+8 |
| Checkbox list | ⌘ Shift+9 | Ctrl+Shift+9 |
| Paste plain text | ⌘ Shift+V | Ctrl+Shift+V |


## Unsupported

Expand Down
18 changes: 16 additions & 2 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ import {
} from './pmPlugins/mentionPlugin';

import { StripMarksOnImagePlugin } from './pmPlugins/stripMarksOnImagePlugin';
import { ShortcutPlugin } from './pmPlugins/shortcutPlugin';
import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';

function runFocused(
editor: Editor,
apply: (chain: ChainedCommands) => ChainedCommands
Expand Down Expand Up @@ -157,6 +157,14 @@ export const EnrichedTextInput = ({
[]
);

const shortcutPlugin = useMemo(
() =>
ShortcutPlugin.configure({
getHtmlStyle: () => htmlStyleRef.current,
}),
[]
);

const submitBehaviorRef = useRef(submitBehavior);
const onSubmitEditingRef = useRef(onSubmitEditing);
const onKeyPressRef = useRef(onKeyPress);
Expand Down Expand Up @@ -219,12 +227,18 @@ export const EnrichedTextInput = ({
MergeAdjacentSameKindBlocksPlugin,
StrictMarksPlugin,
mentionPlugin,
shortcutPlugin,
Placeholder.configure({
placeholder,
showOnlyWhenEditable: true,
}),
],
[stripBoldInStyledHeadingsPlugin, mentionPlugin, placeholder]
[
stripBoldInStyledHeadingsPlugin,
mentionPlugin,
shortcutPlugin,
placeholder,
]
);

const editor = useEditor(
Expand Down
98 changes: 98 additions & 0 deletions src/web/pmPlugins/shortcutPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Extension, type Editor } from '@tiptap/core';

import type { HtmlStyle } from '../../types';
import { isFormatBlocked } from '../formats/formatRules';

export interface ShortcutPluginOptions {
getHtmlStyle: () => Required<HtmlStyle>;
}

function insertPlainTextFromClipboard(editor: Editor): Promise<void> {
return navigator.clipboard.readText().then((text) => {
if (editor.isDestroyed) return;
editor.chain().focus().deleteSelection().insertContent(text).run();
});
}

export const ShortcutPlugin = Extension.create<ShortcutPluginOptions>({
name: 'shortcutPlugin',

addOptions() {
return {
getHtmlStyle: () => {
throw new Error(
'ShortcutPlugin.configure({ getHtmlStyle }) is required'
);
},
};
},

addKeyboardShortcuts() {
const htmlStyle = () => this.options.getHtmlStyle();

const mark =
(name: string, run: (editor: Editor) => boolean) =>
({ editor }: { editor: Editor }) => {
if (!editor.isEditable) return false;
if (isFormatBlocked(name, editor, htmlStyle())) return true;
return run(editor);
};

return {
'Mod-Shift-v': ({ editor }) => {
if (!editor.isEditable) return false;
insertPlainTextFromClipboard(editor).catch(() => {});
return true;
},
'Mod-Alt-Shift-c': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleCodeBlock();
},
'Mod-Shift-c': mark('code', (editor) => editor.commands.toggleCode()),
'Mod-Shift-x': mark('strike', (editor) => editor.commands.toggleStrike()),
'Mod-b': mark('bold', (editor) => editor.commands.toggleBold()),
'Mod-i': mark('italic', (editor) => editor.commands.toggleItalic()),
'Mod-u': mark('underline', (editor) => editor.commands.toggleUnderline()),
'Mod-Shift-7': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleOrderedList();
},
'Mod-Shift-8': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleUnorderedList();
},
'Mod-Shift-9': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleCheckboxList(false);
},
'Mod-Alt-0': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.setParagraph();
},
'Mod-Alt-1': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 1 });
},
'Mod-Alt-2': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 2 });
},
'Mod-Alt-3': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 3 });
},
'Mod-Alt-4': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 4 });
},
'Mod-Alt-5': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 5 });
},
'Mod-Alt-6': ({ editor }) => {
if (!editor.isEditable) return false;
return editor.commands.toggleHeading({ level: 6 });
},
};
},
});
Loading