@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.
@@ -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" } }