Skip to content

Instantly share code, notes, and snippets.

@ifiokjr
Last active January 12, 2021 08:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ifiokjr/b49563482ae301bf31ccb1d2b7374982 to your computer and use it in GitHub Desktop.
Save ifiokjr/b49563482ae301bf31ccb1d2b7374982 to your computer and use it in GitHub Desktop.
next version for remirror

API Enhancements

I've run into a fundamental problem with the way properties are currently setup. Each property maps directly to the property in the extension. This is great for primitives but not so great for handler functions. Ideally you should be able to register a handler method anywhere.

const onChange = useCallback(() => doSomething(), []);
useExtension(MentionExtension, { onChange })

Unfortunately I can't add another onChange handler anywhere else. Every time that onChange method changes it will overwrite the property on the extension.

There needs to be a new kind of property, the handler property. It's is the equivalent of add listener which adds the new handler. It returns a dispose method so that.

const onChange = useCallback() => doSomething(), []);

useExtensionHandlers(MentionExtension, (handlers) => {
  const dispose = handlers('onChange', onChange);

  return () => dispose();
}, []);

Keybindings would be the most difficult part.

If possible it can also take an object of methods like with the keybindings.

Firstly keybinding need to be made dynamic. Currently a keybinding only updates when the createKeymap is encountered. This happens one time during the duration of the manager. However keymaps should be dynamic, you should be able to change your keybindings at any time.

Either fork the prosemirror-keymaps project and add a new state that allows adding new keys to the keybinding, or use the reconfigure api

view.state.reconfigure()

Firstly, delay adding the keymap plugin until the view is ready. Ideally until all keybindings initialized in their respective hooks. Keybindings are a view layer concern.

  • - createPlugin should create the plugin when called and not require the user to call new Plugin({}). It should automatically include the key for you.
  • - createExtraPlugins should allow for adding multiple external plugins.

Tables

  • @remirror/extension-tables should be shared as a preset (deprecate the current package and create @remirror/preset-table).

Schema

  • createNodeSpec and createMarkSpec should no longer be protected and should no longer be called in the constructor. These need to be available to the SchemaExtension so that it can pass in three arguments.
  • Throw an error when a node / mark extension doesn't support extra attributes and doesn't set a static meta property of
class CustomNodeExtension extends NodeExtension {
  // Now users will receive a warning if they try to set extra attributes when consuming this.
  public static meta = { disableExtraAttributes: true }
}

Architecture

The repo size is getting larger with over 40 packages now, and it's impossible to manually manage that many.

  • Add a linter for the package.json files.
  • Add templates for new extensions and new presets. Make contributing much easier. Eventually put that functionality into monots.

Handling Events

With the next branch of remirror handling events in extensions has become much simpler.

Imagine you have an onHover event in your extension that you want to consume in your UI code, in this case React.

First you need to create the extension, and let remirror know that one of your options is an event handler. There are two steps to notifying the extension of handlers.

  • Add a static property to the class called handlerKeys: string[] which is a string of options names that you want remirror to automatically treat as event handlers.
  • Annotate the option you want to be a handler with the Handler type. This allow typescript to treat the type properly. For example, handlers won't show up in onSetOptions or in the options when calling new Extension().
import { PlainExtension, Handler, DefaultExtensionOptions, HandlerKeyList } from 'remirror/core';
import { PluginSpec } from '@remirror/pm/state';

interface HoverOptions {
  debounce?: number;
  onHover?: Handler<(event: Event) => void>;
  onLeave?: Handler<(event: Event) => void>;
}

class HoverExtension extends PlainExtension<HoverOptions> {
  static defaultOptions: DefaultExtensionOptions<HoverOptions> = { debounce: 200 }
  static handlerKeys: HandlerKeyList<HoverOptions> = ['onHover'];

  get name() {
    return 'hover' as const';
  }

