@dojo-ng/rich-text-criticmarkup 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/comment-authoring.d.ts +21 -0
- package/dist/comment-authoring.js +79 -0
- package/dist/comment-popup.d.ts +29 -0
- package/dist/comment-popup.js +120 -0
- package/dist/format.d.ts +42 -0
- package/dist/format.js +65 -0
- package/dist/grammar.d.ts +122 -0
- package/dist/grammar.js +399 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/nodes.d.ts +137 -0
- package/dist/nodes.js +325 -0
- package/dist/plugin.d.ts +27 -0
- package/dist/plugin.js +288 -0
- package/dist/resolution.d.ts +34 -0
- package/dist/resolution.js +207 -0
- package/dist/suggestion-mode.d.ts +29 -0
- package/dist/suggestion-mode.js +417 -0
- package/dist/transformers.d.ts +26 -0
- package/dist/transformers.js +187 -0
- package/fixtures/conformance.json +67 -0
- package/package.json +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @dojo-ng/rich-text-criticmarkup
|
|
2
|
+
|
|
3
|
+
CriticMarkup tracked-changes plugin for @dojo-ng/rich-text
|
|
4
|
+
|
|
5
|
+
Part of [Dojo NG](../../README.md), a framework-agnostic web component library. BSD-3-Clause.
|
|
6
|
+
|
|
7
|
+
> An opt-in plugin for `@dojo-ng/rich-text` (not a custom element). Tracks changes in CriticMarkup, the five-mark plain-text convention: `{++inserted++}`, `{--deleted--}`, `{~~old~>new~~}` (a substitution — imported as a deletion immediately followed by an insertion, and resolved as that pair, never as a lone half), `{>>comment<<}` (bare, or anchored right after a highlight), and `{==highlighted==}` (an annotation, kept on both accept and decline). Turn suggestion mode on with `setSuggestionMode(editor, true)` (or `createCriticMarkupPlugin({ suggesting: true })`): typed/deleted text is wrapped as marks instead of applied directly. Resolve one mark with `markAtSelection`/`acceptMark`/`declineMark`, or the whole document with `acceptAllMarks`/`declineAllMarks` — a bare comment is left standing either way, and a highlight's own anchored comment goes with it, since it is state on the node, not a separate mark. A paragraph split or merge proposed in suggestion mode is carried as a token (`¶`) inside a ONE-LINE mark rather than a real newline, because `@lexical/markdown` splits the document on `\n` before any transformer runs and a mark spanning that split could never be seen on import; `structuralEdits` (plugin option, default `"mark"`) controls this — `"annotate"` lets the edit happen untracked and drops a bare comment at the boundary instead, `"block"` refuses it outright, `"apply"` allows it silently. THE PARAGRAPH TOKEN IS A DIALECT, NOT CRITICMARKUP: no other CriticMarkup tool knows it, so a document written this way shows a literal `¶` to anything else — call `toPortableCriticMarkup(value)` to convert back to plain CriticMarkup before handing it to another tool (splitting a multi-paragraph mark into one mark per block, at the cost of a blank line left behind on decline — the price of interop), or set `structuralEdits: "annotate"` so this plugin never emits the token at all. A LITERAL PILCROW AN AUTHOR TYPES IS WRITTEN DOUBLED (`¶¶`) so it round-trips as itself rather than a break. A mark nested inside another (same kind or different) is detected and refused as its own node — masked to a private-use sentinel on import, unmasked back to literal delimiter text on export, rather than mangled the way a naive regex import would mangle it — firing `dj-criticmarkup-refused`. The grammar (`parseMarks`, `accept`/`decline`/`acceptAll`/`declineAll`, `stripComments`, the token functions) is a standalone string API with no Lexical import anywhere in it, for a consumer with markdown in hand and no editor — a build step, a server, a CLI; `fixtures/conformance.json` ships in the package so a second-language port of the same grammar (this plugin's own origin: a clean-room port of NovelMaker's Python `critic.py`) can run the identical cases. Comments are authored in the editor: `insertComment(editor, text)` inserts bare at a collapsed caret or anchors `{==selection==}{>>note<<}` over a range, `editComment` changes an existing note, and the two deletes are separately labeled because they are genuinely different — `removeComment` drops just the note and leaves the highlight, `removeHighlight` drops both. A NOTE BODY CANNOT CONTAIN `<<}` OR `{>>` (either would break its own mark on the next round trip); `isValidCommentText` checks this against the same grammar the string API uses, and an invalid save is refused inline, naming the offending sequence. To recognize CriticMarkup inside `@dojo-ng/rich-text-markdown`'s own general markdown format, compose `criticMarkupTransformers` into its transformer set; this plugin also registers its own `criticmarkup` format (`format="criticmarkup"`) so tracked changes work with no markdown plugin loaded at all. Setting `plugins` REPLACES the default set, so spread `...defaultPlugins` to keep bold/italic/underline + undo/redo.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @dojo-ng/rich-text-criticmarkup
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Compose the plugin with the default set, starting in suggestion mode: typed/deleted text is wrapped as marks instead of applied directly.
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<dj-rich-text id="editor" label="Draft"></dj-rich-text>
|
|
21
|
+
<script type="module">
|
|
22
|
+
import "@dojo-ng/rich-text";
|
|
23
|
+
import { defaultPlugins } from "@dojo-ng/rich-text";
|
|
24
|
+
import { createCriticMarkupPlugin } from "@dojo-ng/rich-text-criticmarkup";
|
|
25
|
+
const el = document.getElementById("editor");
|
|
26
|
+
el.plugins = [...defaultPlugins, createCriticMarkupPlugin({ suggesting: true })];
|
|
27
|
+
el.value = "The quick brown fox.";
|
|
28
|
+
</script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Examples
|
|
32
|
+
|
|
33
|
+
### Resolve CriticMarkup with no editor
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
import { parseMarks, acceptAll, declineAll } from "@dojo-ng/rich-text-criticmarkup";
|
|
39
|
+
|
|
40
|
+
const draft = "The {--old--}{++new++} plan is set.";
|
|
41
|
+
acceptAll(draft); // "The new plan is set."
|
|
42
|
+
declineAll(draft); // "The old plan is set."
|
|
43
|
+
parseMarks(draft).map((m) => m.kind); // ["deletion", "insertion"]
|
|
44
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment authoring (Track T4, decision 15): insert (bare or anchored), edit, and the two deletes.
|
|
3
|
+
* The UI shell (the popup with its text area and buttons) is Track T3/T4's `nodes.ts`/toolbar
|
|
4
|
+
* concern; these are the plain editor-level operations it calls.
|
|
5
|
+
*/
|
|
6
|
+
import { type LexicalEditor, type LexicalNode } from "lexical";
|
|
7
|
+
/** Whether `text` is safe as a note body: `"{>>" + text + "<<}"` must parse as EXACTLY one comment
|
|
8
|
+
* mark spanning the whole string. A note containing `<<}` would terminate its own mark early on the
|
|
9
|
+
* next round trip; one containing `{>>` nests. This is the grammar module earning its place a second
|
|
10
|
+
* time (decision 2): the editor and the string API agree because they call the same parser. */
|
|
11
|
+
export declare function isValidCommentText(text: string): boolean;
|
|
12
|
+
/** Insert a comment at the current selection: bare at a collapsed caret, anchored (a `HighlightNode`
|
|
13
|
+
* carrying the note) over a range. */
|
|
14
|
+
export declare function insertComment(editor: LexicalEditor, text: string): void;
|
|
15
|
+
/** Change an existing note's text — a bare `CommentNode`'s own text, or a `HighlightNode`'s anchored
|
|
16
|
+
* comment. */
|
|
17
|
+
export declare function editComment(editor: LexicalEditor, node: LexicalNode, text: string): void;
|
|
18
|
+
/** "Remove note": drop an anchored comment's note, leaving the highlight and its text standing. */
|
|
19
|
+
export declare function removeComment(editor: LexicalEditor, node: LexicalNode): void;
|
|
20
|
+
/** "Remove highlight": drop both the note and the highlight, leaving the text unmarked. */
|
|
21
|
+
export declare function removeHighlight(editor: LexicalEditor, node: LexicalNode): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment authoring (Track T4, decision 15): insert (bare or anchored), edit, and the two deletes.
|
|
3
|
+
* The UI shell (the popup with its text area and buttons) is Track T3/T4's `nodes.ts`/toolbar
|
|
4
|
+
* concern; these are the plain editor-level operations it calls.
|
|
5
|
+
*/
|
|
6
|
+
import { $getNodeByKey, $getSelection, $isRangeSelection } from "lexical";
|
|
7
|
+
import { parseMarks } from "./grammar.js";
|
|
8
|
+
import { $createCommentNode, $createHighlightNode, $isCommentNode, $isHighlightNode } from "./nodes.js";
|
|
9
|
+
/** Whether `text` is safe as a note body: `"{>>" + text + "<<}"` must parse as EXACTLY one comment
|
|
10
|
+
* mark spanning the whole string. A note containing `<<}` would terminate its own mark early on the
|
|
11
|
+
* next round trip; one containing `{>>` nests. This is the grammar module earning its place a second
|
|
12
|
+
* time (decision 2): the editor and the string API agree because they call the same parser. */
|
|
13
|
+
export function isValidCommentText(text) {
|
|
14
|
+
const candidate = `{>>${text}<<}`;
|
|
15
|
+
const marks = parseMarks(candidate);
|
|
16
|
+
return marks.length === 1 && marks[0].kind === "comment" && marks[0].start === 0 && marks[0].end === candidate.length;
|
|
17
|
+
}
|
|
18
|
+
function assertValidCommentText(text) {
|
|
19
|
+
if (isValidCommentText(text))
|
|
20
|
+
return;
|
|
21
|
+
const offending = text.includes("<<}") ? "<<}" : text.includes("{>>") ? "{>>" : "a sequence that breaks the mark";
|
|
22
|
+
throw new Error(`Comment text is not valid: it contains "${offending}", which would not round-trip through value.`);
|
|
23
|
+
}
|
|
24
|
+
/** Insert a comment at the current selection: bare at a collapsed caret, anchored (a `HighlightNode`
|
|
25
|
+
* carrying the note) over a range. */
|
|
26
|
+
export function insertComment(editor, text) {
|
|
27
|
+
assertValidCommentText(text);
|
|
28
|
+
editor.update(() => {
|
|
29
|
+
const selection = $getSelection();
|
|
30
|
+
if (!$isRangeSelection(selection))
|
|
31
|
+
return;
|
|
32
|
+
if (selection.isCollapsed()) {
|
|
33
|
+
selection.insertNodes([$createCommentNode(text)]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const nodes = selection.extract();
|
|
37
|
+
if (nodes.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
const highlightNode = $createHighlightNode();
|
|
40
|
+
highlightNode.setComment(text);
|
|
41
|
+
nodes[0].insertBefore(highlightNode);
|
|
42
|
+
for (const node of nodes)
|
|
43
|
+
highlightNode.append(node);
|
|
44
|
+
}, { discrete: true });
|
|
45
|
+
}
|
|
46
|
+
/** Change an existing note's text — a bare `CommentNode`'s own text, or a `HighlightNode`'s anchored
|
|
47
|
+
* comment. */
|
|
48
|
+
export function editComment(editor, node, text) {
|
|
49
|
+
assertValidCommentText(text);
|
|
50
|
+
editor.update(() => {
|
|
51
|
+
const fresh = $getNodeByKey(node.getKey());
|
|
52
|
+
if ($isCommentNode(fresh))
|
|
53
|
+
fresh.setText(text);
|
|
54
|
+
else if ($isHighlightNode(fresh))
|
|
55
|
+
fresh.setComment(text);
|
|
56
|
+
}, { discrete: true });
|
|
57
|
+
}
|
|
58
|
+
/** "Remove note": drop an anchored comment's note, leaving the highlight and its text standing. */
|
|
59
|
+
export function removeComment(editor, node) {
|
|
60
|
+
editor.update(() => {
|
|
61
|
+
const fresh = $getNodeByKey(node.getKey());
|
|
62
|
+
if ($isHighlightNode(fresh))
|
|
63
|
+
fresh.setComment(null);
|
|
64
|
+
}, { discrete: true });
|
|
65
|
+
}
|
|
66
|
+
/** "Remove highlight": drop both the note and the highlight, leaving the text unmarked. */
|
|
67
|
+
export function removeHighlight(editor, node) {
|
|
68
|
+
editor.update(() => {
|
|
69
|
+
const fresh = $getNodeByKey(node.getKey());
|
|
70
|
+
if ($isHighlightNode(fresh))
|
|
71
|
+
unwrap(fresh);
|
|
72
|
+
}, { discrete: true });
|
|
73
|
+
}
|
|
74
|
+
/** Replace `node` with its own children, in place (the standard Lexical "unlink" shape). */
|
|
75
|
+
function unwrap(node) {
|
|
76
|
+
for (const child of node.getChildren())
|
|
77
|
+
node.insertBefore(child);
|
|
78
|
+
node.remove();
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment popup shell (Track T3, filled in by T4): ONE shared `dj-popup` holding a
|
|
3
|
+
* `dj-text-area` and Save/Cancel/"Remove note"/"Remove highlight" controls, opened against
|
|
4
|
+
* whichever control triggered it — the toolbar's add-comment button, a bare `CommentNode`'s own
|
|
5
|
+
* button, or a `HighlightNode` with a comment. Built once by the plugin's `setup()` (decision 15's
|
|
6
|
+
* "the popup and its focus handling exist in one place"), not per node instance.
|
|
7
|
+
*
|
|
8
|
+
* Focus management (decision 15): opening moves focus to the text area; Escape closes without
|
|
9
|
+
* saving; Save and Cancel both return focus to the control that opened the popup.
|
|
10
|
+
*/
|
|
11
|
+
import "@dojo-ng/popup";
|
|
12
|
+
import "@dojo-ng/text-area";
|
|
13
|
+
import "@dojo-ng/button";
|
|
14
|
+
import type { LexicalEditor, LexicalNode } from "lexical";
|
|
15
|
+
export type CommentPopupMode = "insert-bare" | "insert-anchored" | "edit-comment" | "edit-highlight";
|
|
16
|
+
export interface CommentPopupOpenOptions {
|
|
17
|
+
anchor: HTMLElement;
|
|
18
|
+
mode: CommentPopupMode;
|
|
19
|
+
/** The current text to seed the field with — "" for an insert. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Required for "edit-comment"/"edit-highlight"; ignored for the insert modes. */
|
|
22
|
+
node?: LexicalNode;
|
|
23
|
+
}
|
|
24
|
+
export interface CommentPopupController {
|
|
25
|
+
/** The `dj-popup` element — append it once, anywhere reachable in the DOM. */
|
|
26
|
+
element: HTMLElement;
|
|
27
|
+
open(options: CommentPopupOpenOptions): void;
|
|
28
|
+
}
|
|
29
|
+
export declare function createCommentPopupController(editor: LexicalEditor, msg: (key: string) => string): CommentPopupController;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment popup shell (Track T3, filled in by T4): ONE shared `dj-popup` holding a
|
|
3
|
+
* `dj-text-area` and Save/Cancel/"Remove note"/"Remove highlight" controls, opened against
|
|
4
|
+
* whichever control triggered it — the toolbar's add-comment button, a bare `CommentNode`'s own
|
|
5
|
+
* button, or a `HighlightNode` with a comment. Built once by the plugin's `setup()` (decision 15's
|
|
6
|
+
* "the popup and its focus handling exist in one place"), not per node instance.
|
|
7
|
+
*
|
|
8
|
+
* Focus management (decision 15): opening moves focus to the text area; Escape closes without
|
|
9
|
+
* saving; Save and Cancel both return focus to the control that opened the popup.
|
|
10
|
+
*/
|
|
11
|
+
import "@dojo-ng/popup";
|
|
12
|
+
import "@dojo-ng/text-area";
|
|
13
|
+
import "@dojo-ng/button";
|
|
14
|
+
import { editComment, insertComment, removeComment, removeHighlight } from "./comment-authoring.js";
|
|
15
|
+
export function createCommentPopupController(editor, msg) {
|
|
16
|
+
const popup = document.createElement("dj-popup");
|
|
17
|
+
popup.position = "below";
|
|
18
|
+
popup.className = "dj-cm-comment-popup";
|
|
19
|
+
const panel = document.createElement("div");
|
|
20
|
+
panel.className = "dj-cm-comment-popup-panel";
|
|
21
|
+
const textArea = document.createElement("dj-text-area");
|
|
22
|
+
textArea.setAttribute("label", msg("comment"));
|
|
23
|
+
textArea.setAttribute("label-hidden", "");
|
|
24
|
+
textArea.setAttribute("rows", "3");
|
|
25
|
+
const error = document.createElement("div");
|
|
26
|
+
error.className = "dj-cm-comment-popup-error";
|
|
27
|
+
error.setAttribute("role", "alert");
|
|
28
|
+
error.hidden = true;
|
|
29
|
+
const actions = document.createElement("div");
|
|
30
|
+
actions.className = "dj-cm-comment-popup-actions";
|
|
31
|
+
const saveButton = document.createElement("dj-button");
|
|
32
|
+
saveButton.setAttribute("kind", "contained");
|
|
33
|
+
saveButton.textContent = msg("save");
|
|
34
|
+
const cancelButton = document.createElement("dj-button");
|
|
35
|
+
cancelButton.setAttribute("kind", "text");
|
|
36
|
+
cancelButton.textContent = msg("cancel");
|
|
37
|
+
const removeNoteButton = document.createElement("dj-button");
|
|
38
|
+
removeNoteButton.setAttribute("kind", "text");
|
|
39
|
+
removeNoteButton.textContent = msg("removeNote");
|
|
40
|
+
const removeHighlightButton = document.createElement("dj-button");
|
|
41
|
+
removeHighlightButton.setAttribute("kind", "text");
|
|
42
|
+
removeHighlightButton.textContent = msg("removeHighlight");
|
|
43
|
+
actions.append(saveButton, cancelButton, removeNoteButton, removeHighlightButton);
|
|
44
|
+
panel.append(textArea, error, actions);
|
|
45
|
+
popup.append(panel);
|
|
46
|
+
let state = {
|
|
47
|
+
mode: "insert-bare",
|
|
48
|
+
node: null,
|
|
49
|
+
returnFocus: null,
|
|
50
|
+
};
|
|
51
|
+
function showError(message) {
|
|
52
|
+
error.textContent = message;
|
|
53
|
+
error.hidden = false;
|
|
54
|
+
}
|
|
55
|
+
function clearError() {
|
|
56
|
+
error.hidden = true;
|
|
57
|
+
error.textContent = "";
|
|
58
|
+
}
|
|
59
|
+
function close() {
|
|
60
|
+
popup.open = false;
|
|
61
|
+
state.returnFocus?.focus();
|
|
62
|
+
}
|
|
63
|
+
function save() {
|
|
64
|
+
clearError();
|
|
65
|
+
const text = textArea.value;
|
|
66
|
+
try {
|
|
67
|
+
switch (state.mode) {
|
|
68
|
+
case "insert-bare":
|
|
69
|
+
case "insert-anchored":
|
|
70
|
+
insertComment(editor, text);
|
|
71
|
+
break;
|
|
72
|
+
case "edit-comment":
|
|
73
|
+
case "edit-highlight":
|
|
74
|
+
if (state.node)
|
|
75
|
+
editComment(editor, state.node, text);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
showError(err instanceof Error ? err.message : String(err));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
close();
|
|
84
|
+
}
|
|
85
|
+
function doRemoveNote() {
|
|
86
|
+
if (state.node)
|
|
87
|
+
removeComment(editor, state.node);
|
|
88
|
+
close();
|
|
89
|
+
}
|
|
90
|
+
function doRemoveHighlight() {
|
|
91
|
+
if (state.node)
|
|
92
|
+
removeHighlight(editor, state.node);
|
|
93
|
+
close();
|
|
94
|
+
}
|
|
95
|
+
saveButton.addEventListener("click", save);
|
|
96
|
+
cancelButton.addEventListener("click", close);
|
|
97
|
+
removeNoteButton.addEventListener("click", doRemoveNote);
|
|
98
|
+
removeHighlightButton.addEventListener("click", doRemoveHighlight);
|
|
99
|
+
popup.addEventListener("dj-close", () => {
|
|
100
|
+
state.returnFocus?.focus();
|
|
101
|
+
});
|
|
102
|
+
textArea.addEventListener("keydown", (e) => {
|
|
103
|
+
if (e.key === "Escape") {
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
close();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
function open(options) {
|
|
109
|
+
state = { mode: options.mode, node: options.node ?? null, returnFocus: options.anchor };
|
|
110
|
+
clearError();
|
|
111
|
+
textArea.value = options.text;
|
|
112
|
+
const showDeletes = options.mode === "edit-highlight";
|
|
113
|
+
removeNoteButton.hidden = !showDeletes;
|
|
114
|
+
removeHighlightButton.hidden = !showDeletes;
|
|
115
|
+
popup.anchor = options.anchor;
|
|
116
|
+
popup.open = true;
|
|
117
|
+
queueMicrotask(() => textArea.focus());
|
|
118
|
+
}
|
|
119
|
+
return { element: popup, open };
|
|
120
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "criticmarkup" format's import/export walk (Track N3). Decision 13: this plugin registers its
|
|
3
|
+
* own `criticmarkup` format so `format="criticmarkup"` works with no markdown plugin loaded at all —
|
|
4
|
+
* these functions match `RichTextFormat`'s `serialize`/`deserialize` shape but stay free of a
|
|
5
|
+
* `@dojo-ng/rich-text` dependency; whichever track assembles the full plugin object wires them into
|
|
6
|
+
* `formats: { criticmarkup: { serialize, deserialize } }`.
|
|
7
|
+
*
|
|
8
|
+
* Both functions are `$`-prefixed-helper callers, not scope-openers: `deserializeCriticMarkup` runs
|
|
9
|
+
* inside an `editor.update`, `serializeCriticMarkup` inside an `editor.getEditorState().read` — the
|
|
10
|
+
* same contract every other `RichTextFormat` in this codebase follows.
|
|
11
|
+
*/
|
|
12
|
+
import { type LexicalEditor } from "lexical";
|
|
13
|
+
export interface CriticMarkupRefusedDetail {
|
|
14
|
+
/** "nested": a same- or different-kind nested mark, masked rather than imported as its own node.
|
|
15
|
+
* "block-spanning": a mark `tokenizeBlockSpanning` left spanning a block — unreachable with the
|
|
16
|
+
* default paragraph token, since tokenizing eliminates every case that reason describes; kept for
|
|
17
|
+
* a host that configures `paragraphToken: ""` and gets the pre-decision-16 behavior back. */
|
|
18
|
+
reason: "nested" | "block-spanning";
|
|
19
|
+
/** Offsets into the tokenized text this pipeline actually masked and imported — not the caller's
|
|
20
|
+
* original `data`, which tokenizing may have shortened. */
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
}
|
|
24
|
+
declare global {
|
|
25
|
+
interface GlobalEventHandlersEventMap {
|
|
26
|
+
"dj-criticmarkup-refused": CustomEvent<CriticMarkupRefusedDetail>;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export interface DeserializeCriticMarkupOptions {
|
|
30
|
+
/** The paragraph-break token (decision 16). Default `PARAGRAPH_TOKEN`. */
|
|
31
|
+
paragraphToken?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Import `data` as CriticMarkup: `tokenizeBlockSpanning`, then `maskNested`, then the markdown
|
|
35
|
+
* conversion through `criticMarkupTransformers`, then an unmask walk over the resulting text nodes.
|
|
36
|
+
* Emits `dj-criticmarkup-refused` on `editor.getRootElement()` for anything the grammar reports as
|
|
37
|
+
* nested or (in the `paragraphToken: ""` case) still block-spanning — computed from the tokenized
|
|
38
|
+
* text BEFORE masking, since masking is what makes those marks invisible to `parseMarks` afterward.
|
|
39
|
+
*/
|
|
40
|
+
export declare function deserializeCriticMarkup(editor: LexicalEditor, data: string, options?: DeserializeCriticMarkupOptions): void;
|
|
41
|
+
/** The plain export walk: the markdown conversion through `criticMarkupTransformers`. */
|
|
42
|
+
export declare function serializeCriticMarkup(_editor: LexicalEditor): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "criticmarkup" format's import/export walk (Track N3). Decision 13: this plugin registers its
|
|
3
|
+
* own `criticmarkup` format so `format="criticmarkup"` works with no markdown plugin loaded at all —
|
|
4
|
+
* these functions match `RichTextFormat`'s `serialize`/`deserialize` shape but stay free of a
|
|
5
|
+
* `@dojo-ng/rich-text` dependency; whichever track assembles the full plugin object wires them into
|
|
6
|
+
* `formats: { criticmarkup: { serialize, deserialize } }`.
|
|
7
|
+
*
|
|
8
|
+
* Both functions are `$`-prefixed-helper callers, not scope-openers: `deserializeCriticMarkup` runs
|
|
9
|
+
* inside an `editor.update`, `serializeCriticMarkup` inside an `editor.getEditorState().read` — the
|
|
10
|
+
* same contract every other `RichTextFormat` in this codebase follows.
|
|
11
|
+
*/
|
|
12
|
+
import { $getRoot, $isElementNode, $isTextNode } from "lexical";
|
|
13
|
+
import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown";
|
|
14
|
+
import { parseMarks, tokenizeBlockSpanning, maskNested, unmaskNested, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
15
|
+
import { criticMarkupTransformers } from "./transformers.js";
|
|
16
|
+
/**
|
|
17
|
+
* Import `data` as CriticMarkup: `tokenizeBlockSpanning`, then `maskNested`, then the markdown
|
|
18
|
+
* conversion through `criticMarkupTransformers`, then an unmask walk over the resulting text nodes.
|
|
19
|
+
* Emits `dj-criticmarkup-refused` on `editor.getRootElement()` for anything the grammar reports as
|
|
20
|
+
* nested or (in the `paragraphToken: ""` case) still block-spanning — computed from the tokenized
|
|
21
|
+
* text BEFORE masking, since masking is what makes those marks invisible to `parseMarks` afterward.
|
|
22
|
+
*/
|
|
23
|
+
export function deserializeCriticMarkup(editor, data, options = {}) {
|
|
24
|
+
const token = options.paragraphToken ?? PARAGRAPH_TOKEN;
|
|
25
|
+
const tokenized = tokenizeBlockSpanning(data, token);
|
|
26
|
+
const refusals = refusalsIn(tokenized);
|
|
27
|
+
const { masked } = maskNested(tokenized);
|
|
28
|
+
$convertFromMarkdownString(masked, criticMarkupTransformers);
|
|
29
|
+
unmaskTextNodes($getRoot());
|
|
30
|
+
for (const detail of refusals)
|
|
31
|
+
dispatchRefused(editor, detail);
|
|
32
|
+
}
|
|
33
|
+
/** The plain export walk: the markdown conversion through `criticMarkupTransformers`. */
|
|
34
|
+
export function serializeCriticMarkup(_editor) {
|
|
35
|
+
return $convertToMarkdownString(criticMarkupTransformers);
|
|
36
|
+
}
|
|
37
|
+
function refusalsIn(text) {
|
|
38
|
+
const marks = parseMarks(text);
|
|
39
|
+
const refusals = [];
|
|
40
|
+
for (const mark of marks) {
|
|
41
|
+
const container = marks.find((p) => p !== mark && p.start <= mark.start && mark.end <= p.end);
|
|
42
|
+
if (container)
|
|
43
|
+
refusals.push({ reason: "nested", start: mark.start, end: mark.end });
|
|
44
|
+
else if (mark.spansBlock)
|
|
45
|
+
refusals.push({ reason: "block-spanning", start: mark.start, end: mark.end });
|
|
46
|
+
}
|
|
47
|
+
return refusals;
|
|
48
|
+
}
|
|
49
|
+
function unmaskTextNodes(node) {
|
|
50
|
+
if ($isTextNode(node)) {
|
|
51
|
+
const current = node.getTextContent();
|
|
52
|
+
const unmasked = unmaskNested(current);
|
|
53
|
+
if (unmasked !== current)
|
|
54
|
+
node.setTextContent(unmasked);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if ($isElementNode(node)) {
|
|
58
|
+
for (const child of node.getChildren())
|
|
59
|
+
unmaskTextNodes(child);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function dispatchRefused(editor, detail) {
|
|
63
|
+
const root = editor.getRootElement();
|
|
64
|
+
root?.dispatchEvent(new CustomEvent("dj-criticmarkup-refused", { detail, bubbles: true, composed: true }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CriticMarkup grammar: parsing the five marks, and resolving them.
|
|
3
|
+
*
|
|
4
|
+
* No Lexical import here — this module is a plain string API so a consumer with markdown in hand
|
|
5
|
+
* and no editor (a build step, a server, a CLI) can parse and resolve CriticMarkup too. The editor
|
|
6
|
+
* package (Track N) calls into this rather than keeping its own copy.
|
|
7
|
+
*
|
|
8
|
+
* Not a regex per mark: a lazily-matched regex pairs the FIRST close token it finds, so
|
|
9
|
+
* `{--a {--b--} c--}` mis-pairs with the INNER close and leaves ` c--}` sitting in the prose as
|
|
10
|
+
* literal characters. `parseMarks` is a small stack machine instead: it opens a frame on any of the
|
|
11
|
+
* five open tokens and closes the frame ON TOP OF THE STACK on its matching close token, so a nested
|
|
12
|
+
* mark — same kind or different — always closes before the mark around it does.
|
|
13
|
+
*/
|
|
14
|
+
export type MarkKind = "insertion" | "deletion" | "substitution" | "comment" | "highlight";
|
|
15
|
+
export interface Mark {
|
|
16
|
+
kind: MarkKind;
|
|
17
|
+
/** Offsets into the ORIGINAL string; text.slice(start, end) is the mark as written. */
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
/** True when a blank line falls inside the mark's own span. */
|
|
21
|
+
spansBlock: boolean;
|
|
22
|
+
/** True when this mark contains another mark. */
|
|
23
|
+
nested: boolean;
|
|
24
|
+
/** insertion | deletion | comment | highlight */
|
|
25
|
+
text?: string;
|
|
26
|
+
/** substitution only */
|
|
27
|
+
old?: string;
|
|
28
|
+
new?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every CriticMarkup mark in `text`, in reading order — nested marks included as their own entries,
|
|
32
|
+
* alongside the outer mark that contains them.
|
|
33
|
+
*
|
|
34
|
+
* An unterminated mark — an open token with no matching close anywhere after it — produces no entry
|
|
35
|
+
* at all, and is left as the literal characters it is. The alternative, scanning until some LATER,
|
|
36
|
+
* unrelated mark's close token turns up, would swallow everything in between as one giant mark,
|
|
37
|
+
* which is worse than finding nothing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseMarks(text: string): Mark[];
|
|
40
|
+
/**
|
|
41
|
+
* Apply `mark`: keep an insertion's (or a substitution's new) text; drop a deletion; keep a
|
|
42
|
+
* highlight's text, its own anchored comment going with it; leave a bare comment untouched.
|
|
43
|
+
*/
|
|
44
|
+
export declare function accept(text: string, mark: Mark): string;
|
|
45
|
+
/**
|
|
46
|
+
* Reject `mark`: restore a deletion's (or a substitution's old) text; drop an insertion; keep a
|
|
47
|
+
* highlight's text (and its own anchored comment, if any); leave a bare comment untouched.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decline(text: string, mark: Mark): string;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve every mark as `accept` would, nested ones included. Idempotent: a body with no marks is
|
|
52
|
+
* returned unchanged.
|
|
53
|
+
*
|
|
54
|
+
* Re-parses after each single resolution rather than applying a batch of offsets: resolving an
|
|
55
|
+
* outer mark moves everything after its opening delimiter, so a stale offset for a nested mark would
|
|
56
|
+
* land on the wrong span.
|
|
57
|
+
*/
|
|
58
|
+
export declare function acceptAll(text: string): string;
|
|
59
|
+
/** `acceptAll`'s opposite. See its docstring for why this re-parses. */
|
|
60
|
+
export declare function declineAll(text: string): string;
|
|
61
|
+
/** Remove every bare comment mark, right to left, leaving everything else untouched. */
|
|
62
|
+
export declare function stripComments(text: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Split a block-spanning insertion, deletion, or highlight into one mark per block, so every mark
|
|
65
|
+
* ends up on a single line: `{++A\n\nB++}` becomes `{++A++}\n\n{++B++}`, which renders identically.
|
|
66
|
+
*
|
|
67
|
+
* Superseded as the import-time normalization by `tokenizeBlockSpanning` (decision 16); this
|
|
68
|
+
* function is unchanged from decision 8 and survives only as the step `toPortableCriticMarkup`
|
|
69
|
+
* uses to turn the dialect back into plain CriticMarkup.
|
|
70
|
+
*
|
|
71
|
+
* A substitution is left alone — splitting one means deciding how `old` and `new` pair up block for
|
|
72
|
+
* block, which is not always well defined — and so is a comment, which has no per-block content to
|
|
73
|
+
* repeat. Both arrive as literal delimiter characters: visible to the author, never silently merged
|
|
74
|
+
* into the prose.
|
|
75
|
+
*
|
|
76
|
+
* Declining the SPLIT form leaves the blank line between the pieces where declining the unsplit form
|
|
77
|
+
* would have removed it too — no prose is lost either way, but it is a known, accepted cost of this
|
|
78
|
+
* route, not an oversight.
|
|
79
|
+
*/
|
|
80
|
+
export declare function normalizeBlockSpanning(text: string): string;
|
|
81
|
+
/** The paragraph-break token: a break carried inside a mark instead of a real newline. */
|
|
82
|
+
export declare const PARAGRAPH_TOKEN = "\u00B6";
|
|
83
|
+
/** A literal token character in prose is escaped by doubling, so it round-trips through a mark. */
|
|
84
|
+
export declare function escapeToken(text: string, token?: string): string;
|
|
85
|
+
/** The exact inverse of `escapeToken`: a doubled token becomes one literal character again. */
|
|
86
|
+
export declare function unescapeToken(text: string, token?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Real newlines inside a mark become the paragraph token, one mark's content at a time — decision
|
|
89
|
+
* 16's replacement for `normalizeBlockSpanning`'s splitting at import time. Unlike that route, this
|
|
90
|
+
* applies to all five kinds: a block-spanning substitution or comment tokenizes too, since the break
|
|
91
|
+
* is now an ordinary character inside a one-line mark rather than a structural split.
|
|
92
|
+
*
|
|
93
|
+
* Escapes any existing literal token character WITHIN a spanning mark's own content first, so a
|
|
94
|
+
* literal pilcrow sitting next to the blank line being converted is not confused with the new
|
|
95
|
+
* structural break — scoped to that one mark's content, not the whole document: a document that
|
|
96
|
+
* ROUND-TRIPS through this dialect already satisfies "single token is structural, doubled token is
|
|
97
|
+
* literal" everywhere else (decision 16's own invariant, kept by `escapeToken` on every export), and
|
|
98
|
+
* escaping the whole text here would re-double a break token that already exists in some OTHER,
|
|
99
|
+
* non-spanning mark — corrupting exactly the values this function is meant to leave alone.
|
|
100
|
+
*/
|
|
101
|
+
export declare function tokenizeBlockSpanning(text: string, token?: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* Convert a dialect document back to plain CriticMarkup, for handing to a tool that does not know
|
|
104
|
+
* the paragraph token: structural tokens become real newlines (and escaped literal tokens become the
|
|
105
|
+
* single character they stand for), then `normalizeBlockSpanning` splits the resulting block-spanning
|
|
106
|
+
* marks the decision-8 way — accepting that route's blank-line-on-decline cost as the price of
|
|
107
|
+
* portability.
|
|
108
|
+
*/
|
|
109
|
+
export declare function toPortableCriticMarkup(text: string, token?: string): string;
|
|
110
|
+
/**
|
|
111
|
+
* Replace the delimiter characters of every mark nested inside another mark with a sentinel from the
|
|
112
|
+
* U+E000 private-use block, one code point per delimiter character — leaving the nested mark's own
|
|
113
|
+
* content untouched. The editor's import path is regex-based and cannot see a same-kind nested mark
|
|
114
|
+
* correctly (`parseMarks` can); masking lets the OUTER mark import correctly, with the inner
|
|
115
|
+
* delimiters arriving as ordinary text. `unmaskNested` is this function's exact inverse.
|
|
116
|
+
*/
|
|
117
|
+
export declare function maskNested(text: string): {
|
|
118
|
+
masked: string;
|
|
119
|
+
masks: number;
|
|
120
|
+
};
|
|
121
|
+
/** The exact inverse of `maskNested`: every sentinel code point becomes its real delimiter character. */
|
|
122
|
+
export declare function unmaskNested(text: string): string;
|