Merge pull request #132 from fantasticit/fix/code-block

tiptap: fix select in codeBlock
This commit is contained in:
fantasticit 2022-07-20 12:42:33 +08:00 committed by GitHub
commit 2489811e9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 150 additions and 242 deletions

View File

@ -1,248 +1,156 @@
import { mergeAttributes, Node, textblockTypeInputRule } from '@tiptap/core'; import { findChildren } from '@tiptap/core';
import BuiltInCodeBlock, { CodeBlockOptions } from '@tiptap/extension-code-block';
import { ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from '@tiptap/react';
import { lowlight } from 'lowlight/lib/all'; import { lowlight } from 'lowlight/lib/all';
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { Node as ProsemirrorNode } from 'prosemirror-model';
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { CodeBlockWrapper } from 'tiptap/core/wrappers/code-block'; import { CodeBlockWrapper } from 'tiptap/core/wrappers/code-block';
import { LowlightPlugin } from 'tiptap/prose-utils';
export interface CodeBlockOptions { function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
/** return nodes
* Adds a prefix to language classes that are applied to code tags. .map((node) => {
* Defaults to `'language-'`. const classes = [...className, ...(node.properties ? node.properties.className : [])];
*/
languageClassPrefix: string;
/**
* Define whether the node should be exited on triple enter.
* Defaults to `true`.
*/
exitOnTripleEnter: boolean;
/**
* Define whether the node should be exited on arrow down if there is no node after it.
* Defaults to `true`.
*/
exitOnArrowDown: boolean;
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' { if (node.children) {
interface Commands<ReturnType> { return parseNodes(node.children, classes);
codeBlock: {
/**
* Set a code block
*/
setCodeBlock: (attributes?: { language: string }) => ReturnType;
/**
* Toggle a code block
*/
toggleCodeBlock: (attributes?: { language: string }) => ReturnType;
};
} }
}
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export const BuiltInCodeBlock = Node.create<CodeBlockOptions>({
name: 'codeBlock',
draggable: true,
addOptions() {
return { return {
languageClassPrefix: 'language-', text: node.value,
exitOnTripleEnter: true, classes,
exitOnArrowDown: true,
HTMLAttributes: {},
}; };
},
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => {
const { languageClassPrefix } = this.options;
const classNames = Array.from(element.firstElementChild?.classList || element.classList || []);
const languages = classNames
.filter((className) => className.startsWith(languageClassPrefix))
.map((className) => className.replace(languageClassPrefix, ''));
const language = languages[0];
if (!language) {
return null;
}
return language;
},
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: 'pre',
preserveWhitespace: 'full',
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
'pre',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
[
'code',
{
class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null,
},
0,
],
];
},
addCommands() {
return {
setCodeBlock:
(attributes) =>
({ commands }) => {
return commands.setNode(this.name, attributes);
},
toggleCodeBlock:
(attributes) =>
({ commands }) => {
return commands.toggleNode(this.name, 'paragraph', attributes);
},
};
},
addKeyboardShortcuts() {
return {
'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(),
// remove code block when at start of document or code block is empty
'Backspace': () => {
const { empty, $anchor } = this.editor.state.selection;
const isAtStart = $anchor.pos === 1;
if (!empty || $anchor.parent.type.name !== this.name) {
return false;
}
if (isAtStart || !$anchor.parent.textContent.length) {
return this.editor.commands.clearNodes();
}
return false;
},
// exit node on triple enter
'Enter': ({ editor }) => {
if (!this.options.exitOnTripleEnter) {
return false;
}
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
const endsWithDoubleNewline = $from.parent.textContent.endsWith('\n\n');
if (!isAtEnd || !endsWithDoubleNewline) {
return false;
}
return editor
.chain()
.command(({ tr }) => {
tr.delete($from.pos - 2, $from.pos);
return true;
}) })
.exitCode() .flat();
.run(); }
},
// exit node on arrow down function getHighlightNodes(result: any) {
'ArrowDown': ({ editor }) => { // `.value` for lowlight v1, `.children` for lowlight v2
if (!this.options.exitOnArrowDown) { return result.value || result.children || [];
return false; }
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode;
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = [];
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const language = block.node.attrs.language || defaultLanguage;
const languages = lowlight.listLanguages();
const nodes =
language && languages.includes(language)
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
parseNodes(nodes).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(' '),
});
decorations.push(decoration);
} }
const { state } = editor; from = to;
const { selection, doc } = state; });
const { $from, empty } = selection; });
if (!empty || $from.parent.type !== this.type) { return DecorationSet.create(doc, decorations);
return false; }
function isFunction(param: Function) {
return typeof param === 'function';
}
export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
}: {
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
if (!['highlight', 'highlightAuto', 'listLanguages'].every((api) => isFunction(lowlight[api]))) {
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension');
} }
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; const lowlightPlugin: Plugin<any> = new Plugin({
key: new PluginKey('lowlight'),
if (!isAtEnd) { state: {
return false; init: (_, { doc }) =>
} getDecorations({
doc,
const after = $from.after(); name,
lowlight,
if (after === undefined) { defaultLanguage,
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return false;
}
return editor.commands.exitCode();
},
'Tab': ({ editor }) => {
const { selection } = this.editor.state;
const { $anchor } = selection;
return editor.chain().insertContentAt($anchor.pos, ' ').run();
},
};
},
addInputRules() {
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: (match) => ({
language: match[1],
}), }),
}), apply: (transaction, decorationSet, oldState, newState) => {
textblockTypeInputRule({ const oldNodeName = oldState.selection.$head.parent.type.name;
find: tildeInputRegex, const newNodeName = newState.selection.$head.parent.type.name;
type: this.type, const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
getAttributes: (match) => ({ const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
language: match[1],
}), if (
}), transaction.docChanged &&
]; // Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulte a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some((step) => {
// @ts-ignore
return (
step.from !== undefined &&
// @ts-ignore
step.to !== undefined &&
oldNodes.some((node) => {
// @ts-ignore
return (
node.pos >= step.from &&
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
);
})
);
}))
) {
return getDecorations({
doc: transaction.doc,
name,
lowlight,
defaultLanguage,
});
}
return decorationSet.map(transaction.mapping, transaction.doc);
}, },
}); },
props: {
decorations(state) {
return lowlightPlugin.getState(state);
},
},
});
return lowlightPlugin;
}
export interface CodeBlockLowlightOptions extends CodeBlockOptions { export interface CodeBlockLowlightOptions extends CodeBlockOptions {
lowlight: any; lowlight: any;
@ -253,11 +161,15 @@ export const CodeBlock = BuiltInCodeBlock.extend<CodeBlockLowlightOptions>({
addOptions() { addOptions() {
return { return {
...this.parent?.(), ...this.parent?.(),
lowlight, lowlight: {},
defaultLanguage: null, defaultLanguage: null,
}; };
}, },
addNodeView() {
return ReactNodeViewRenderer(CodeBlockWrapper);
},
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
...(this.parent?.() || []), ...(this.parent?.() || []),
@ -268,10 +180,6 @@ export const CodeBlock = BuiltInCodeBlock.extend<CodeBlockLowlightOptions>({
}), }),
]; ];
}, },
addNodeView() {
return ReactNodeViewRenderer(CodeBlockWrapper);
},
}).configure({ }).configure({
lowlight, lowlight,
defaultLanguage: 'auto', defaultLanguage: 'auto',