  createPlugin = (): PluginSpec => {
    return {
      props: {
        mouseenter: (_, event) => {
          this.options.onHover(event); // Call all attached handlers.
          return false;
        },
        mouseleave: (_, event) => {
          this.options.onLeave(event);
          return false;
        }
      }
    }
  }
}

The handler is automatically created. Under the hood prosemirror looks at your static handlerKeys and creates a map of the keys to an array of handlers. It then replaces the this.options.onHover with a method that calls each item in the array that's been created. Obviously if there's no handlers then the call to this.options.onHover will do nothing.

Some things of note.

  • Utility types HandlerKeyList and DefaultExtensionOptions can make it easier to spot any missing options and keys. They allow Typescript to guide you.
  • Importing from @remirror/pm/state rather than prosemirror-state. It's up to you, but since @remirror/pm is a peer dependency for all remirror projects and you're free to use it.

To consume the onHover handler in your react codebase.

import React, { useMemo } from 'react';
import {
  useCreateExtension,
  useExtension,
  useManager,
  RemirrorProvider,
  useRemirror,
} from 'remirror/react';
import { HoverExtension } from './hover-extension';

const Wrapper = () => {
  const hoverExtension = useCreateExtension({ debounce: 100 });
  const extensions = useMemo(() => [hoverExtension], [])
  const manager = useManager({ extensions, presets: [] });

  return (
    <RemirrorProvider manager={manager}>
      <Editor />
    </RemirrorProvider>
  );
}

const Editor = () => {
  const { getRootProps } = useRemirror();
  const [message, setMessage] = useState('not hovering');

  useExtension(HoverExtension, ({ addHandler }) => {
    const disposeHover = addHandler('onHover', (_event) => {
      setMessage('hovering')
    });

    const disposeLeave = addHandler('onLeave', (_event) => {
      setMessage('not hovering');
    });

    return () => {
      disposeHover();
      disposeLeave();
    };
  }, [setMessage]);

  return (
    <>
      <p>{message}</p>
      <div {...getRootProps()}>
    </>
  )
}

The code updates the message to the hover when hovering over the editor and when leaving.

  • The addHandler method returns a dispose function which can be used to clean up after your event handler.
  • The callback in the useExtension hook should return a callback for cleaning up the handlers.

The advantage of this automated approach is that multiple handlers can be attached. This means you can place code at the place where it is needed.

Current issues

  • onSetCustomOption provides a type and a value, however the value doesn't seem to recognise functions. So everything has to be cast as any. This should be fixed later on. I could be to do with the annotation and I've created a new type utility called RemoveAnnotation to maybe help with that.
