diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts index 43a12dd6..30979e6f 100644 --- a/packages/core/src/ExtensionManager.ts +++ b/packages/core/src/ExtensionManager.ts @@ -96,15 +96,18 @@ export default class ExtensionManager { .flat() } + get attributes() { + return getAttributesFromExtensions(this.extensions) + } + get nodeViews() { const { editor } = this const { nodeExtensions } = splitExtensions(this.extensions) - const allAttributes = getAttributesFromExtensions(this.extensions) return Object.fromEntries(nodeExtensions .filter(extension => !!extension.config.addNodeView) .map(extension => { - const extensionAttributes = allAttributes.filter(attribute => attribute.type === extension.config.name) + const extensionAttributes = this.attributes.filter(attribute => attribute.type === extension.config.name) const context = { options: extension.options, editor, diff --git a/packages/core/src/commands/clearNodes.ts b/packages/core/src/commands/clearNodes.ts index 219f3c44..1aec3bbb 100644 --- a/packages/core/src/commands/clearNodes.ts +++ b/packages/core/src/commands/clearNodes.ts @@ -18,7 +18,7 @@ export const clearNodes = (): Command => ({ state, tr, dispatch }) => { const targetLiftDepth = liftTarget(nodeRange) if (node.type.isTextblock && dispatch) { - tr.setNodeMarkup(nodeRange.start, state.schema.nodes.paragraph) + tr.setNodeMarkup(nodeRange.start, state.doc.type.contentMatch.defaultType) } if ((targetLiftDepth || targetLiftDepth === 0) && dispatch) { diff --git a/packages/core/src/commands/splitBlock.ts b/packages/core/src/commands/splitBlock.ts index 5f26c29d..46d4e883 100644 --- a/packages/core/src/commands/splitBlock.ts +++ b/packages/core/src/commands/splitBlock.ts @@ -2,6 +2,7 @@ import { canSplit } from 'prosemirror-transform' import { ContentMatch, Fragment } from 'prosemirror-model' import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state' import { Command } from '../types' +import getSplittedAttributes from '../helpers/getSplittedAttributes' function defaultBlockAt(match: ContentMatch) { for (let i = 0; i < match.edgeCount; i + 1) { @@ -15,8 +16,7 @@ function defaultBlockAt(match: ContentMatch) { } export interface SplitBlockOptions { - withAttributes: boolean, - withMarks: boolean, + keepMarks: boolean, } function keepMarks(state: EditorState) { @@ -31,14 +31,24 @@ function keepMarks(state: EditorState) { /** * Forks a new node from an existing node. */ -export const splitBlock = (options: Partial = {}): Command => ({ tr, state, dispatch }) => { +export const splitBlock = (options: Partial = {}): Command => ({ + tr, + state, + dispatch, + editor, +}) => { const defaultOptions: SplitBlockOptions = { - withAttributes: false, - withMarks: true, + keepMarks: true, } const config = { ...defaultOptions, ...options } const { selection, doc } = tr const { $from, $to } = selection + const extensionAttributes = editor.extensionManager.attributes + const newAttributes = getSplittedAttributes( + extensionAttributes, + $from.node().type.name, + $from.node().attrs, + ) if (selection instanceof NodeSelection && selection.node.isBlock) { if (!$from.parentOffset || !canSplit(doc, $from.pos)) { @@ -46,7 +56,7 @@ export const splitBlock = (options: Partial = {}): Command => } if (dispatch) { - if (config.withMarks) { + if (config.keepMarks) { keepMarks(state) } @@ -74,9 +84,7 @@ export const splitBlock = (options: Partial = {}): Command => let types = atEnd && deflt ? [{ type: deflt, - attrs: config.withAttributes - ? $from.node().attrs - : {}, + attrs: newAttributes, }] : undefined @@ -91,9 +99,7 @@ export const splitBlock = (options: Partial = {}): Command => types = deflt ? [{ type: deflt, - attrs: config.withAttributes - ? $from.node().attrs - : {}, + attrs: newAttributes, }] : undefined } @@ -111,7 +117,7 @@ export const splitBlock = (options: Partial = {}): Command => } } - if (config.withMarks) { + if (config.keepMarks) { keepMarks(state) } diff --git a/packages/core/src/commands/splitListItem.ts b/packages/core/src/commands/splitListItem.ts index c05da4cb..aca57dba 100644 --- a/packages/core/src/commands/splitListItem.ts +++ b/packages/core/src/commands/splitListItem.ts @@ -1,13 +1,112 @@ -import { splitListItem as originalSplitListItem } from 'prosemirror-schema-list' -import { NodeType } from 'prosemirror-model' +import { + NodeType, + Node as ProseMirrorNode, + Fragment, + Slice, +} from 'prosemirror-model' +import { canSplit } from 'prosemirror-transform' +import { TextSelection } from 'prosemirror-state' import { Command } from '../types' import getNodeType from '../helpers/getNodeType' +import getSplittedAttributes from '../helpers/getSplittedAttributes' /** * Splits one list item into two list items. */ -export const splitListItem = (typeOrName: string | NodeType): Command => ({ state, dispatch }) => { +export const splitListItem = (typeOrName: string | NodeType): Command => ({ + tr, state, dispatch, editor, +}) => { const type = getNodeType(typeOrName, state.schema) + const { $from, $to } = state.selection - return originalSplitListItem(type)(state, dispatch) + // @ts-ignore + // eslint-disable-next-line + const node: ProseMirrorNode = state.selection.node + + if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { + return false + } + + const grandParent = $from.node(-1) + + if (grandParent.type !== type) { + return false + } + + const extensionAttributes = editor.extensionManager.attributes + + if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { + // In an empty block. If this is a nested list, the wrapping + // list item should be split. Otherwise, bail out and let next + // command handle lifting. + if ( + $from.depth === 2 + || $from.node(-3).type !== type + || $from.index(-2) !== $from.node(-2).childCount - 1 + ) { + return false + } + + if (dispatch) { + let wrap = Fragment.empty + const keepItem = $from.index(-1) > 0 + + // Build a fragment containing empty versions of the structure + // from the outer list item to the parent node of the cursor + for (let d = $from.depth - (keepItem ? 1 : 2); d >= $from.depth - 3; d -= 1) { + wrap = Fragment.from($from.node(d).copy(wrap)) + } + + // Add a second list item with an empty default start node + const newNextTypeAttributes = getSplittedAttributes( + extensionAttributes, + $from.node().type.name, + $from.node().attrs, + ) + const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined + wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || undefined)) + + tr + .replace( + $from.before(keepItem ? undefined : -1), + $from.after(-3), + new Slice(wrap, keepItem ? 3 : 2, 2), + ) + .setSelection(TextSelection.near(tr.doc.resolve($from.pos + (keepItem ? 3 : 2)))) + .scrollIntoView() + } + + return true + } + + const nextType = $to.pos === $from.end() + ? grandParent.contentMatchAt(0).defaultType + : null + + const newTypeAttributes = getSplittedAttributes( + extensionAttributes, + grandParent.type.name, + grandParent.attrs, + ) + const newNextTypeAttributes = getSplittedAttributes( + extensionAttributes, + $from.node().type.name, + $from.node().attrs, + ) + + tr.delete($from.pos, $to.pos) + + const types = nextType + ? [{ type, attrs: newTypeAttributes }, { type: nextType, attrs: newNextTypeAttributes }] + : [{ type, attrs: newTypeAttributes }] + + if (!canSplit(tr.doc, $from.pos, 2)) { + return false + } + + if (dispatch) { + tr.split($from.pos, 2, types).scrollIntoView() + } + + return true } diff --git a/packages/core/src/helpers/getAttributesFromExtensions.ts b/packages/core/src/helpers/getAttributesFromExtensions.ts index 9136be1c..b7a64b18 100644 --- a/packages/core/src/helpers/getAttributesFromExtensions.ts +++ b/packages/core/src/helpers/getAttributesFromExtensions.ts @@ -20,6 +20,7 @@ export default function getAttributesFromExtensions(extensions: Extensions): Ext rendered: true, renderHTML: null, parseHTML: null, + keepOnSplit: true, } extensions.forEach(extension => { diff --git a/packages/core/src/helpers/getSplittedAttributes.ts b/packages/core/src/helpers/getSplittedAttributes.ts new file mode 100644 index 00000000..8d3396ab --- /dev/null +++ b/packages/core/src/helpers/getSplittedAttributes.ts @@ -0,0 +1,21 @@ +import { AnyObject, ExtensionAttribute } from '../types' + +export default function getSplittedAttributes( + extensionAttributes: ExtensionAttribute[], + typeName: string, + attributes: AnyObject, +): AnyObject { + return Object.fromEntries(Object + .entries(attributes) + .filter(([name]) => { + const extensionAttribute = extensionAttributes.find(item => { + return item.type === typeName && item.name === name + }) + + if (!extensionAttribute) { + return false + } + + return extensionAttribute.attribute.keepOnSplit + })) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index aec14f17..f1f5bcb6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -60,6 +60,7 @@ export type Attribute = { rendered?: boolean, renderHTML?: ((attributes: { [key: string]: any }) => { [key: string]: any } | null) | null, parseHTML?: ((element: HTMLElement) => { [key: string]: any } | null) | null, + keepOnSplit: boolean, } export type Attributes = { diff --git a/packages/extension-task-item/src/task-item.ts b/packages/extension-task-item/src/task-item.ts index d1d1efd0..cfbf2402 100644 --- a/packages/extension-task-item/src/task-item.ts +++ b/packages/extension-task-item/src/task-item.ts @@ -34,6 +34,7 @@ export const TaskItem = Node.create({ renderHTML: attributes => ({ 'data-checked': attributes.checked, }), + keepOnSplit: false, }, } }, diff --git a/packages/extension-text-align/src/text-align.ts b/packages/extension-text-align/src/text-align.ts index 7d766eba..66e37a56 100644 --- a/packages/extension-text-align/src/text-align.ts +++ b/packages/extension-text-align/src/text-align.ts @@ -57,16 +57,6 @@ export const TextAlign = Extension.create({ addKeyboardShortcuts() { return { - // TODO: re-use only 'textAlign' attribute - // TODO: use custom splitBlock only for `this.options.types` - Enter: () => this.editor.commands.first(({ commands }) => [ - () => commands.newlineInCode(), - () => commands.createParagraphNear(), - () => commands.liftEmptyBlock(), - () => commands.splitBlock({ - withAttributes: true, - }), - ]), 'Mod-Shift-l': () => this.editor.commands.setTextAlign('left'), 'Mod-Shift-e': () => this.editor.commands.setTextAlign('center'), 'Mod-Shift-r': () => this.editor.commands.setTextAlign('right'),