Dojo NG is being built in the open, one section at a time — follow along on Heptapod.

API REFERENCE · RICH TEXT PLUGINS

@dojo-ng/rich-text-criticmarkup

Track changes for <dj-rich-text> in CriticMarkup, a plain-text convention for suggested edits and comments.

Try it in the playground

@dojo-ng/rich-text-criticmarkup · v0.1.3 · A plugin for <dj-rich-text>, not a custom element

npm install @dojo-ng/rich-text-criticmarkup

Compose the plugin with the default set, starting in suggestion mode: typed/deleted text is wrapped as marks instead of applied directly.

<dj-rich-text id="editor" label="Draft"></dj-rich-text>
<script type="module">
  import "@dojo-ng/rich-text";
  import { defaultPlugins } from "@dojo-ng/rich-text";
  import { createCriticMarkupPlugin } from "@dojo-ng/rich-text-criticmarkup";
  const el = document.getElementById("editor");
  el.plugins = [...defaultPlugins, createCriticMarkupPlugin({ suggesting: true })];
  el.value = "The quick brown fox.";
</script>

The five marks

  • {++inserted++} and {--deleted--}.
  • {~~old~>new~~}, a substitution. It is imported as a deletion followed by an insertion, and accepted or declined as that pair, never one half alone.
  • {>>comment<<}, on its own or attached right after a highlight.
  • {==highlighted==}, a note on the text. It is kept on both accept and decline.

Suggestion mode

  • Turn it on with setSuggestionMode(editor, true), or start with createCriticMarkupPlugin({ suggesting: true }). Typed and deleted text then becomes marks instead of changing the text directly.
  • Resolve one mark with markAtSelection, acceptMark, and declineMark, or the whole document with acceptAllMarks and declineAllMarks.
  • A comment on its own stays either way. A highlight's attached comment goes with the highlight.

