@tiptap/core
Version:
headless rich text editor
153 lines (125 loc) • 4.78 kB
text/typescript
import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { CommandManager } from '../CommandManager.js'
import { Extension } from '../Extension.js'
import { createChainableState } from '../helpers/createChainableState.js'
import { isNodeEmpty } from '../helpers/isNodeEmpty.js'
import { isiOS } from '../utilities/isiOS.js'
import { isMacOS } from '../utilities/isMacOS.js'
export const Keymap = Extension.create({
name: 'keymap',
addKeyboardShortcuts() {
const handleBackspace = () => this.editor.commands.first(({ commands }) => [
() => commands.undoInputRule(),
// maybe convert first text block node to default node
() => commands.command(({ tr }) => {
const { selection, doc } = tr
const { empty, $anchor } = selection
const { pos, parent } = $anchor
const $parentPos = $anchor.parent.isTextblock && pos > 0 ? tr.doc.resolve(pos - 1) : $anchor
const parentIsIsolating = $parentPos.parent.type.spec.isolating
const parentPos = $anchor.pos - $anchor.parentOffset
const isAtStart = (parentIsIsolating && $parentPos.parent.childCount === 1)
? parentPos === $anchor.pos
: Selection.atStart(doc).from === pos
if (
!empty
|| !parent.type.isTextblock
|| parent.textContent.length
|| !isAtStart
|| (isAtStart && $anchor.parent.type.name === 'paragraph') // prevent clearNodes when no nodes to clear, otherwise history stack is appended
) {
return false
}
return commands.clearNodes()
}),
() => commands.deleteSelection(),
() => commands.joinBackward(),
() => commands.selectNodeBackward(),
])
const handleDelete = () => this.editor.commands.first(({ commands }) => [
() => commands.deleteSelection(),
() => commands.deleteCurrentNode(),
() => commands.joinForward(),
() => commands.selectNodeForward(),
])
const handleEnter = () => this.editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
])
const baseKeymap = {
Enter: handleEnter,
'Mod-Enter': () => this.editor.commands.exitCode(),
Backspace: handleBackspace,
'Mod-Backspace': handleBackspace,
'Shift-Backspace': handleBackspace,
Delete: handleDelete,
'Mod-Delete': handleDelete,
'Mod-a': () => this.editor.commands.selectAll(),
}
const pcKeymap = {
...baseKeymap,
}
const macKeymap = {
...baseKeymap,
'Ctrl-h': handleBackspace,
'Alt-Backspace': handleBackspace,
'Ctrl-d': handleDelete,
'Ctrl-Alt-Backspace': handleDelete,
'Alt-Delete': handleDelete,
'Alt-d': handleDelete,
'Ctrl-a': () => this.editor.commands.selectTextblockStart(),
'Ctrl-e': () => this.editor.commands.selectTextblockEnd(),
}
if (isiOS() || isMacOS()) {
return macKeymap
}
return pcKeymap
},
addProseMirrorPlugins() {
return [
// With this plugin we check if the whole document was selected and deleted.
// In this case we will additionally call `clearNodes()` to convert e.g. a heading
// to a paragraph if necessary.
// This is an alternative to ProseMirror's `AllSelection`, which doesn’t work well
// with many other commands.
new Plugin({
key: new PluginKey('clearDocument'),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some(transaction => transaction.docChanged)
&& !oldState.doc.eq(newState.doc)
const ignoreTr = transactions.some(transaction => transaction.getMeta('preventClearDocument'))
if (!docChanges || ignoreTr) {
return
}
const { empty, from, to } = oldState.selection
const allFrom = Selection.atStart(oldState.doc).from
const allEnd = Selection.atEnd(oldState.doc).to
const allWasSelected = from === allFrom && to === allEnd
if (empty || !allWasSelected) {
return
}
const isEmpty = isNodeEmpty(newState.doc)
if (!isEmpty) {
return
}
const tr = newState.tr
const state = createChainableState({
state: newState,
transaction: tr,
})
const { commands } = new CommandManager({
editor: this.editor,
state,
})
commands.clearNodes()
if (!tr.steps.length) {
return
}
return tr
},
}),
]
},
})