import {
Dispose,
EditorView,
entries,
extension,
findParentNode,
findPositionOfNodeAfter,
findPositionOfNodeBefore,
isDomNode,
isElementDomNode,
isNumber,
isUndefined,
PlainExtension,
ResolvedPos,
StateUpdateLifecycleProps,
Static,
throttle,
} from '@remirror/core';
import { dropPoint, insertPoint } from '@remirror/pm/transform';
import { Decoration, DecorationSet } from '@remirror/pm/view';
export interface DropCursorOptions {
/**
* The class name added to the block node which is directly before the drop
* cursor.
*
* @default 'before-drop-cursor'
*/
beforeClass?: string;
/**
* The class name added to the block node which is directly after the drop
* cursor.
*
* @default 'after-drop-cursor'
*/
afterClass?: string;
/**
* The class name added to the block node which contains the drop cursor.
*
* @default 'container-drop-cursor'
*/
containerClass?: string;
/**
* The inline drop cursor class which is added to the drop cursor widget
* within a text block.
*
* @default 'inline-drop-cursor'
*/
inlineClass?: string;
/**
* The block drop cursor class which is added to the drop cursor widget when
* it is between two nodes.
*
* @default 'block-drop-cursor'
*/
blockClass?: string;
/**
* Create the inline and block HTML elements for the drop cursor widgets.
*/
createCursorElement?: (props: CursorElementsProps) => CursorElements;
/**
* Set the throttling delay in milliseconds. Set it to `-1` to remove any
* throttling which is not recommended.
*
* @default 50
*/
throttle?: Static<number>;
}
const BLOCK_KEY = 'drop-cursor-block';
const INLINE_KEY = 'drop-cursor-inline';
const DEFAULT_TIMEOUT = 50;
/**
* Create a plugin that, when added to a ProseMirror instance, causes a widget
* decoration to show up at the drop position when something is dragged over the
* editor.
*
* @category Builtin Extension
*/
@extension<DropCursorOptions>({
defaultOptions: {
createCursorElement,
afterClass: 'after-drop-cursor',
beforeClass: 'before-drop-cursor',
containerClass: 'container-drop-cursor',
inlineClass: 'inline-drop-cursor',
blockClass: 'block-drop-cursor',
throttle: DEFAULT_TIMEOUT,
},
staticKeys: ['throttle'],
})
export class DropCursorExtension extends PlainExtension<DropCursorOptions> {
get name() {
return 'dropCursor' as const;
}
/**
* The decoration set.
*/
#decorationSet = DecorationSet.empty;
/**
* The elements attached.
*/
#elements?: CursorElements = undefined;
/**
* The current derived target position. This is cached to help prevent
* unnecessary re-rendering.
*/
#target?: number;
/**
* The currently active timeout.
*/
#timeout?: number;
get isDragging(): boolean {
return (
!!this.store.view.dragging ??
(this.#decorationSet !== DecorationSet.empty || isUndefined(this.#target))
);
}
/**
* Set up the handlers once the view is available.
*/
onView(view: EditorView): Dispose | void {
const element = view.dom;
if (!isElementDomNode(element)) {
return;
}
const handlers = {
dragover: this.dragover.bind(this),
dragend: this.dragend.bind(this),
drop: this.drop.bind(this),
dragleave: this.dragleave.bind(this),
};
if (this.options.throttle > 0) {
handlers.dragover = throttle(
this.options.throttle,
this.dragover.bind(this),
);
}
element.addEventListener('dragover', handlers.dragover);
element.addEventListener('dragend', handlers.dragend);
element.addEventListener('drop', handlers.drop);
element.addEventListener('dragleave', handlers.dragleave);
return () => {
// Remove the event handlers.
for (const [event, handler] of entries(handlers)) {
element.removeEventListener(event, handler);
}
};
}
/**
* Update the elements if an active cursor is present while the state is
* updating.
*/
onStateUpdate(props: StateUpdateLifecycleProps): void {
const { previousState, state } = props;
if (isNumber(this.#target) && !state.doc.eq(previousState.doc)) {
// Update the cursor when the cursor is active and the doc has changed.
// This takes care of updates that might be happening while dragging
// content over the editor.
// this.updateCursor(state.doc.resolve(this.#target));
}
}
createDecorations(): DecorationSet {
return this.#decorationSet;
}
/**
* Removes the decoration and (by default) the current target value.
*/
private removeDecorationSet(ignoreTarget = false) {
if (!ignoreTarget) {
this.#target = undefined;
}
this.#decorationSet = DecorationSet.empty;
this.store.commands.updateDecorations();
}
/**
* Removes the drop cursor decoration from the view after the set timeout.
*
* Sometimes the drag events don't automatically trigger so it's important to
* have this cleanup in place.
*/
private scheduleRemoval(timeout: number, ignoreTarget = false) {
if (this.#timeout) {
clearTimeout(this.#timeout);
}
this.#timeout = setTimeout(() => {
this.removeDecorationSet(ignoreTarget);
}, timeout) as any;
}
/**
* Update the decoration set with a new position.
*/
private updateDecorationSet() {
const { view, commands } = this.store;
const { blockClass, inlineClass } = this.options;
if (!this.#target) {
return;
}
if (!this.#elements) {
this.#elements = this.options.createCursorElement({
blockClass,
inlineClass,
view,
});
}
const { block, inline } = this.#elements;
const {
state: { doc },
} = view;
const $pos = doc.resolve(this.#target);
const cursorIsInline = $pos.parent.inlineContent;
// Create the relevant decorations
const decorations = cursorIsInline
? this.createInlineDecoration($pos, inline)
: this.createBlockDecoration($pos, block);
// Update the decoration set.
this.#decorationSet = DecorationSet.create(doc, decorations);
// Update all decorations.
commands.updateDecorations();
}
/**
* Called on every dragover event.
*
* Captures the current position and whether
*/
private dragover(event: DragEvent) {
const { view } = this.store;
if (!view.editable) {
return;
}
const positionAtCoords = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!positionAtCoords) {
return;
}
const { pos } = positionAtCoords;
const { dragging, state } = view;
const { doc, schema } = state;
const target = dragging?.slice
? dropPoint(doc, pos, dragging.slice) ?? pos
: insertPoint(doc, pos, schema.nodes.image) ?? pos;
if (target === this.#target) {
// This line resets the timeout.
this.scheduleRemoval(DEFAULT_TIMEOUT);
return;
}
this.#target = target;
this.updateDecorationSet();
this.scheduleRemoval(DEFAULT_TIMEOUT);
}
/**
* Called when the drag ends.
*
* ? Sometimes this event doesn't fire, is there a way to prevent this.
*/
private dragend(_: DragEvent) {
this.scheduleRemoval(50);
}
/**
* Called when the element is dropped.
*
* ? Sometimes this event doesn't fire, is there a way to prevent this.
*/
private drop(_: DragEvent) {
this.scheduleRemoval(DEFAULT_TIMEOUT);
}
/**
* Called when the drag leaves the boundaries of the prosemirror editor dom
* node.
*/
private dragleave(event: DragEvent) {
const { view } = this.store;
if (
event.target === view.dom ||
(isDomNode(event.relatedTarget) &&
!view.dom.contains(event.relatedTarget))
) {
this.scheduleRemoval(DEFAULT_TIMEOUT);
}
}
/**
* Create an inline decoration for the document which is rendered when the
* cursor position is within a text block.
*/
private createInlineDecoration(
$pos: ResolvedPos,
element: HTMLElement,
): Decoration[] {
const { containerClass } = this.options;
const decorations: Decoration[] = [];
const dropCursor = Decoration.widget($pos.pos, element, {
key: INLINE_KEY,
});
decorations.push(dropCursor);
const container = findParentNode({
selection: $pos,
predicate: (node) => node.type.isBlock || node.type.isTextblock,
});
if (container) {
const { pos, end } = container;
const containerDecoration = Decoration.node(pos, end, {
class: containerClass,
});
decorations.push(containerDecoration);
}
return decorations;
}
/**
* Create a block decoration for the document which is rendered when the
* cursor position is between two nodes.
*/
private createBlockDecoration(
$pos: ResolvedPos,
element: HTMLElement,
): Decoration[] {
const { beforeClass, afterClass } = this.options;
const decorations: Decoration[] = [];
// Create the cursor decoration.
const dropCursor = Decoration.widget($pos.pos, element, { key: BLOCK_KEY });
const before = findPositionOfNodeBefore($pos);
const after = findPositionOfNodeAfter($pos);
decorations.push(dropCursor);
if (before) {
const { pos, end } = before;
const beforeDecoration = Decoration.node(pos, end, {
class: beforeClass,
});
decorations.push(beforeDecoration);
}
if (after) {
const { pos, end } = after;
const afterDecoration = Decoration.node(pos, end, { class: afterClass });
decorations.push(afterDecoration);
}
return decorations;
}
}
/**
* The default cursor element creator.
*/
function createCursorElement(props: CursorElementsProps): CursorElements {
const { blockClass, inlineClass } = props;
const inline = document.createElement('span');
const block = document.createElement('div');
inline.classList.add(inlineClass);
block.classList.add(blockClass);
return { inline, block };
}
export interface CursorElementsProps {
view: EditorView;
inlineClass: string;
blockClass: string;
}
export interface CursorElements {
/**
* The element to use for inline content. When the drag target points to a
* textBlock. The inline element is absolutely positioned.
*/
inline: HTMLElement;
/**
* The element to use for block content. This is the case when the target sits
* between two nodes. The block element will be appended to the editor dom.
*/
block: HTMLElement;
}
type CustomEventMap = Pick<
DocumentEventMap,
'dragover' | 'dragend' | 'drop' | 'dragleave'
>;
declare global {
namespace Remirror {
interface AllExtensions {
dropCursor: DropCursorExtension;
}
}
}