Comments

  • insertComment(editor, text) adds a comment at a collapsed caret, or highlights a selection and attaches the comment to it. editComment changes a comment.
  • removeComment removes only the comment and keeps the highlight; removeHighlight removes both.
  • A comment cannot contain <<} or {>>, because either would break the mark the next time the text is read. isValidCommentText checks this, and an invalid comment is refused with a message that names the problem.

Splitting and joining paragraphs

  • A paragraph split or join suggested in suggestion mode is stored as a token inside a one-line mark, not as a real line break, so it survives Markdown import.
  • The structuralEdits option (default "mark") controls this. "annotate" makes the edit without tracking it and leaves a comment at the boundary, "block" refuses the edit, and "apply" makes it silently.
  • The token is an extension, not standard CriticMarkup: other tools show it as a literal . Call toPortableCriticMarkup(value) before handing a document to another tool. It splits a multi-paragraph mark into one mark per paragraph, which can leave a blank line behind on decline. Or set structuralEdits: "annotate" so the token is never written.
  • A that the author types is written doubled (¶¶) so it stays a character.

Nested marks

  • A mark inside another mark is refused as its own node instead of being garbled. It is kept as literal text, and dj-criticmarkup-refused fires.

Formats

  • The plugin adds its own criticmarkup format (format="criticmarkup"), so it works without the Markdown plugin.
  • To read CriticMarkup inside @dojo-ng/rich-text-markdown's format, add criticMarkupTransformers to its transformer set.

Without an editor

  • The grammar functions (parseMarks, accept, decline, acceptAll, declineAll, stripComments, and the token functions) are plain string functions with no Lexical import. Use them in a build step, on a server, or in a command-line tool.
  • fixtures/conformance.json is included, so a port to another language can run the same test cases.

Setup

  • Setting plugins replaces the default set, so spread ...defaultPlugins to keep bold, italic, underline, undo, and redo.

Examples

Resolve CriticMarkup with no editor

The grammar is a plain string API — parse and resolve marks from a server, a build step, or a CLI, with no Lexical/DOM dependency at all.

import { parseMarks, acceptAll, declineAll } from "@dojo-ng/rich-text-criticmarkup";

const draft = "The {--old--}{++new++} plan is set.";
acceptAll(draft);                        // "The new plan is set."
declineAll(draft);                       // "The old plan is set."
parseMarks(draft).map((m) => m.kind);    // ["deletion", "insertion"]

Events

These events bubble from the host element. The data is in event.detail.

dj-criticmarkup-refused

CriticMarkupRefusedDetail

dj-criticmarkup-change

CriticMarkupChangeDetail

Functions

accept

function accept(text: string, mark: Mark): string;

acceptAll

function acceptAll(text: string): string;

acceptAllMarks

function acceptAllMarks(editor: LexicalEditor): void;

acceptMark

function acceptMark(editor: LexicalEditor, node: LexicalNode): void;

configureSuggestionMode

function configureSuggestionMode(editor: LexicalEditor, options: SuggestionModeOptions): void;

createCommentPopupController

function createCommentPopupController(editor: LexicalEditor, msg: (key: string) => string): CommentPopupController;

createCriticMarkupPlugin

function createCriticMarkupPlugin(options?: CriticMarkupOptions): RichTextPlugin;

decline

function decline(text: string, mark: Mark): string;

declineAll

function declineAll(text: string): string;

declineAllMarks

function declineAllMarks(editor: LexicalEditor): void;

declineMark

function declineMark(editor: LexicalEditor, node: LexicalNode): void;

deserializeCriticMarkup

function deserializeCriticMarkup(editor: LexicalEditor, data: string, options?: DeserializeCriticMarkupOptions): void;

editComment

function editComment(editor: LexicalEditor, node: LexicalNode, text: string): void;

escapeToken

function escapeToken(text: string, token?: string): string;

inlineFormatSegments

function inlineFormatSegments(masked: string, format?: number): InlineFormatSegment[];

insertComment

function insertComment(editor: LexicalEditor, text: string): void;

isSuggestionMode

function isSuggestionMode(editor: LexicalEditor): boolean;

isValidCommentText

function isValidCommentText(text: string): boolean;

markAtSelection

function markAtSelection(editor: LexicalEditor): LexicalNode | null;

maskInlineFormat

function maskInlineFormat(text: string): string;

maskNested

function maskNested(text: string): {
  masked: string;
  masks: number;
};

normalizeBlockSpanning

function normalizeBlockSpanning(text: string): string;

parseMarks

function parseMarks(text: string): Mark[];

removeComment

function removeComment(editor: LexicalEditor, node: LexicalNode): void;

removeHighlight

function removeHighlight(editor: LexicalEditor, node: LexicalNode): void;

serializeCriticMarkup

function serializeCriticMarkup(_editor: LexicalEditor): string;

setSuggestionMode

function setSuggestionMode(editor: LexicalEditor, enabled: boolean): void;

stripComments

function stripComments(text: string): string;

tokenizeBlockSpanning

function tokenizeBlockSpanning(text: string, token?: string): string;

toPortableCriticMarkup

function toPortableCriticMarkup(text: string, token?: string): string;

unescapeToken

function unescapeToken(text: string, token?: string): string;

unmaskInlineFormat

function unmaskInlineFormat(text: string): string;

unmaskNested

function unmaskNested(text: string): string;

Classes

BreakNode

class BreakNode extends DecoratorNode<HTMLElement> {
  static getType(): string;
  static clone(node: BreakNode): BreakNode;
  isInline(): boolean;
  static importJSON(_serializedNode: SerializedBreakNode): BreakNode;
  exportJSON(): SerializedBreakNode;
  createDOM(): HTMLElement;
  updateDOM(): boolean;
  decorate(): HTMLElement;
  getTextContent(): string;
}

CommentNode

class CommentNode extends DecoratorNode<HTMLElement> {
  __text: string;
  static getType(): string;
  static clone(node: CommentNode): CommentNode;
  constructor(text: string, key?: NodeKey);
  isInline(): boolean;
  static importJSON(serializedNode: SerializedCommentNode): CommentNode;
  exportJSON(): SerializedCommentNode;
  createDOM(): HTMLElement;
  updateDOM(): boolean;
  exportDOM(): DOMExportOutput;
  decorate(_editor: LexicalEditor): HTMLElement;
  getText(): string;
  setText(text: string): this;
}

DeletionNode

class DeletionNode extends MarkNode {
  static getType(): string;
  static clone(node: DeletionNode): DeletionNode;
  createDOM(): HTMLElement;
  static importJSON(serializedNode: SerializedDeletionNode): DeletionNode;
  exportJSON(): SerializedDeletionNode;
}

HighlightNode

class HighlightNode extends MarkNode {
  __comment: string | null;
  static getType(): string;
  static clone(node: HighlightNode): HighlightNode;
  createDOM(): HTMLElement;
  updateDOM(_prevNode: this, dom: HTMLElement, _config: EditorConfig): boolean;
  static importJSON(serializedNode: SerializedHighlightNode): HighlightNode;
  exportJSON(): SerializedHighlightNode;
  getComment(): string | null;
  setComment(comment: string | null): this;
}

InsertionNode

class InsertionNode extends MarkNode {
  static getType(): string;
  static clone(node: InsertionNode): InsertionNode;
  createDOM(): HTMLElement;
  static importJSON(serializedNode: SerializedInsertionNode): InsertionNode;
  exportJSON(): SerializedInsertionNode;
  canInsertTextBefore(): boolean;
  canInsertTextAfter(): boolean;
}

Values

COMMENT_TRANSFORMER

const COMMENT_TRANSFORMER: Transformer;

CONTENT_CSS

const CONTENT_CSS = "\ndj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success-600, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-neutral-100, #f3f4f6); }

criticMarkupPlugin

const criticMarkupPlugin: RichTextPlugin;

criticMarkupTransformers

const criticMarkupTransformers: Transformer[];

DELETION_TRANSFORMER

const DELETION_TRANSFORMER: Transformer;

HIGHLIGHT_TRANSFORMER

const HIGHLIGHT_TRANSFORMER: Transformer;

INSERTION_TRANSFORMER

const INSERTION_TRANSFORMER: Transformer;

PARAGRAPH_TOKEN

const PARAGRAPH_TOKEN = "\u00B6";

SKIP_TAG

const SKIP_TAG = "dj-criticmarkup-suggestion";

SUBSTITUTION_TRANSFORMER

const SUBSTITUTION_TRANSFORMER: Transformer;

Types

CommentPopupController

interface CommentPopupController {
  element: HTMLElement;
  open(options: CommentPopupOpenOptions): void;
}

CommentPopupMode

type CommentPopupMode = "insert-bare" | "insert-anchored" | "edit-comment" | "edit-highlight";

CommentPopupOpenOptions

interface CommentPopupOpenOptions {
  anchor: HTMLElement;
  mode: CommentPopupMode;
  text: string;
  node?: LexicalNode;
}

CriticMarkupChangeDetail

interface CriticMarkupChangeDetail {
  kind: MarkKind;
  action: "accept" | "decline";
}

CriticMarkupOptions

interface CriticMarkupOptions {
  suggesting?: boolean;
  structuralEdits?: StructuralPolicy;
  paragraphToken?: string;
  confirmBulk?: boolean;
  toolbar?: boolean;
}

CriticMarkupRefusedDetail

interface CriticMarkupRefusedDetail {
  reason: "nested" | "block-spanning";
  start: number;
  end: number;
}

DeserializeCriticMarkupOptions

interface DeserializeCriticMarkupOptions {
  paragraphToken?: string;
}

InlineFormatSegment

interface InlineFormatSegment {
  text: string;
  format: number;
}

Mark

interface Mark {
  kind: MarkKind;
  start: number;
  end: number;
  spansBlock: boolean;
  nested: boolean;
  text?: string;
  old?: string;
  new?: string;
}

MarkKind

type MarkKind = "insertion" | "deletion" | "substitution" | "comment" | "highlight";

SerializedBreakNode

type SerializedBreakNode = SerializedLexicalNode;

SerializedCommentNode

type SerializedCommentNode = Spread<{
  text: string;
}, SerializedLexicalNode>;

SerializedDeletionNode

type SerializedDeletionNode = SerializedElementNode;

SerializedHighlightNode

type SerializedHighlightNode = Spread<{
  comment: string | null;
}, SerializedElementNode>;

SerializedInsertionNode

type SerializedInsertionNode = SerializedElementNode;

StructuralPolicy

type StructuralPolicy = "mark" | "annotate" | "block" | "apply";

SuggestionModeOptions

interface SuggestionModeOptions {
  structuralEdits?: StructuralPolicy;
}