Skip to content

Commit

Permalink
feat: add ensureTrailingParagraph config option
Browse files Browse the repository at this point in the history
 In some scenarios it is difficult to place a cursor after the last
element. This ensures there's always space to select the position
afterward and fixes a whole range of issues. It defaults to false
otherwise it breaks a lot of tests.
  • Loading branch information
ifiokjr committed Jul 20, 2019
1 parent 0df574d commit 052e46f
Show file tree
Hide file tree
Showing 14 changed files with 256 additions and 30 deletions.
11 changes: 10 additions & 1 deletion @remirror/core/src/extension.ts
Expand Up @@ -489,10 +489,19 @@ export interface PrioritizedExtension {
export type FlexibleExtension = PrioritizedExtension | AnyExtension;

export interface ExtensionListParams {
/** A list of passed extensions */
/**
* A list of passed extensions
*/
extensions: AnyExtension[];
}

export interface ExtensionParams<GExtension extends AnyExtension = AnyExtension> {
/**
* An extension
*/
extension: GExtension;
}

/**
* Determines if the passed in extension is a any type of extension.
*
Expand Down
29 changes: 28 additions & 1 deletion @remirror/core/src/helpers/__tests__/utils.spec.ts
Expand Up @@ -14,11 +14,14 @@ import {
tdEmpty,
tr as row,
} from 'jest-prosemirror';
import { NodeSelection, TextSelection } from 'prosemirror-state';
import { NodeSelection, Selection, TextSelection } from 'prosemirror-state';
import { omit } from '../base';
import {
cloneTransaction,
findElementAtPosition,
findNodeAtEndOfDoc,
findNodeAtSelection,
findNodeAtStartOfDoc,
findParentNode,
findParentNodeOfType,
findPositionOfNodeBefore,
Expand Down Expand Up @@ -385,3 +388,27 @@ describe('findSelectedNodeOfType', () => {
expect(selectedNode!.node.type.name).toEqual('paragraph');
});
});

describe('findNodeAt...', () => {
const expectedEnd = h2('Heading here');
const expectedStart = p('<cursor> I am champion');
const pmDoc = doc(expectedStart, expectedEnd);

test('findNodeAtSelection', () => {
const selection = Selection.atEnd(pmDoc);
const { node, pos, start } = findNodeAtSelection(selection);
expect(node).toBe(expectedEnd);
expect(pos).toBe(16);
expect(start).toBe(17);
});

test('findNodeAtEndOfDoc', () => {
const { node } = findNodeAtEndOfDoc(pmDoc);
expect(node).toBe(expectedEnd);
});

test('findNodeAtStartOfDoc', () => {
const { node } = findNodeAtStartOfDoc(pmDoc);
expect(node).toBe(expectedStart);
});
});
10 changes: 5 additions & 5 deletions @remirror/core/src/helpers/nodes.ts
Expand Up @@ -2,11 +2,11 @@ import {
Attrs,
MarkTypeParams,
NodeTypeParams,
NullablePMNodeParams,
PMNodeParams,
OptionalProsemirrorNodeParams,
PosParams,
PredicateParams,
ProsemirrorNode,
ProsemirrorNodeParams,
} from '../types';
import { bool } from './base';
import { isProsemirrorNode } from './document';
Expand All @@ -27,9 +27,9 @@ type NodePredicateParams = PredicateParams<ProsemirrorNode>;
*
* @public
*/
export interface NodeWithPosition extends PMNodeParams, PosParams {}
export interface NodeWithPosition extends ProsemirrorNodeParams, PosParams {}

interface FlattenParams extends NullablePMNodeParams, Partial<DescendParams> {}
interface FlattenParams extends OptionalProsemirrorNodeParams, Partial<DescendParams> {}

/**
* Flattens descendants of a given `node`.
Expand Down Expand Up @@ -166,7 +166,7 @@ interface FindChildrenByMarkParams extends FlattenParams, MarkTypeParams {}
export const findChildrenByMark = ({ node, type, descend }: FindChildrenByMarkParams) =>
findChildren({ node, predicate: child => bool(type.isInSet(child.marks)), descend });

interface ContainsParams extends PMNodeParams, NodeTypeParams {}
interface ContainsParams extends ProsemirrorNodeParams, NodeTypeParams {}

/**
* Returns `true` if a given node contains nodes of a given `nodeType`
Expand Down
56 changes: 45 additions & 11 deletions @remirror/core/src/helpers/utils.ts
Expand Up @@ -6,10 +6,11 @@ import {
EditorView,
NodeTypeParams,
NodeTypesParams,
PMNodeParams,
PosParams,
PredicateParams,
ProsemirrorNode,
ProsemirrorNodeParams,
ResolvedPos,
Selection,
SelectionParams,
Transaction,
Expand All @@ -20,15 +21,10 @@ import { isNodeSelection, isSelection, isTextDOMNode } from './document';

/* "Borrowed" from prosemirror-utils in order to avoid requirement of `@prosemirror-tables`*/

interface NodeEqualsTypeParams extends NodeTypesParams, PMNodeParams {}
interface NodeEqualsTypeParams extends NodeTypesParams, ProsemirrorNodeParams {}

/**
* Checks if the type a given `node` equals to a given `nodeType`.
*
* @param type - the prosemirror node type(s)
* @param node - the prosemirror node
*
* @public
*/
export const nodeEqualsType = ({ types, node }: NodeEqualsTypeParams) => {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
Expand Down Expand Up @@ -119,7 +115,7 @@ export const removeNodeBefore = (tr: Transaction): Transaction => {
};

interface FindSelectedNodeOfTypeParams extends NodeTypesParams, SelectionParams {}
export interface FindSelectedNodeOfType extends FindParentNode {
export interface FindSelectedNodeOfType extends FindParentNodeResult {
/**
* The depth of the returned node.
*/
Expand Down Expand Up @@ -151,7 +147,7 @@ export const findSelectedNodeOfType = ({
return undefined;
};

export interface FindParentNode extends PMNodeParams {
export interface FindParentNodeResult extends ProsemirrorNodeParams {
/**
* The start position of the node.
*/
Expand All @@ -178,7 +174,7 @@ interface FindParentNodeParams extends SelectionParams, PredicateParams<Prosemir
export const findParentNode = ({
predicate,
selection,
}: FindParentNodeParams): FindParentNode | undefined => {
}: FindParentNodeParams): FindParentNodeResult | undefined => {
const { $from } = selection;
for (let currentDepth = $from.depth; currentDepth > 0; currentDepth--) {
const node = $from.node(currentDepth);
Expand All @@ -193,6 +189,44 @@ export const findParentNode = ({
return;
};

/**
* Finds the node at the passed selection.
*/
export const findNodeAtSelection = (selection: Selection): FindParentNodeResult => {
return findParentNode({ predicate: () => true, selection })!;
};

/**
* Finds the node at the end of the Prosemirror document.
*
* @param doc - the parent doc node of the editor which contains all the other nodes.
*/
export const findNodeAtEndOfDoc = (doc: ProsemirrorNode) => findNodeAtPosition(PMSelection.atEnd(doc).$from);

/**
* Finds the node at the start of the prosemirror.
*
* @param doc - the parent doc node of the editor which contains all the other nodes.
*/
export const findNodeAtStartOfDoc = (doc: ProsemirrorNode) =>
findNodeAtPosition(PMSelection.atStart(doc).$from);

/**
* Finds the node at the resolved position.
*
* @param $pos - the resolve position in the document
*/
export const findNodeAtPosition = ($pos: ResolvedPos): FindParentNodeResult => {
const { depth } = $pos;
const node = $pos.node(depth);

return {
pos: depth > 0 ? $pos.before(depth) : 0,
start: $pos.start(depth),
node,
};
};

interface FindParentNodeOfTypeParams extends NodeTypesParams, SelectionParams {}

/**
Expand All @@ -207,7 +241,7 @@ interface FindParentNodeOfTypeParams extends NodeTypesParams, SelectionParams {}
export const findParentNodeOfType = ({
types,
selection,
}: FindParentNodeOfTypeParams): FindParentNode | undefined => {
}: FindParentNodeOfTypeParams): FindParentNodeResult | undefined => {
return findParentNode({ predicate: node => nodeEqualsType({ types, node }), selection });
};

Expand Down
48 changes: 48 additions & 0 deletions @remirror/core/src/nodes/__tests__/paragraph.spec.ts
@@ -0,0 +1,48 @@
import { ExtensionMap } from '@test-fixtures/schema-helpers';
import { renderEditor } from 'jest-remirror';
import { ParagraphExtension, ParagraphExtensionOptions } from '../paragraph';

const { heading } = ExtensionMap.nodes;
const create = (params: ParagraphExtensionOptions = { ensureTrailingParagraph: true }) =>
renderEditor({
plainNodes: [new ParagraphExtension({ ...params }), heading],
});

describe('plugin', () => {
let {
add,
nodes: { doc, p, heading: h },
} = create();

beforeEach(() => {
({
add,
nodes: { doc, p, heading: h },
} = create());
});

it('adds a new paragraph when needed', () => {
const { state } = add(doc(h('Yo')));
expect(state.doc).toEqualRemirrorDocument(doc(h('Yo'), p()));
});

it('does not add multiple paragraphs', () => {
const { state } = add(doc(p('Yo')));
expect(state.doc).toEqualRemirrorDocument(doc(p('Yo')));
});

it('dynamically appends paragraphs', () => {
const { state } = add(doc(p('Yo'), p('<cursor>'))).insertText('# Greatness');
expect(state.doc).toEqualRemirrorDocument(doc(p('Yo'), h('Greatness'), p()));
});

it('does nothing when `ensureTrailingParagraph` is false', () => {
({
add,
nodes: { doc, p, heading: h },
} = create({ ensureTrailingParagraph: false }));

const { state } = add(doc(p('Yo'), p('<cursor>'))).insertText('# Greatness');
expect(state.doc).toEqualRemirrorDocument(doc(p('Yo'), h('Greatness')));
});
});
85 changes: 83 additions & 2 deletions @remirror/core/src/nodes/paragraph.ts
@@ -1,12 +1,39 @@
import { setBlockType } from 'prosemirror-commands';
import { Plugin } from 'prosemirror-state';
import { ExtensionParams } from '../extension';
import { findNodeAtEndOfDoc, FindParentNodeResult, getPluginState, nodeEqualsType } from '../helpers';
import { NodeExtension } from '../node-extension';
import { CommandNodeTypeParams, NodeExtensionOptions, NodeExtensionSpec } from '../types';
import {
CommandNodeTypeParams,
NodeExtensionOptions,
NodeExtensionSpec,
NodeTypeParams,
SchemaNodeTypeParams,
} from '../types';

export class ParagraphExtension extends NodeExtension<NodeExtensionOptions, 'createParagraph', {}> {
export interface ParagraphExtensionOptions extends NodeExtensionOptions {
/**
* Ensure that there's always a trailing paragraph at the end of the document.
*
* Why? In some scenarios it is difficult to place a cursor after the last element.
* This ensures there's always space to select the position afterward.
*
* @default false
*/
ensureTrailingParagraph?: boolean;
}

export class ParagraphExtension extends NodeExtension<ParagraphExtensionOptions, 'createParagraph', {}> {
get name() {
return 'paragraph' as const;
}

get defaultOptions() {
return {
ensureTrailingParagraph: false,
};
}

get schema(): NodeExtensionSpec {
return {
content: 'inline*',
Expand All @@ -28,4 +55,58 @@ export class ParagraphExtension extends NodeExtension<NodeExtensionOptions, 'cre
},
};
}

public plugin({ type }: SchemaNodeTypeParams): Plugin {
return createParagraphPlugin({ extension: this, type });
}
}

/**
* False when there is already a paragraph at the end of the document.
*
* The result of the find node when the node needs to be replaced.
*/
type ParagraphExtensionPluginState = false | FindParentNodeResult;

interface CreateParagraphPluginParams extends NodeTypeParams, ExtensionParams {}

/**
* Create the paragraph plugin which can check the end of the document and insert a new node if the option
* `ensureTrailingParagraph` is set to true.
*/
const createParagraphPlugin = ({ extension, type }: CreateParagraphPluginParams) => {
const { options, pluginKey } = extension;
const { ensureTrailingParagraph } = options;

return new Plugin<ParagraphExtensionPluginState>({
key: extension.pluginKey,
view() {
return {
update: view => {
const insertParagraphAtEnd = getPluginState<ParagraphExtensionPluginState>(pluginKey, view.state);

if (!insertParagraphAtEnd || !ensureTrailingParagraph) {
return;
}

const { pos, node } = insertParagraphAtEnd;
view.dispatch(view.state.tr.insert(pos + node.nodeSize, type.create()));
},
};
},
state: {
init: (_, state) => {
const result = findNodeAtEndOfDoc(state.tr.doc);
return nodeEqualsType({ node: result.node, types: type }) ? false : result;
},
apply: (tr, state) => {
if (!tr.docChanged) {
return state;
}

const result = findNodeAtEndOfDoc(tr.doc);
return nodeEqualsType({ node: result.node, types: type }) ? false : result;
},
},
});
};
12 changes: 10 additions & 2 deletions @remirror/core/src/types/builders.ts
Expand Up @@ -123,14 +123,22 @@ export interface MarkTypeParams {
type: MarkType;
}

export interface PMNodeParams {
export interface ProsemirrorNodeParams {
/**
* The prosemirror node
*/
node: ProsemirrorNode;
}

export interface NullablePMNodeParams {
export interface DocParams {
/**
* The parent doc node of the editor which contains all the other nodes.
* This is also a ProsemirrorNode
*/
doc: ProsemirrorNode;
}

export interface OptionalProsemirrorNodeParams {
/**
* The nullable prosemirror node which may or may not exist.
*/
Expand Down

0 comments on commit 052e46f

Please sign in to comment.