Extensions

Some notes on potential next steps for remirror extensions.

Multicursor

Multicursor support is such a useful innovation and yet it's not available for most text editors. This would result in two new packages.

  • prosemirror-multicursor - Provides a plugin and a new selection api that builds on top of the current selection api but adds support for multiple cursors, handles cursors folding together, handles multiple selections, etc...
  • @remirror/extension-multicursor - Integrates the plugin as a remirror extension.

Search

Find and replace is a big feature of any editor and again I want to take inspiration from vscode which has a really good find and replace UI.

Something similar for the remirror.

  • @remirror/extension-search - Find and replace within the remirror editor.

It's possible that the plugin can be generalized and released as prosemirror-search.

Pages

Handling pages is quite a complex and often asked for solution. Maybe remirror can offer some solutions in this area.

Maybe some kind of pagination handler. Decorations are used to deliniate pages, calculations of the full document are made to determine what is within each page. Pages are consistent across devices.

Positioners

What about this? A remirror extension that can always tell you exactly where something is located. I'm not sure of the API yet, especially how it would interact with react hooks. But in many ways, it's an event listener and should be treated as such. I also would prefer if the project moved away from managing positions with ids as is currently the case.

The problem with hooks is treating functions as ids since they are constantly being recreated.

Something like an event listener that uses the callback provided as the id. And can also be disposed off via the function it returns.

