Skip to content

Commit

Permalink
feat(core): support fragments with insertHtml (#965)
Browse files Browse the repository at this point in the history
  • Loading branch information
ifiokjr committed Jun 18, 2021
1 parent 88e7139 commit 6ab7d22
Show file tree
Hide file tree
Showing 20 changed files with 472 additions and 130 deletions.
24 changes: 24 additions & 0 deletions .changeset/good-insects-promise.md
@@ -0,0 +1,24 @@
---
'@remirror/extension-markdown': minor
---

Add new `insertMarkdown` command.

```ts
commands.insertMarkdown('# Heading\nAnd content');
// => <h1 id="heading">Heading</h1><p>And content</p>
```

The content will be inlined by default if not a block node.

```ts
commands.insertMarkdown('**is bold.**');
// => <strong>is bold.</strong>
```

To always wrap the content in a block you can pass the following option.

```ts
commands.insertMarkdown('**is bold.**', { alwaysWrapInBlock: true });
// => <p><strong>is bold.</strong></p>
```
5 changes: 5 additions & 0 deletions .changeset/real-games-clap.md
@@ -0,0 +1,5 @@
---
'@remirror/core': patch
---

Fix [#962](https://github.com/remirror/remirror/issues/962) so that inline html can now be added via `commands.insertHtml()`.
5 changes: 5 additions & 0 deletions .changeset/six-cobras-rhyme.md
@@ -0,0 +1,5 @@
---
'@remirror/core-utils': minor
---

Add support for the ProseMirror `Fragment` to the `StringHandlerOptions` in `@remirror/core-utils`. Now passing the option `fragment: true` to any string handler will return a `Fragment` rather than a `Node`.
60 changes: 49 additions & 11 deletions packages/remirror__core-utils/src/core-utils.ts
Expand Up @@ -47,14 +47,15 @@ import type {
TrStateProps,
} from '@remirror/core-types';
import {
DOMParser,
DOMParser as PMDomParser,
DOMSerializer,
Fragment,
Mark,
MarkType,
Node as PMNode,
NodeRange,
NodeType,
ParseOptions,
ResolvedPos as PMResolvedPos,
Schema,
Slice,
Expand Down Expand Up @@ -141,6 +142,15 @@ export function isProsemirrorNode(value: unknown): value is ProsemirrorNode {
return isObject(value) && value instanceof PMNode;
}

/**
* Checks to see if the passed value is a ProsemirrorNode
*
* @param value - the value to check
*/
export function isProsemirrorFragment(value: unknown): value is Fragment {
return isObject(value) && value instanceof Fragment;
}

/**
* Checks to see if the passed value is a ProsemirrorMark
*
Expand Down Expand Up @@ -975,7 +985,10 @@ export function getTextSelection(
/**
* A function that converts a string into a `ProsemirrorNode`.
*/
export type StringHandler = (params: StringHandlerOptions) => ProsemirrorNode;
export interface StringHandler {
(params: NodeStringHandlerOptions): ProsemirrorNode;
(params: FragmentStringHandlerOptions): Fragment;
}

export interface StringHandlerProps {
/**
Expand Down Expand Up @@ -1143,14 +1156,19 @@ export function prosemirrorNodeToDom(
return DOMSerializer.fromSchema(node.type.schema).serializeFragment(fragment, { document });
}

function elementFromString(html: string, document?: Document): HTMLElement {
const parser = new (((document ?? getDocument()) as any)?.defaultView ?? window).DOMParser();
return parser.parseFromString(`<body>${html}</body>`, 'text/html').body;
}

/**
* Convert the provided `node` to a html string.
*
* @param node - the node to extract html from.
* @param document - the document to use for the DOM
*
* ```ts
* import { EditorState, toHtml } from 'remirror';
* import { EditorState, prosemirrorNodeToHtml } from 'remirror';
*
* function convertStateToHtml(state: EditorState): string {
* return prosemirrorNodeToHtml({ node: state.doc, schema: state.schema });
Expand All @@ -1164,27 +1182,43 @@ export function prosemirrorNodeToHtml(node: ProsemirrorNode, document = getDocum
return element.innerHTML;
}

export interface StringHandlerOptions extends Partial<CustomDocumentProps>, SchemaProps {
export interface BaseStringHandlerOptions
extends Partial<CustomDocumentProps>,
SchemaProps,
ParseOptions {
/**
* The string content provided to the editor.
*/
content: string;
}

export interface FragmentStringHandlerOptions extends BaseStringHandlerOptions {
/**
* When true will create a fragment from the provided string.
*/
fragment: true;
}

export interface NodeStringHandlerOptions extends BaseStringHandlerOptions {
fragment?: false;
}

export type StringHandlerOptions = NodeStringHandlerOptions | FragmentStringHandlerOptions;

/**
* Convert a HTML string into a ProseMirror node. This can be used for the
* `stringHandler` property in your editor when you want to support html.
*
* ```tsx
* import { fromHtml } from 'remirror';
* import { htmlToProsemirrorNode } from 'remirror';
* import { Remirror, useManager } from '@remirror/react';
*
* const Editor = () => {
* const manager = useManager([]);
*
* return (
* <Remirror
* stringHandler={fromHtml}
* stringHandler={htmlToProsemirrorNode}
* initialContent='<p>A wise person once told me to relax</p>'
* >
* <div />
Expand All @@ -1193,12 +1227,16 @@ export interface StringHandlerOptions extends Partial<CustomDocumentProps>, Sche
* }
* ```
*/
export function htmlToProsemirrorNode(props: StringHandlerOptions): ProsemirrorNode {
const { content, schema, document = getDocument() } = props;
const element = document.createElement('div');
element.innerHTML = content.trim();
export function htmlToProsemirrorNode(props: FragmentStringHandlerOptions): Fragment;
export function htmlToProsemirrorNode(props: NodeStringHandlerOptions): ProsemirrorNode;
export function htmlToProsemirrorNode(props: StringHandlerOptions): ProsemirrorNode | Fragment {
const { content, schema, document, fragment = false, ...parseOptions } = props;
const element = elementFromString(content);
const parser = PMDomParser.fromSchema(schema);

return DOMParser.fromSchema(schema).parse(element);
return fragment
? parser.parseSlice(element, parseOptions).content
: parser.parse(element, parseOptions);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/remirror__core-utils/src/index.ts
Expand Up @@ -19,11 +19,13 @@ export {
export type {
CreateDocumentNodeProps,
CustomDocumentProps,
FragmentStringHandlerOptions,
GetMarkRange,
InvalidContentBlock,
InvalidContentHandler,
InvalidContentHandlerProps,
NamedStringHandlers,
NodeStringHandlerOptions,
StringHandler,
StringHandlerOptions,
StringHandlerProps,
Expand Down Expand Up @@ -70,6 +72,7 @@ export {
isMarkType,
isNodeSelection,
isNodeType,
isProsemirrorFragment,
isProsemirrorMark,
isProsemirrorNode,
isRemirrorJSON,
Expand Down
@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`commands.insertHtml can insert marks 1`] = `
<div
aria-label=""
aria-multiline="true"
class="ProseMirror remirror-editor"
contenteditable="true"
role="textbox"
>
<p>
Content
<strong>
is bold.
</strong>
</p>
</div>
`;
30 changes: 1 addition & 29 deletions packages/remirror__core/__tests__/commands-extension.spec.ts
@@ -1,5 +1,5 @@
import { extensionValidityTest, renderEditor } from 'jest-remirror';
import { BoldExtension, HeadingExtension, ItalicExtension } from 'remirror/extensions';
import { BoldExtension, ItalicExtension } from 'remirror/extensions';
import { AllSelection } from '@remirror/pm/state';

import { CommandsExtension } from '../';
Expand Down Expand Up @@ -211,31 +211,3 @@ describe('setContent', () => {
expect(editor.state.doc).toEqualProsemirrorNode(doc(p('my content')));
});
});

describe('commands.insertHtml', () => {
it('can insert html', () => {
const editor = renderEditor([
new HeadingExtension(),
new BoldExtension(),
new ItalicExtension(),
]);
const { doc, p } = editor.nodes;
const { bold, italic } = editor.marks;
const { heading } = editor.attributeNodes;
const h1 = heading({ level: 1 });

editor.add(doc(p('Content<cursor>')));

editor.chain.insertHtml('<h1>This is a heading</h1>').selectText('end').run();
expect(editor.state.doc).toEqualProsemirrorNode(doc(p('Content'), h1('This is a heading')));

editor.commands.insertHtml('<p>A paragraph <em>with</em> <strong>formatting</strong></p>');
expect(editor.state.doc).toEqualProsemirrorNode(
doc(
p('Content'),
h1('This is a heading'),
p('A paragraph ', italic('with'), ' ', bold('formatting')),
),
);
});
});
42 changes: 41 additions & 1 deletion packages/remirror__core/__tests__/helpers-extension.spec.ts
@@ -1,5 +1,5 @@
import { extensionValidityTest, renderEditor } from 'jest-remirror';
import { HeadingExtension } from 'remirror/extensions';
import { BoldExtension, HeadingExtension, ItalicExtension } from 'remirror/extensions';

import { HelpersExtension } from '../';

Expand Down Expand Up @@ -30,3 +30,43 @@ describe('helpers', () => {
expect(helpers.isSelectionEmpty()).toBeTrue();
});
});

describe('commands.insertHtml', () => {
it('can insert html', () => {
const editor = renderEditor([
new HeadingExtension(),
new BoldExtension(),
new ItalicExtension(),
]);
const { doc, p } = editor.nodes;
const { bold, italic } = editor.marks;
const { heading } = editor.attributeNodes;
const h1 = heading({ level: 1 });

editor.add(doc(p('Content<cursor>')));

editor.chain.insertHtml('<h1>This is a heading</h1>').selectText('end').run();
expect(editor.state.doc).toEqualProsemirrorNode(doc(p('Content'), h1('This is a heading')));

editor.commands.insertHtml('<p>A paragraph <em>with</em> <strong>formatting</strong></p>');
expect(editor.state.doc).toEqualProsemirrorNode(
doc(
p('Content'),
h1('This is a heading'),
p('A paragraph ', italic('with'), ' ', bold('formatting')),
),
);
});

it('can insert marks', () => {
const editor = renderEditor([new BoldExtension()]);
const { doc, p } = editor.nodes;
const { bold } = editor.marks;

editor.add(doc(p('Content <cursor>')));

editor.chain.insertHtml('<strong>is bold.</strong>').selectText('end').run();
expect(editor.state.doc).toEqualProsemirrorNode(doc(p('Content ', bold('is bold.'))));
expect(editor.dom).toMatchSnapshot();
});
});
37 changes: 18 additions & 19 deletions packages/remirror__core/src/builtins/commands-extension.ts
Expand Up @@ -33,6 +33,8 @@ import {
getMarkRange,
getTextSelection,
htmlToProsemirrorNode,
isEmptyBlockNode,
isProsemirrorFragment,
isProsemirrorNode,
isTextSelection,
removeMark,
Expand Down Expand Up @@ -634,15 +636,20 @@ export class CommandsExtension extends PlainExtension<CommandOptions> {
*/
@command()
insertNode(
node: string | NodeType | ProsemirrorNode,
node: string | NodeType | ProsemirrorNode | Fragment,
options: InsertNodeOptions = {},
): CommandFunction {
return ({ dispatch, tr, state }) => {
const { attrs, range, selection } = options;
const { from, to } = getTextSelection(selection ?? range ?? tr.selection, tr.doc);
const { attrs, range, selection, replaceEmptyParentBlock = false } = options;
const { from, to, $from } = getTextSelection(selection ?? range ?? tr.selection, tr.doc);

if (isProsemirrorNode(node)) {
dispatch?.(tr.replaceRangeWith(from, to, node));
if (isProsemirrorNode(node) || isProsemirrorFragment(node)) {
const pos = $from.before($from.depth);
dispatch?.(
replaceEmptyParentBlock && from === to && isEmptyBlockNode($from.parent)
? tr.replaceWith(pos, pos + $from.parent.nodeSize, node)
: tr.replaceWith(from, to, node),
);

return true;
}
Expand Down Expand Up @@ -685,20 +692,6 @@ export class CommandsExtension extends PlainExtension<CommandOptions> {
};
}

/**
* Insert a html string as a ProseMirror Node,
*
* @category Builtin Command
*/
@command()
insertHtml(html: string, options?: InsertNodeOptions): CommandFunction {
return (props) => {
const { state } = props;
const node = this.store.stringHandlers.html({ content: html, schema: state.schema });
return this.insertNode(node, options)(props);
};
}

/**
* Set the focus for the editor.
*
Expand Down Expand Up @@ -1197,6 +1190,12 @@ export interface InsertNodeOptions {
* Set the selection where the command should occur.
*/
selection?: PrimitiveSelection;

/**
* Set this to true to replace an empty parent block with this content (if the
* content is a block node).
*/
replaceEmptyParentBlock?: boolean;
}

const DEFAULT_COMMAND_META: Required<CommandExtensionMeta> = {
Expand Down

0 comments on commit 6ab7d22

Please sign in to comment.