Skip to content

Commit 2f20578

Browse files
authored
feat(web): added shortcuts (#603)
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please follow the template so that the reviewers can easily understand what the code changes affect --> # Summary - added shortcuts for styles - shortcuts are inspired by Slack for bold, italic etc. and by google docs when it comes to headings, lists - I've verified on my Windows laptop that on it shortcuts also work - `WEB.md` contains a detailed list of all shortcuts ## Test Plan + Run `yarn test:e2e:web` to verify new e2e tests pass + Run `yarn example-web dev` and play around with shortcuts ## Screenshots / Videos N/A ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent 57decb3 commit 2f20578

6 files changed

Lines changed: 216 additions & 14 deletions

File tree

.playwright/helpers/visual-regression.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export function editorLocator(page: Page): Locator {
1313
return page.locator(visualRegressionSelectors.editorInner);
1414
}
1515

16+
export async function focusEnrichedEditable(page: Page): Promise<Locator> {
17+
const editor = editorLocator(page);
18+
await editor.click();
19+
await expect(
20+
editor.locator('[contenteditable="true"]').first()
21+
).toBeFocused();
22+
return editor;
23+
}
24+
1625
export async function gotoVisualRegression(page: Page): Promise<void> {
1726
await page.goto('/visual-regression');
1827
await page.waitForSelector(visualRegressionSelectors.editorInner);

.playwright/tests/images.spec.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { test, expect } from '@playwright/test';
33
import { toolbarButton } from '../helpers/toolbar';
44
import {
55
editorLocator,
6+
focusEnrichedEditable,
67
getSerializedHtml,
78
gotoVisualRegression,
89
setEditorHtml,
@@ -166,12 +167,8 @@ test.describe('images', () => {
166167
timeout: VISIBILITY_TIMEOUT_MS,
167168
});
168169

169-
const editor = editorLocator(page);
170170
for (const key of toolbarOrder) {
171-
await editor.click();
172-
await expect(
173-
editor.locator('[contenteditable="true"]').first()
174-
).toBeFocused();
171+
const editor = await focusEnrichedEditable(page);
175172
await editor.press('Meta+A');
176173
await toolbarButton(page, key).click();
177174
await expect

.playwright/tests/inputShortcuts.spec.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, expect, type Page } from '@playwright/test';
22

33
import {
4-
editorLocator,
4+
focusEnrichedEditable,
55
getSerializedHtml,
66
gotoVisualRegression,
77
setEditorHtml,
@@ -10,15 +10,80 @@ import {
1010
const TYPE_SHORTCUT_DELAY_MS = 80;
1111

1212
async function typeShortcut(page: Page, text: string) {
13-
const editor = editorLocator(page);
14-
await editor.click();
15-
await expect(
16-
editor.locator('[contenteditable="true"]').first()
17-
).toBeFocused();
18-
13+
const editor = await focusEnrichedEditable(page);
1914
await editor.pressSequentially(text, { delay: TYPE_SHORTCUT_DELAY_MS });
2015
}
2116

17+
test.describe('keyboard shortcuts (Slack/Docs chords)', () => {
18+
test.beforeEach(async ({ context, page }) => {
19+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
20+
await gotoVisualRegression(page);
21+
});
22+
23+
test('bold: Cmd/Ctrl+B wraps selection', async ({ page }) => {
24+
await setEditorHtml(page, '<html><p>hi</p></html>');
25+
await focusEnrichedEditable(page);
26+
await page.keyboard.press('ControlOrMeta+a');
27+
await page.keyboard.press('ControlOrMeta+KeyB');
28+
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<b[\s>]/i);
29+
});
30+
31+
test('italic: Cmd/Ctrl+I wraps selection', async ({ page }) => {
32+
await setEditorHtml(page, '<html><p>hi</p></html>');
33+
await focusEnrichedEditable(page);
34+
await page.keyboard.press('ControlOrMeta+a');
35+
await page.keyboard.press('ControlOrMeta+KeyI');
36+
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<i[\s>]/i);
37+
});
38+
39+
test('heading: Cmd/Ctrl+Alt+Digit2 sets h2', async ({ page }) => {
40+
await setEditorHtml(page, '<html><p>x</p></html>');
41+
await focusEnrichedEditable(page);
42+
await page.keyboard.press('ControlOrMeta+Alt+Digit2');
43+
await expect.poll(async () => getSerializedHtml(page)).toMatch(/<h2[\s>]/i);
44+
});
45+
46+
test('bulleted list: Cmd/Ctrl+Shift+Digit8', async ({ page }) => {
47+
await setEditorHtml(page, '<html><p></p></html>');
48+
await focusEnrichedEditable(page);
49+
await page.keyboard.press('ControlOrMeta+Shift+Digit8');
50+
await expect
51+
.poll(async () => {
52+
const html = await getSerializedHtml(page);
53+
return /<ul/i.test(html) && /<li/i.test(html);
54+
})
55+
.toBe(true);
56+
});
57+
58+
test('paste plain: Cmd/Ctrl+Shift+V inserts text/plain only', async ({
59+
page,
60+
}) => {
61+
await setEditorHtml(page, '<html><p></p></html>');
62+
await focusEnrichedEditable(page);
63+
64+
await page.evaluate(async () => {
65+
await navigator.clipboard.write([
66+
new ClipboardItem({
67+
'text/plain': new Blob(['PLAIN_PASTE'], { type: 'text/plain' }),
68+
'text/html': new Blob(['<strong>HTML_STRONG</strong>'], {
69+
type: 'text/html',
70+
}),
71+
}),
72+
]);
73+
});
74+
75+
await page.keyboard.press('ControlOrMeta+Shift+KeyV');
76+
77+
await expect
78+
.poll(async () => getSerializedHtml(page))
79+
.toMatch(/PLAIN_PASTE/);
80+
81+
const html = await getSerializedHtml(page);
82+
expect(html).not.toMatch(/HTML_STRONG/);
83+
expect(html).not.toMatch(/<strong/i);
84+
});
85+
});
86+
2287
test.describe('list input shortcuts', () => {
2388
test.beforeEach(async ({ page }) => {
2489
await gotoVisualRegression(page);

docs/WEB.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,25 @@ Web support is still experimental. APIs and behavior can change in future releas
1515
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
1616
- 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.
1717
- Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props
18+
- Keyboard shortcuts for formatting
19+
20+
## Keyboard shortcuts
21+
22+
| Action | Mac | Windows/Linux |
23+
| --- | --- | --- |
24+
| Bold | ⌘ B | Ctrl+B |
25+
| Italic | ⌘ I | Ctrl+I |
26+
| Underline | ⌘ U | Ctrl+U |
27+
| Strikethrough | ⌘ Shift+X | Ctrl+Shift+X |
28+
| Inline code | ⌘ Shift+C | Ctrl+Shift+C |
29+
| Code block | ⌘ Alt Shift+C | Ctrl+Alt+Shift+C |
30+
| Normal paragraph | ⌘ Alt+0 | Ctrl+Alt+0 |
31+
| Heading `n` (h1–h6) | ⌘ Alt+1 … ⌘ Alt+6 | Ctrl+Alt+1 … Ctrl+Alt+6 |
32+
| Numbered list | ⌘ Shift+7 | Ctrl+Shift+7 |
33+
| Bulleted list | ⌘ Shift+8 | Ctrl+Shift+8 |
34+
| Checkbox list | ⌘ Shift+9 | Ctrl+Shift+9 |
35+
| Paste plain text | ⌘ Shift+V | Ctrl+Shift+V |
36+
1837

1938
## Unsupported
2039

src/web/EnrichedTextInput.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ import {
7171
} from './pmPlugins/mentionPlugin';
7272

7373
import { StripMarksOnImagePlugin } from './pmPlugins/stripMarksOnImagePlugin';
74+
import { ShortcutPlugin } from './pmPlugins/shortcutPlugin';
7475
import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
75-
7676
function runFocused(
7777
editor: Editor,
7878
apply: (chain: ChainedCommands) => ChainedCommands
@@ -157,6 +157,14 @@ export const EnrichedTextInput = ({
157157
[]
158158
);
159159

160+
const shortcutPlugin = useMemo(
161+
() =>
162+
ShortcutPlugin.configure({
163+
getHtmlStyle: () => htmlStyleRef.current,
164+
}),
165+
[]
166+
);
167+
160168
const submitBehaviorRef = useRef(submitBehavior);
161169
const onSubmitEditingRef = useRef(onSubmitEditing);
162170
const onKeyPressRef = useRef(onKeyPress);
@@ -219,12 +227,18 @@ export const EnrichedTextInput = ({
219227
MergeAdjacentSameKindBlocksPlugin,
220228
StrictMarksPlugin,
221229
mentionPlugin,
230+
shortcutPlugin,
222231
Placeholder.configure({
223232
placeholder,
224233
showOnlyWhenEditable: true,
225234
}),
226235
],
227-
[stripBoldInStyledHeadingsPlugin, mentionPlugin, placeholder]
236+
[
237+
stripBoldInStyledHeadingsPlugin,
238+
mentionPlugin,
239+
shortcutPlugin,
240+
placeholder,
241+
]
228242
);
229243

230244
const editor = useEditor(
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Extension, type Editor } from '@tiptap/core';
2+
3+
import type { HtmlStyle } from '../../types';
4+
import { isFormatBlocked } from '../formats/formatRules';
5+
6+
export interface ShortcutPluginOptions {
7+
getHtmlStyle: () => Required<HtmlStyle>;
8+
}
9+
10+
function insertPlainTextFromClipboard(editor: Editor): Promise<void> {
11+
return navigator.clipboard.readText().then((text) => {
12+
if (editor.isDestroyed) return;
13+
editor.chain().focus().deleteSelection().insertContent(text).run();
14+
});
15+
}
16+
17+
export const ShortcutPlugin = Extension.create<ShortcutPluginOptions>({
18+
name: 'shortcutPlugin',
19+
20+
addOptions() {
21+
return {
22+
getHtmlStyle: () => {
23+
throw new Error(
24+
'ShortcutPlugin.configure({ getHtmlStyle }) is required'
25+
);
26+
},
27+
};
28+
},
29+
30+
addKeyboardShortcuts() {
31+
const htmlStyle = () => this.options.getHtmlStyle();
32+
33+
const mark =
34+
(name: string, run: (editor: Editor) => boolean) =>
35+
({ editor }: { editor: Editor }) => {
36+
if (!editor.isEditable) return false;
37+
if (isFormatBlocked(name, editor, htmlStyle())) return true;
38+
return run(editor);
39+
};
40+
41+
return {
42+
'Mod-Shift-v': ({ editor }) => {
43+
if (!editor.isEditable) return false;
44+
insertPlainTextFromClipboard(editor).catch(() => {});
45+
return true;
46+
},
47+
'Mod-Alt-Shift-c': ({ editor }) => {
48+
if (!editor.isEditable) return false;
49+
return editor.commands.toggleCodeBlock();
50+
},
51+
'Mod-Shift-c': mark('code', (editor) => editor.commands.toggleCode()),
52+
'Mod-Shift-x': mark('strike', (editor) => editor.commands.toggleStrike()),
53+
'Mod-b': mark('bold', (editor) => editor.commands.toggleBold()),
54+
'Mod-i': mark('italic', (editor) => editor.commands.toggleItalic()),
55+
'Mod-u': mark('underline', (editor) => editor.commands.toggleUnderline()),
56+
'Mod-Shift-7': ({ editor }) => {
57+
if (!editor.isEditable) return false;
58+
return editor.commands.toggleOrderedList();
59+
},
60+
'Mod-Shift-8': ({ editor }) => {
61+
if (!editor.isEditable) return false;
62+
return editor.commands.toggleUnorderedList();
63+
},
64+
'Mod-Shift-9': ({ editor }) => {
65+
if (!editor.isEditable) return false;
66+
return editor.commands.toggleCheckboxList(false);
67+
},
68+
'Mod-Alt-0': ({ editor }) => {
69+
if (!editor.isEditable) return false;
70+
return editor.commands.setParagraph();
71+
},
72+
'Mod-Alt-1': ({ editor }) => {
73+
if (!editor.isEditable) return false;
74+
return editor.commands.toggleHeading({ level: 1 });
75+
},
76+
'Mod-Alt-2': ({ editor }) => {
77+
if (!editor.isEditable) return false;
78+
return editor.commands.toggleHeading({ level: 2 });
79+
},
80+
'Mod-Alt-3': ({ editor }) => {
81+
if (!editor.isEditable) return false;
82+
return editor.commands.toggleHeading({ level: 3 });
83+
},
84+
'Mod-Alt-4': ({ editor }) => {
85+
if (!editor.isEditable) return false;
86+
return editor.commands.toggleHeading({ level: 4 });
87+
},
88+
'Mod-Alt-5': ({ editor }) => {
89+
if (!editor.isEditable) return false;
90+
return editor.commands.toggleHeading({ level: 5 });
91+
},
92+
'Mod-Alt-6': ({ editor }) => {
93+
if (!editor.isEditable) return false;
94+
return editor.commands.toggleHeading({ level: 6 });
95+
},
96+
};
97+
},
98+
});

0 commit comments

Comments
 (0)