It's a new paradigm. I'm working towards to create a distinct separation of concerns. Prosemirror exclusively handles everything within the content editable section. The UI framework handles everything outside. A Positioner extension would be a great way to make this a reality.

Decorations

Sometimes, there's a need to step into the editor and add custom styles to the areas around different words and text.

This is bread and butter for decorators. The question to ask is can this be integrated in such a way that the UI layer can also interact.

Redacted - WRONG

Why forEachExtension?

For the sake of this example let's assume that all extensions implement all the lifecycle methods. With that assumption we can compare the reasoning behind the forEachExtension instead of the default Inner Looping

Inner Looping

This style relies on the following lifecycle methods.

  • (unnamed) - +1
  • onCreate - +1
  • onView - +1

unnamed refers to when the manager inspects each extension initially in order to determine which extension have the relevant methods.

So there are 3 lifecycle methods and each runs through all extensions.

There are 10 extensions and 3 lifecycle methods.

  • Best case: 30 steps.

If each each extension needs to create its own loop over the other extensions then we now have a multiple of 10 for the worst case scenario. So within the onCreate and onView methods there are now 10 extensions looping through all 10 extensions.

  • Worst case: 230 steps.

Reason: 10 x 3: (calling each lifecycle method) + 10 x 10: (looping each extension per lifecycle method) x 2: (lifecycle methods)

forEachExtension

We effectively add an extra two lifecycle methods for a total of 5 steps.

  • (unnamed) +1
  • onCreate +0
    • forEach +1
    • afterForEach +1
  • onView +0
    • forEach +1
    • afterForEach +1

