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.
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 withcreateCriticMarkupPlugin({ suggesting: true }). Typed and deleted text then becomes marks instead of changing the text directly. - Resolve one mark with
markAtSelection,acceptMark, anddeclineMark, or the whole document withacceptAllMarksanddeclineAllMarks. - 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.editCommentchanges a comment.removeCommentremoves only the comment and keeps the highlight;removeHighlightremoves both.- A comment cannot contain
<<}or{>>, because either would break the mark the next time the text is read.isValidCommentTextchecks 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
structuralEditsoption (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¶. CalltoPortableCriticMarkup(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 setstructuralEdits: "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-refusedfires.
Formats
- The plugin adds its own
criticmarkupformat (format="criticmarkup"), so it works without the Markdown plugin. - To read CriticMarkup inside
@dojo-ng/rich-text-markdown's format, addcriticMarkupTransformersto 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.jsonis included, so a port to another language can run the same test cases.
Setup
- Setting
pluginsreplaces the default set, so spread...defaultPluginsto 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;
}