@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five CriticMarkup text-match transformers (Track N2). Order is substitution, deletion,
|
|
3
|
+
* insertion, highlight, comment — decision 7: `@lexical/markdown`'s import loop tries transformers
|
|
4
|
+
* in LIST order against the full remaining text and takes the first whose regex matches ANYWHERE in
|
|
5
|
+
* it, not the one that would have matched earliest. Highlight before comment is load-bearing: an
|
|
6
|
+
* anchored `{==t==}{>>n<<}` must let the highlight's own regex (which carries the optional trailing
|
|
7
|
+
* comment group) claim the comment text before a bare-comment transformer listed earlier could steal
|
|
8
|
+
* it from a highlight elsewhere on the same line.
|
|
9
|
+
*
|
|
10
|
+
* No `trigger` on any of these: CriticMarkup marks are authored through toolbar actions and
|
|
11
|
+
* suggestion mode (Track S/T), not typed markdown shortcuts, and a stray `}` should never silently
|
|
12
|
+
* spawn a mark.
|
|
13
|
+
*
|
|
14
|
+
* These regexes assume the text hitting them has already been through decision 16's pipeline
|
|
15
|
+
* (`tokenizeBlockSpanning` then `maskNested`, Track N3) — a mark never spans a block by the time a
|
|
16
|
+
* transformer sees it, and a nested mark's own delimiters are sentinels, not the literal characters
|
|
17
|
+
* these patterns look for.
|
|
18
|
+
*/
|
|
19
|
+
import { $createTextNode, $isTextNode } from "lexical";
|
|
20
|
+
import { escapeToken, unescapeToken, PARAGRAPH_TOKEN } from "./grammar.js";
|
|
21
|
+
import { $createBreakNode, $isBreakNode, $createCommentNode, $isCommentNode, $createDeletionNode, $isDeletionNode, DeletionNode, $createHighlightNode, $isHighlightNode, HighlightNode, $createInsertionNode, $isInsertionNode, InsertionNode, CommentNode, } from "./nodes.js";
|
|
22
|
+
/**
|
|
23
|
+
* Split raw mark content around paragraph-token breaks (decision 16) into `TextNode`/`BreakNode`
|
|
24
|
+
* children, appended to `parent` in order. Every text piece inherits `format`, so a mark sitting
|
|
25
|
+
* inside bold text stays bold. A doubled (escaped) token is left in the buffer for `unescapeToken`
|
|
26
|
+
* to fold into one literal character; a lone token flushes the buffer and inserts a break.
|
|
27
|
+
*/
|
|
28
|
+
function appendTokenSegments(parent, content, format) {
|
|
29
|
+
let buffer = "";
|
|
30
|
+
let i = 0;
|
|
31
|
+
const flush = () => {
|
|
32
|
+
if (buffer === "")
|
|
33
|
+
return;
|
|
34
|
+
const t = $createTextNode(unescapeToken(buffer));
|
|
35
|
+
t.setFormat(format);
|
|
36
|
+
parent.append(t);
|
|
37
|
+
buffer = "";
|
|
38
|
+
};
|
|
39
|
+
while (i < content.length) {
|
|
40
|
+
if (content.startsWith(PARAGRAPH_TOKEN, i)) {
|
|
41
|
+
if (content.startsWith(PARAGRAPH_TOKEN, i + PARAGRAPH_TOKEN.length)) {
|
|
42
|
+
buffer += PARAGRAPH_TOKEN + PARAGRAPH_TOKEN; // escaped pair — unescaped whole on flush
|
|
43
|
+
i += PARAGRAPH_TOKEN.length * 2;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
flush();
|
|
47
|
+
parent.append($createBreakNode());
|
|
48
|
+
i += PARAGRAPH_TOKEN.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
buffer += content[i];
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
flush();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The inverse of `appendTokenSegments`, for export: walk `node`'s children, passing each text
|
|
60
|
+
* piece through `exportFormat` (so `**`/`*` wrapping from active formats is preserved) with its
|
|
61
|
+
* literal content escaped first, and passing a `BreakNode`'s token straight through unescaped. The
|
|
62
|
+
* escaping must happen on each piece BEFORE formatting wraps it and must never touch a break's own
|
|
63
|
+
* token — collapsing to a single `getTextContent()` + `escapeToken()` pass would double-escape (or
|
|
64
|
+
* silently drop) whichever of the two survives the ambiguity.
|
|
65
|
+
*/
|
|
66
|
+
function exportMarkContent(node, exportFormat) {
|
|
67
|
+
let out = "";
|
|
68
|
+
for (const child of node.getChildren()) {
|
|
69
|
+
if ($isBreakNode(child))
|
|
70
|
+
out += child.getTextContent();
|
|
71
|
+
else if ($isTextNode(child))
|
|
72
|
+
out += exportFormat(child, escapeToken(child.getTextContent()));
|
|
73
|
+
else
|
|
74
|
+
out += child.getTextContent();
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
// --- substitution -------------------------------------------------------------------------------
|
|
79
|
+
export const SUBSTITUTION_TRANSFORMER = {
|
|
80
|
+
dependencies: [DeletionNode, InsertionNode],
|
|
81
|
+
importRegExp: /\{~~(.*?)~>(.*?)~~\}/,
|
|
82
|
+
regExp: /\{~~(.*?)~>(.*?)~~\}$/,
|
|
83
|
+
replace: (textNode, match) => {
|
|
84
|
+
const [, oldRaw, newRaw] = match;
|
|
85
|
+
const format = textNode.getFormat();
|
|
86
|
+
const deletionNode = $createDeletionNode();
|
|
87
|
+
appendTokenSegments(deletionNode, oldRaw, format);
|
|
88
|
+
const insertionNode = $createInsertionNode();
|
|
89
|
+
appendTokenSegments(insertionNode, newRaw, format);
|
|
90
|
+
textNode.replace(deletionNode);
|
|
91
|
+
deletionNode.insertAfter(insertionNode);
|
|
92
|
+
},
|
|
93
|
+
type: "text-match",
|
|
94
|
+
};
|
|
95
|
+
// --- deletion -------------------------------------------------------------------------------------
|
|
96
|
+
export const DELETION_TRANSFORMER = {
|
|
97
|
+
dependencies: [DeletionNode],
|
|
98
|
+
importRegExp: /\{--(.*?)--\}/,
|
|
99
|
+
regExp: /\{--(.*?)--\}$/,
|
|
100
|
+
replace: (textNode, match) => {
|
|
101
|
+
const [, raw] = match;
|
|
102
|
+
const node = $createDeletionNode();
|
|
103
|
+
appendTokenSegments(node, raw, textNode.getFormat());
|
|
104
|
+
textNode.replace(node);
|
|
105
|
+
},
|
|
106
|
+
export: (node, _exportChildren, exportFormat) => {
|
|
107
|
+
if (!$isDeletionNode(node))
|
|
108
|
+
return null;
|
|
109
|
+
// Decision 4, half one: when an insertion immediately follows, this is a substitution pair —
|
|
110
|
+
// emit nothing here and let the insertion below emit the combined `{~~old~>new~~}`. Either
|
|
111
|
+
// half emitting alone would produce the pair twice or not at all.
|
|
112
|
+
if ($isInsertionNode(node.getNextSibling()))
|
|
113
|
+
return "";
|
|
114
|
+
return `{--${exportMarkContent(node, exportFormat)}--}`;
|
|
115
|
+
},
|
|
116
|
+
type: "text-match",
|
|
117
|
+
};
|
|
118
|
+
// --- insertion ------------------------------------------------------------------------------------
|
|
119
|
+
export const INSERTION_TRANSFORMER = {
|
|
120
|
+
dependencies: [InsertionNode],
|
|
121
|
+
importRegExp: /\{\+\+(.*?)\+\+\}/,
|
|
122
|
+
regExp: /\{\+\+(.*?)\+\+\}$/,
|
|
123
|
+
replace: (textNode, match) => {
|
|
124
|
+
const [, raw] = match;
|
|
125
|
+
const node = $createInsertionNode();
|
|
126
|
+
appendTokenSegments(node, raw, textNode.getFormat());
|
|
127
|
+
textNode.replace(node);
|
|
128
|
+
},
|
|
129
|
+
export: (node, _exportChildren, exportFormat) => {
|
|
130
|
+
if (!$isInsertionNode(node))
|
|
131
|
+
return null;
|
|
132
|
+
const prev = node.getPreviousSibling();
|
|
133
|
+
if ($isDeletionNode(prev)) {
|
|
134
|
+
// Decision 4, half two: the deletion just before emitted "", so the combined pair is
|
|
135
|
+
// emitted here instead of a lone insertion.
|
|
136
|
+
return `{~~${exportMarkContent(prev, exportFormat)}~>${exportMarkContent(node, exportFormat)}~~}`;
|
|
137
|
+
}
|
|
138
|
+
return `{++${exportMarkContent(node, exportFormat)}++}`;
|
|
139
|
+
},
|
|
140
|
+
type: "text-match",
|
|
141
|
+
};
|
|
142
|
+
// --- highlight (with an optional anchored comment) -------------------------------------------------
|
|
143
|
+
export const HIGHLIGHT_TRANSFORMER = {
|
|
144
|
+
dependencies: [HighlightNode],
|
|
145
|
+
importRegExp: /\{==(.*?)==\}(?:\{>>(.*?)<<\})?/,
|
|
146
|
+
regExp: /\{==(.*?)==\}(?:\{>>(.*?)<<\})?$/,
|
|
147
|
+
replace: (textNode, match) => {
|
|
148
|
+
const [, raw, commentRaw] = match;
|
|
149
|
+
const node = $createHighlightNode();
|
|
150
|
+
appendTokenSegments(node, raw, textNode.getFormat());
|
|
151
|
+
if (commentRaw !== undefined)
|
|
152
|
+
node.setComment(unescapeToken(commentRaw));
|
|
153
|
+
textNode.replace(node);
|
|
154
|
+
},
|
|
155
|
+
export: (node, _exportChildren, exportFormat) => {
|
|
156
|
+
if (!$isHighlightNode(node))
|
|
157
|
+
return null;
|
|
158
|
+
const comment = node.getComment();
|
|
159
|
+
const commentPart = comment !== null ? `{>>${escapeToken(comment)}<<}` : "";
|
|
160
|
+
return `{==${exportMarkContent(node, exportFormat)}==}${commentPart}`;
|
|
161
|
+
},
|
|
162
|
+
type: "text-match",
|
|
163
|
+
};
|
|
164
|
+
// --- comment (bare only — an anchored one is consumed above, by the highlight transformer) --------
|
|
165
|
+
export const COMMENT_TRANSFORMER = {
|
|
166
|
+
dependencies: [CommentNode],
|
|
167
|
+
importRegExp: /\{>>(.*?)<<\}/,
|
|
168
|
+
regExp: /\{>>(.*?)<<\}$/,
|
|
169
|
+
replace: (textNode, match) => {
|
|
170
|
+
const [, raw] = match;
|
|
171
|
+
textNode.replace($createCommentNode(unescapeToken(raw)));
|
|
172
|
+
},
|
|
173
|
+
export: (node) => {
|
|
174
|
+
if (!$isCommentNode(node))
|
|
175
|
+
return null;
|
|
176
|
+
return `{>>${escapeToken(node.getText())}<<}`;
|
|
177
|
+
},
|
|
178
|
+
type: "text-match",
|
|
179
|
+
};
|
|
180
|
+
/** The five transformers in decision 7's required order. */
|
|
181
|
+
export const criticMarkupTransformers = [
|
|
182
|
+
SUBSTITUTION_TRANSFORMER,
|
|
183
|
+
DELETION_TRANSFORMER,
|
|
184
|
+
INSERTION_TRANSFORMER,
|
|
185
|
+
HIGHLIGHT_TRANSFORMER,
|
|
186
|
+
COMMENT_TRANSFORMER,
|
|
187
|
+
];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "id": "parse-insertion", "op": "parse", "input": "{++foo++}",
|
|
3
|
+
"expected": [ { "kind": "insertion", "start": 0, "end": 9, "text": "foo", "spansBlock": false, "nested": false } ] },
|
|
4
|
+
{ "id": "parse-deletion", "op": "parse", "input": "{--bar--}",
|
|
5
|
+
"expected": [ { "kind": "deletion", "start": 0, "end": 9, "text": "bar", "spansBlock": false, "nested": false } ] },
|
|
6
|
+
{ "id": "parse-substitution", "op": "parse", "input": "{~~old~>new~~}",
|
|
7
|
+
"expected": [ { "kind": "substitution", "start": 0, "end": 14, "old": "old", "new": "new", "spansBlock": false, "nested": false } ] },
|
|
8
|
+
{ "id": "parse-comment", "op": "parse", "input": "{>>note<<}",
|
|
9
|
+
"expected": [ { "kind": "comment", "start": 0, "end": 10, "text": "note", "spansBlock": false, "nested": false } ] },
|
|
10
|
+
{ "id": "parse-highlight", "op": "parse", "input": "{==hl==}",
|
|
11
|
+
"expected": [ { "kind": "highlight", "start": 0, "end": 8, "text": "hl", "spansBlock": false, "nested": false } ] },
|
|
12
|
+
{ "id": "parse-nested-same-kind", "op": "parse", "input": "{--a {--b--} c--}",
|
|
13
|
+
"expected": [
|
|
14
|
+
{ "kind": "deletion", "start": 0, "end": 17, "text": "a {--b--} c", "spansBlock": false, "nested": true },
|
|
15
|
+
{ "kind": "deletion", "start": 5, "end": 12, "text": "b", "spansBlock": false, "nested": false }
|
|
16
|
+
] },
|
|
17
|
+
{ "id": "parse-nested-different-kind", "op": "parse", "input": "{++a {--b--} c++}",
|
|
18
|
+
"expected": [
|
|
19
|
+
{ "kind": "insertion", "start": 0, "end": 17, "text": "a {--b--} c", "spansBlock": false, "nested": true },
|
|
20
|
+
{ "kind": "deletion", "start": 5, "end": 12, "text": "b", "spansBlock": false, "nested": false }
|
|
21
|
+
] },
|
|
22
|
+
{ "id": "parse-unterminated", "op": "parse", "input": "{++foo", "expected": [] },
|
|
23
|
+
{ "id": "parse-fenced-code-skip", "op": "parse", "input": "```\n{--not tracked--}\n```", "expected": [] },
|
|
24
|
+
{ "id": "parse-reading-order", "op": "parse", "input": "{++a++}{--b--}{==c==}",
|
|
25
|
+
"expected": [
|
|
26
|
+
{ "kind": "insertion", "start": 0, "end": 7, "text": "a", "spansBlock": false, "nested": false },
|
|
27
|
+
{ "kind": "deletion", "start": 7, "end": 14, "text": "b", "spansBlock": false, "nested": false },
|
|
28
|
+
{ "kind": "highlight", "start": 14, "end": 21, "text": "c", "spansBlock": false, "nested": false }
|
|
29
|
+
] },
|
|
30
|
+
{ "id": "parse-spans-block", "op": "parse", "input": "{++A\n\nB++}",
|
|
31
|
+
"expected": [ { "kind": "insertion", "start": 0, "end": 10, "text": "A\n\nB", "spansBlock": true, "nested": false } ] },
|
|
32
|
+
|
|
33
|
+
{ "id": "accept-insertion", "op": "accept", "input": "{++foo++}", "mark": 0, "expected": "foo" },
|
|
34
|
+
{ "id": "decline-insertion", "op": "decline", "input": "{++foo++}", "mark": 0, "expected": "" },
|
|
35
|
+
{ "id": "accept-deletion", "op": "accept", "input": "{--bar--}", "mark": 0, "expected": "" },
|
|
36
|
+
{ "id": "decline-deletion", "op": "decline", "input": "{--bar--}", "mark": 0, "expected": "bar" },
|
|
37
|
+
{ "id": "accept-substitution", "op": "accept", "input": "{~~old~>new~~}", "mark": 0, "expected": "new" },
|
|
38
|
+
{ "id": "decline-substitution", "op": "decline", "input": "{~~old~>new~~}", "mark": 0, "expected": "old" },
|
|
39
|
+
{ "id": "accept-highlight-bare", "op": "accept", "input": "{==hl==}", "mark": 0, "expected": "hl" },
|
|
40
|
+
{ "id": "decline-highlight-bare", "op": "decline", "input": "{==hl==}", "mark": 0, "expected": "hl" },
|
|
41
|
+
{ "id": "accept-comment-bare", "op": "accept", "input": "{>>note<<}", "mark": 0, "expected": "{>>note<<}" },
|
|
42
|
+
{ "id": "decline-comment-bare", "op": "decline", "input": "{>>note<<}", "mark": 0, "expected": "{>>note<<}" },
|
|
43
|
+
{ "id": "accept-highlight-anchored-comment", "op": "accept", "input": "{==hl==}{>>note<<}", "mark": 0, "expected": "hl" },
|
|
44
|
+
{ "id": "decline-highlight-anchored-comment", "op": "decline", "input": "{==hl==}{>>note<<}", "mark": 0, "expected": "hl" },
|
|
45
|
+
|
|
46
|
+
{ "id": "acceptAll-nested-deletion", "op": "acceptAll", "input": "{--a {--b--} c--}", "expected": "" },
|
|
47
|
+
{ "id": "declineAll-nested-deletion", "op": "declineAll", "input": "{--a {--b--} c--}", "expected": "a b c" },
|
|
48
|
+
{ "id": "acceptAll-identity-no-marks", "op": "acceptAll", "input": "plain prose, no marks here.", "expected": "plain prose, no marks here." },
|
|
49
|
+
{ "id": "declineAll-identity-no-marks", "op": "declineAll", "input": "plain prose, no marks here.", "expected": "plain prose, no marks here." },
|
|
50
|
+
{ "id": "acceptAll-bare-comment-survives", "op": "acceptAll", "input": "{++x++}{>>note<<}", "expected": "x{>>note<<}" },
|
|
51
|
+
{ "id": "declineAll-bare-comment-survives", "op": "declineAll", "input": "{++x++}{>>note<<}", "expected": "{>>note<<}" },
|
|
52
|
+
{ "id": "stripComments-removes-bare-comment", "op": "stripComments", "input": "x{>>note<<}", "expected": "x" },
|
|
53
|
+
|
|
54
|
+
{ "id": "token-accept-insertion-break", "op": "accept", "input": "A{++¶++}B", "mark": 0, "expected": "A\n\nB" },
|
|
55
|
+
{ "id": "token-decline-insertion-break", "op": "decline", "input": "A{++¶++}B", "mark": 0, "expected": "AB" },
|
|
56
|
+
{ "id": "token-accept-deletion-break", "op": "accept", "input": "A{--¶--}B", "mark": 0, "expected": "AB" },
|
|
57
|
+
{ "id": "token-decline-deletion-break", "op": "decline", "input": "A{--¶--}B", "mark": 0, "expected": "A\n\nB" },
|
|
58
|
+
{ "id": "token-decline-insertion-no-orphan-blank-line", "op": "decline", "input": "{++A¶B++}", "mark": 0, "expected": "" },
|
|
59
|
+
{ "id": "token-accept-insertion-content-break", "op": "accept", "input": "{++A¶B++}", "mark": 0, "expected": "A\n\nB" },
|
|
60
|
+
{ "id": "token-accept-doubled-pilcrow-literal", "op": "accept", "input": "{++A¶¶B++}", "mark": 0, "expected": "A¶B" },
|
|
61
|
+
|
|
62
|
+
{ "id": "tokenize-insertion-block-spanning", "op": "tokenize", "input": "{++A\n\nB++}", "expected": "{++A¶B++}" },
|
|
63
|
+
{ "id": "tokenize-substitution-block-spanning", "op": "tokenize", "input": "{~~A\n\nB~>C~~}", "expected": "{~~A¶B~>C~~}" },
|
|
64
|
+
|
|
65
|
+
{ "id": "portable-insertion-tokenized-to-split", "op": "portable", "input": "{++A¶B++}", "expected": "{++A++}\n\n{++B++}" },
|
|
66
|
+
{ "id": "declineAll-portable-split-blank-line-cost", "op": "declineAll", "input": "{++A++}\n\n{++B++}", "expected": "\n\n" }
|
|
67
|
+
]
|
package/package.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{ "name": "@dojo-ng/rich-text-criticmarkup", "version": "0.1.0", "license": "BSD-3-Clause", "description": "CriticMarkup tracked-changes plugin for @dojo-ng/rich-text", "type": "module",
|
|
2
|
+
"main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts",
|
|
3
|
+
"files": ["dist", "!dist/**/*.map", "fixtures"],
|
|
4
|
+
"scripts": { "build": "tsc -p tsconfig.json", "clean": "rm -rf dist" },
|
|
5
|
+
"dependencies": { "@dojo-ng/rich-text": "^0.1.0", "@dojo-ng/i18n": "^0.1.0", "@dojo-ng/popup-confirmation": "^0.1.0", "@dojo-ng/popup": "^0.1.0", "@dojo-ng/text-area": "^0.1.0", "@dojo-ng/button": "^0.1.0", "lexical": "^0.21.0", "@lexical/markdown": "^0.21.0", "@lexical/utils": "^0.21.0", "lit": "^3.3.0" } }
|