With 10 extensions now the best case scenario is 50 steps

  • Best case: 50 steps

However, since none of the lifecycle methods receive the full list of extensions they are forced to use the forEachExtension to implement whatever loop they needed. So there's are no extra steps.

  • Worst case: 50 steps

100 extensions

There are already 20 extensions in the stripped down version of the remirror editor. 12 of them rely on the lifecycle methods. However, let's imagine a case for a huge editor with 100 extensions.

Inner Looping

  • Best case: 300 steps
  • Worst case: 20300 steps

Reason: 100 x 3: (calling each lifecycle method) + 100 x 100: (looping each extension per lifecycle method) x 2: (lifecycle methods)

forEachExtension

  • Best case: 500 steps
  • Worst case: 500 steps

Conclusion

None of this probably matters. Browsers are very performant these days The performance bottle necks will probably come from loading prettier, or a 1mb json file to represent emojis. This just outlines the reasoning behind the forEachExtension.

import {
AnyExtension,
AnyPreset,
CreateLifecycleMethod,
PlainExtension,
Shape,
} from '@remirror/core';
import {
buildParser,
buildSerializer,
FromMarkdown,
Parser,
Serializer,
ToMarkdown,
} from './markdown-utils';
export class MarkdownExtension extends PlainExtension {
public readonly name = 'markdown' as const;
public onCreate: CreateLifecycleMethod = (parameter) => {
const serializerMethods: FromMarkdown[] = [];
const parserMethods: ToMarkdown[] = [];
return {
// Loops through each extension during the first phase of the extension
// manager lifecycle.
forEachExtension(extension) {
if (extension.fromMarkdown) {
serializerMethods.push(extension.fromMarkdown);
}
if (extension.toMarkdown) {
parserMethods.push(extension.toMarkdown);
}
},
afterExtensionLoop() {
const { setStoreKey } = parameter;
const parser = buildParser(parserMethods);
const serializer = buildSerializer(serializerMethods);
// Add the serializer and parser to the manager.
setStoreKey('serializer', serializer);
setStoreKey('parser', parser);
// Now the manager can be called with `manager.store.serializer` and
// `manager.store.parser`.
},
};
};
}
declare global {
// This is a global namespace for remirror, similar to jest that allows you to
// augment the types available to jest. This means that any extension has the
// power now to improve the core functionality of remirror.
namespace Remirror {
interface ManagerStore<ExtensionUnion extends AnyExtension, PresetUnion extends AnyPreset> {
/**
* Parse content from markdown
*/
parser: Parser;
/**
* Serialize prosemirror content to markdown
*/
serializer: Serializer;
}
interface ExtensionCreatorMethods<Settings extends Shape = {}, Properties extends Shape = {}> {
/**
* Convert to markdown.
*/
toMarkdown?: ToMarkdown;
/**
* Create from markdown.
*/
fromMarkdown?: FromMarkdown;
}
}
}
import { Preset } from 'remirror/core';
import { ParagraphExtension } from 'remirror/extension/paragraph';
import { TextExtension } from 'remirror/extension/text';
import { MarkdownExtension } from './markdown-extension'
export class MarkdownPreset extends Preset {
public readonly name = 'markdown' as const;
// Create extensions and manage with the preset.
public createExtensions() {
const paragraphExtension = new ParagraphExtension();
const textExtension = new TextExtension();
const markdownExtension = new MarkdownExtension()
// create markdown parser and serializer for paragraphs.
paragraphExtension.toMarkdown = function () {};
paragraphExtension.fromMarkdown = function () {};
// create markdown parser and serializer for text.
textExtension.toMarkdown = function () {};
textExtension.fromMarkdown = function () {};
// etc...
return [paragraphExtension, textExtension, markdownExtension];
}
// No properties are used here but can respond to updates in properties passed
// into this preset and pass them to the properties.
protected onSetProperties() {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment