@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/dist/grammar.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
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
|
+
const OPEN = {
|
|
15
|
+
"{++": "insertion",
|
|
16
|
+
"{--": "deletion",
|
|
17
|
+
"{~~": "substitution",
|
|
18
|
+
"{>>": "comment",
|
|
19
|
+
"{==": "highlight",
|
|
20
|
+
};
|
|
21
|
+
const CLOSE = {
|
|
22
|
+
insertion: "++}",
|
|
23
|
+
deletion: "--}",
|
|
24
|
+
substitution: "~~}",
|
|
25
|
+
comment: "<<}",
|
|
26
|
+
highlight: "==}",
|
|
27
|
+
};
|
|
28
|
+
// The `old~>new` separator inside a substitution. Its own token because a substitution's close
|
|
29
|
+
// token, `~~}`, must not fire until this has been seen — see the frame's `sepPos` below.
|
|
30
|
+
const SEP = "~>";
|
|
31
|
+
// Every open and close token is exactly 3 characters, which the masking and tokenizing code below
|
|
32
|
+
// relies on rather than looking each one up by kind.
|
|
33
|
+
const TOKEN_LEN = 3;
|
|
34
|
+
// A code fence hides CriticMarkup the same way it hides any other markdown instruction: a novel
|
|
35
|
+
// legitimately containing `{--` inside a fenced snippet must not become a tracked change.
|
|
36
|
+
const FENCE = /^\s{0,3}(```|~~~)/;
|
|
37
|
+
// A blank line — one line that is empty or all whitespace — is a block boundary in CommonMark.
|
|
38
|
+
// `@lexical/markdown` splits the document on `\n` before any transformer runs, so a mark whose span
|
|
39
|
+
// crosses one can never be seen by a text-match transformer (see decisions 8 and 16).
|
|
40
|
+
const BLANK_LINE = /\n[ \t]*\n/;
|
|
41
|
+
const BLANK_LINE_G = /\n[ \t]*\n/g;
|
|
42
|
+
function fenceMask(text) {
|
|
43
|
+
const mask = new Uint8Array(text.length);
|
|
44
|
+
let inFence = false;
|
|
45
|
+
let offset = 0;
|
|
46
|
+
for (const line of splitKeepEnds(text)) {
|
|
47
|
+
const fenceLine = FENCE.test(line);
|
|
48
|
+
const hide = fenceLine || inFence;
|
|
49
|
+
if (fenceLine)
|
|
50
|
+
inFence = !inFence;
|
|
51
|
+
if (hide)
|
|
52
|
+
for (let i = offset; i < offset + line.length; i++)
|
|
53
|
+
mask[i] = 1;
|
|
54
|
+
offset += line.length;
|
|
55
|
+
}
|
|
56
|
+
return mask;
|
|
57
|
+
}
|
|
58
|
+
function splitKeepEnds(text) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
let start = 0;
|
|
61
|
+
for (let i = 0; i < text.length; i++) {
|
|
62
|
+
if (text[i] === "\n") {
|
|
63
|
+
lines.push(text.slice(start, i + 1));
|
|
64
|
+
start = i + 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (start < text.length)
|
|
68
|
+
lines.push(text.slice(start));
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
class Frame {
|
|
72
|
+
constructor(kind, start, contentStart) {
|
|
73
|
+
this.sepPos = null; // substitution only
|
|
74
|
+
this.nested = false; // set when a mark closes while this frame is still open beneath it
|
|
75
|
+
this.kind = kind;
|
|
76
|
+
this.start = start;
|
|
77
|
+
this.contentStart = contentStart;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Every CriticMarkup mark in `text`, in reading order — nested marks included as their own entries,
|
|
82
|
+
* alongside the outer mark that contains them.
|
|
83
|
+
*
|
|
84
|
+
* An unterminated mark — an open token with no matching close anywhere after it — produces no entry
|
|
85
|
+
* at all, and is left as the literal characters it is. The alternative, scanning until some LATER,
|
|
86
|
+
* unrelated mark's close token turns up, would swallow everything in between as one giant mark,
|
|
87
|
+
* which is worse than finding nothing.
|
|
88
|
+
*/
|
|
89
|
+
export function parseMarks(text) {
|
|
90
|
+
const fence = fenceMask(text);
|
|
91
|
+
const stack = [];
|
|
92
|
+
const results = [];
|
|
93
|
+
const n = text.length;
|
|
94
|
+
let i = 0;
|
|
95
|
+
while (i < n) {
|
|
96
|
+
if (fence[i]) {
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const top = stack.length ? stack[stack.length - 1] : null;
|
|
101
|
+
if (top) {
|
|
102
|
+
const close = CLOSE[top.kind];
|
|
103
|
+
const ready = top.kind !== "substitution" || top.sepPos !== null;
|
|
104
|
+
if (ready && text.startsWith(close, i)) {
|
|
105
|
+
results.push(finish(text, top, i, close.length));
|
|
106
|
+
stack.pop();
|
|
107
|
+
if (stack.length)
|
|
108
|
+
stack[stack.length - 1].nested = true;
|
|
109
|
+
i += close.length;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (top.kind === "substitution" && top.sepPos === null && text.startsWith(SEP, i)) {
|
|
113
|
+
top.sepPos = i;
|
|
114
|
+
i += SEP.length;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const openToken = Object.keys(OPEN).find((token) => text.startsWith(token, i));
|
|
119
|
+
if (openToken) {
|
|
120
|
+
stack.push(new Frame(OPEN[openToken], i, i + openToken.length));
|
|
121
|
+
i += openToken.length;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
// Frames still open at EOF are the unterminated case: abandoned, not reported.
|
|
127
|
+
results.sort((a, b) => a.start - b.start);
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
function finish(text, frame, closePos, closeLen) {
|
|
131
|
+
const end = closePos + closeLen;
|
|
132
|
+
const mark = {
|
|
133
|
+
kind: frame.kind,
|
|
134
|
+
start: frame.start,
|
|
135
|
+
end,
|
|
136
|
+
spansBlock: BLANK_LINE.test(text.slice(frame.start, end)),
|
|
137
|
+
nested: frame.nested,
|
|
138
|
+
};
|
|
139
|
+
if (frame.kind === "substitution") {
|
|
140
|
+
mark.old = text.slice(frame.contentStart, frame.sepPos);
|
|
141
|
+
mark.new = text.slice(frame.sepPos + SEP.length, closePos);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
mark.text = text.slice(frame.contentStart, closePos);
|
|
145
|
+
}
|
|
146
|
+
return mark;
|
|
147
|
+
}
|
|
148
|
+
// --- accept, decline, and the bulk forms ------------------------------------------------------
|
|
149
|
+
/**
|
|
150
|
+
* Apply `mark`: keep an insertion's (or a substitution's new) text; drop a deletion; keep a
|
|
151
|
+
* highlight's text, its own anchored comment going with it; leave a bare comment untouched.
|
|
152
|
+
*/
|
|
153
|
+
export function accept(text, mark) {
|
|
154
|
+
return resolve(text, mark, "new");
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reject `mark`: restore a deletion's (or a substitution's old) text; drop an insertion; keep a
|
|
158
|
+
* highlight's text (and its own anchored comment, if any); leave a bare comment untouched.
|
|
159
|
+
*/
|
|
160
|
+
export function decline(text, mark) {
|
|
161
|
+
return resolve(text, mark, "old");
|
|
162
|
+
}
|
|
163
|
+
function resolve(text, mark, side) {
|
|
164
|
+
if (mark.kind === "comment") {
|
|
165
|
+
// Only a BARE comment reaches this branch — an anchored one is consumed by its highlight's
|
|
166
|
+
// own resolution below, before it would ever be resolved on its own.
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
const end = mark.kind === "highlight" ? highlightEnd(text, mark) : mark.end;
|
|
170
|
+
return text.slice(0, mark.start) + keptText(mark, side) + text.slice(end);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `mark`'s own `end`, extended to swallow an immediately adjacent `{>>...<<}` — CriticMarkup's
|
|
174
|
+
* anchored-comment convention. Re-parses the text right after `mark` rather than matching a regex,
|
|
175
|
+
* so a comment containing its own delimiters is still found correctly.
|
|
176
|
+
*/
|
|
177
|
+
function highlightEnd(text, mark) {
|
|
178
|
+
if (!text.startsWith("{>>", mark.end))
|
|
179
|
+
return mark.end;
|
|
180
|
+
const following = parseMarks(text.slice(mark.end));
|
|
181
|
+
if (following.length && following[0].kind === "comment" && following[0].start === 0) {
|
|
182
|
+
return mark.end + following[0].end;
|
|
183
|
+
}
|
|
184
|
+
return mark.end;
|
|
185
|
+
}
|
|
186
|
+
function keptText(mark, side) {
|
|
187
|
+
switch (mark.kind) {
|
|
188
|
+
case "highlight":
|
|
189
|
+
return resolveParagraphTokens(mark.text);
|
|
190
|
+
case "substitution":
|
|
191
|
+
return resolveParagraphTokens(side === "new" ? mark.new : mark.old);
|
|
192
|
+
case "insertion":
|
|
193
|
+
return side === "new" ? resolveParagraphTokens(mark.text) : "";
|
|
194
|
+
case "deletion":
|
|
195
|
+
return side === "new" ? "" : resolveParagraphTokens(mark.text);
|
|
196
|
+
default:
|
|
197
|
+
throw new Error(`unknown mark kind ${mark.kind}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolve every mark as `accept` would, nested ones included. Idempotent: a body with no marks is
|
|
202
|
+
* returned unchanged.
|
|
203
|
+
*
|
|
204
|
+
* Re-parses after each single resolution rather than applying a batch of offsets: resolving an
|
|
205
|
+
* outer mark moves everything after its opening delimiter, so a stale offset for a nested mark would
|
|
206
|
+
* land on the wrong span.
|
|
207
|
+
*/
|
|
208
|
+
export function acceptAll(text) {
|
|
209
|
+
return resolveAll(text, accept);
|
|
210
|
+
}
|
|
211
|
+
/** `acceptAll`'s opposite. See its docstring for why this re-parses. */
|
|
212
|
+
export function declineAll(text) {
|
|
213
|
+
return resolveAll(text, decline);
|
|
214
|
+
}
|
|
215
|
+
function resolveAll(text, resolveOne) {
|
|
216
|
+
let marks = parseMarks(text);
|
|
217
|
+
let i = 0;
|
|
218
|
+
while (i < marks.length) {
|
|
219
|
+
const mark = marks[i];
|
|
220
|
+
if (mark.kind === "comment") {
|
|
221
|
+
// A bare comment is left standing (decision 10), skipped in place rather than resolved —
|
|
222
|
+
// resolving it would return the text unchanged for this same mark and spin forever.
|
|
223
|
+
i++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
text = resolveOne(text, mark);
|
|
227
|
+
marks = parseMarks(text);
|
|
228
|
+
i = 0;
|
|
229
|
+
}
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
/** Remove every bare comment mark, right to left, leaving everything else untouched. */
|
|
233
|
+
export function stripComments(text) {
|
|
234
|
+
const marks = parseMarks(text).filter((m) => m.kind === "comment");
|
|
235
|
+
marks.sort((a, b) => b.start - a.start);
|
|
236
|
+
for (const mark of marks)
|
|
237
|
+
text = text.slice(0, mark.start) + text.slice(mark.end);
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
// --- decision 8, route 1: split a block-spanning mark into one mark per block -----------------
|
|
241
|
+
const SPLITTABLE = {
|
|
242
|
+
insertion: ["{++", "++}"],
|
|
243
|
+
deletion: ["{--", "--}"],
|
|
244
|
+
highlight: ["{==", "==}"],
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Split a block-spanning insertion, deletion, or highlight into one mark per block, so every mark
|
|
248
|
+
* ends up on a single line: `{++A\n\nB++}` becomes `{++A++}\n\n{++B++}`, which renders identically.
|
|
249
|
+
*
|
|
250
|
+
* Superseded as the import-time normalization by `tokenizeBlockSpanning` (decision 16); this
|
|
251
|
+
* function is unchanged from decision 8 and survives only as the step `toPortableCriticMarkup`
|
|
252
|
+
* uses to turn the dialect back into plain CriticMarkup.
|
|
253
|
+
*
|
|
254
|
+
* A substitution is left alone — splitting one means deciding how `old` and `new` pair up block for
|
|
255
|
+
* block, which is not always well defined — and so is a comment, which has no per-block content to
|
|
256
|
+
* repeat. Both arrive as literal delimiter characters: visible to the author, never silently merged
|
|
257
|
+
* into the prose.
|
|
258
|
+
*
|
|
259
|
+
* Declining the SPLIT form leaves the blank line between the pieces where declining the unsplit form
|
|
260
|
+
* would have removed it too — no prose is lost either way, but it is a known, accepted cost of this
|
|
261
|
+
* route, not an oversight.
|
|
262
|
+
*/
|
|
263
|
+
export function normalizeBlockSpanning(text) {
|
|
264
|
+
const marks = parseMarks(text).filter((m) => m.spansBlock && m.kind in SPLITTABLE);
|
|
265
|
+
marks.sort((a, b) => b.start - a.start); // right to left: splitting changes the body's length
|
|
266
|
+
for (const mark of marks) {
|
|
267
|
+
const [openTok, closeTok] = SPLITTABLE[mark.kind];
|
|
268
|
+
const blocks = mark.text.split(BLANK_LINE_G);
|
|
269
|
+
const rewritten = blocks.map((block) => `${openTok}${block}${closeTok}`).join("\n\n");
|
|
270
|
+
text = text.slice(0, mark.start) + rewritten + text.slice(mark.end);
|
|
271
|
+
}
|
|
272
|
+
return text;
|
|
273
|
+
}
|
|
274
|
+
// --- decision 16: the paragraph-break token -----------------------------------------------------
|
|
275
|
+
/** The paragraph-break token: a break carried inside a mark instead of a real newline. */
|
|
276
|
+
export const PARAGRAPH_TOKEN = "¶";
|
|
277
|
+
/** A literal token character in prose is escaped by doubling, so it round-trips through a mark. */
|
|
278
|
+
export function escapeToken(text, token = PARAGRAPH_TOKEN) {
|
|
279
|
+
if (token === "")
|
|
280
|
+
return text;
|
|
281
|
+
return text.split(token).join(token + token);
|
|
282
|
+
}
|
|
283
|
+
/** The exact inverse of `escapeToken`: a doubled token becomes one literal character again. */
|
|
284
|
+
export function unescapeToken(text, token = PARAGRAPH_TOKEN) {
|
|
285
|
+
if (token === "")
|
|
286
|
+
return text;
|
|
287
|
+
return text.split(token + token).join(token);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolve a piece of KEPT mark text: an unpaired (structural) token becomes a real paragraph break,
|
|
291
|
+
* and a doubled (escaped) token becomes the single literal character it stands for. Scans
|
|
292
|
+
* left-to-right so a doubled pair is consumed as a unit and never mistaken for two lone tokens.
|
|
293
|
+
*/
|
|
294
|
+
function resolveParagraphTokens(text, token = PARAGRAPH_TOKEN) {
|
|
295
|
+
if (token === "")
|
|
296
|
+
return text;
|
|
297
|
+
let out = "";
|
|
298
|
+
let i = 0;
|
|
299
|
+
while (i < text.length) {
|
|
300
|
+
if (text.startsWith(token, i)) {
|
|
301
|
+
if (text.startsWith(token, i + token.length)) {
|
|
302
|
+
out += token + token; // escaped pair — unescapeToken folds it below
|
|
303
|
+
i += token.length * 2;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
out += "\n\n"; // unpaired: a structural break
|
|
307
|
+
i += token.length;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
out += text[i];
|
|
312
|
+
i++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return unescapeToken(out, token);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Real newlines inside a mark become the paragraph token, one mark's content at a time — decision
|
|
319
|
+
* 16's replacement for `normalizeBlockSpanning`'s splitting at import time. Unlike that route, this
|
|
320
|
+
* applies to all five kinds: a block-spanning substitution or comment tokenizes too, since the break
|
|
321
|
+
* is now an ordinary character inside a one-line mark rather than a structural split.
|
|
322
|
+
*
|
|
323
|
+
* Escapes any existing literal token character WITHIN a spanning mark's own content first, so a
|
|
324
|
+
* literal pilcrow sitting next to the blank line being converted is not confused with the new
|
|
325
|
+
* structural break — scoped to that one mark's content, not the whole document: a document that
|
|
326
|
+
* ROUND-TRIPS through this dialect already satisfies "single token is structural, doubled token is
|
|
327
|
+
* literal" everywhere else (decision 16's own invariant, kept by `escapeToken` on every export), and
|
|
328
|
+
* escaping the whole text here would re-double a break token that already exists in some OTHER,
|
|
329
|
+
* non-spanning mark — corrupting exactly the values this function is meant to leave alone.
|
|
330
|
+
*/
|
|
331
|
+
export function tokenizeBlockSpanning(text, token = PARAGRAPH_TOKEN) {
|
|
332
|
+
if (token === "")
|
|
333
|
+
return text; // disabled, matching escapeToken/unescapeToken's own convention
|
|
334
|
+
const marks = parseMarks(text).filter((m) => m.spansBlock);
|
|
335
|
+
if (!marks.length)
|
|
336
|
+
return text;
|
|
337
|
+
marks.sort((a, b) => b.start - a.start); // right to left: rewriting changes the body's length
|
|
338
|
+
let body = text;
|
|
339
|
+
for (const mark of marks) {
|
|
340
|
+
const contentStart = mark.start + TOKEN_LEN;
|
|
341
|
+
const contentEnd = mark.end - TOKEN_LEN;
|
|
342
|
+
const content = escapeToken(body.slice(contentStart, contentEnd), token).replace(BLANK_LINE_G, token);
|
|
343
|
+
body = body.slice(0, contentStart) + content + body.slice(contentEnd);
|
|
344
|
+
}
|
|
345
|
+
return body;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Convert a dialect document back to plain CriticMarkup, for handing to a tool that does not know
|
|
349
|
+
* the paragraph token: structural tokens become real newlines (and escaped literal tokens become the
|
|
350
|
+
* single character they stand for), then `normalizeBlockSpanning` splits the resulting block-spanning
|
|
351
|
+
* marks the decision-8 way — accepting that route's blank-line-on-decline cost as the price of
|
|
352
|
+
* portability.
|
|
353
|
+
*/
|
|
354
|
+
export function toPortableCriticMarkup(text, token = PARAGRAPH_TOKEN) {
|
|
355
|
+
return normalizeBlockSpanning(resolveParagraphTokens(text, token));
|
|
356
|
+
}
|
|
357
|
+
// --- decision 11: nesting is detected and masked, not mangled ----------------------------------
|
|
358
|
+
// One private-use sentinel per CriticMarkup delimiter character, so masking is a plain,
|
|
359
|
+
// length-preserving character substitution with a trivial exact inverse.
|
|
360
|
+
const DELIMITER_CHARS = ["{", "}", "+", "-", "~", ">", "<", "="];
|
|
361
|
+
const MASK_OF = new Map(DELIMITER_CHARS.map((ch, i) => [ch, String.fromCodePoint(0xe000 + i)]));
|
|
362
|
+
const UNMASK_OF = new Map(DELIMITER_CHARS.map((ch, i) => [String.fromCodePoint(0xe000 + i), ch]));
|
|
363
|
+
/**
|
|
364
|
+
* Replace the delimiter characters of every mark nested inside another mark with a sentinel from the
|
|
365
|
+
* U+E000 private-use block, one code point per delimiter character — leaving the nested mark's own
|
|
366
|
+
* content untouched. The editor's import path is regex-based and cannot see a same-kind nested mark
|
|
367
|
+
* correctly (`parseMarks` can); masking lets the OUTER mark import correctly, with the inner
|
|
368
|
+
* delimiters arriving as ordinary text. `unmaskNested` is this function's exact inverse.
|
|
369
|
+
*/
|
|
370
|
+
export function maskNested(text) {
|
|
371
|
+
const marks = parseMarks(text);
|
|
372
|
+
const nested = marks.filter((m) => marks.some((p) => p !== m && p.start <= m.start && m.end <= p.end));
|
|
373
|
+
if (!nested.length)
|
|
374
|
+
return { masked: text, masks: 0 };
|
|
375
|
+
const units = text.split("");
|
|
376
|
+
for (const mark of nested) {
|
|
377
|
+
maskRange(units, mark.start, mark.start + TOKEN_LEN);
|
|
378
|
+
maskRange(units, mark.end - TOKEN_LEN, mark.end);
|
|
379
|
+
if (mark.kind === "substitution") {
|
|
380
|
+
const sepPos = mark.start + TOKEN_LEN + mark.old.length;
|
|
381
|
+
maskRange(units, sepPos, sepPos + SEP.length);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { masked: units.join(""), masks: nested.length };
|
|
385
|
+
}
|
|
386
|
+
function maskRange(units, start, end) {
|
|
387
|
+
for (let i = start; i < end; i++) {
|
|
388
|
+
const masked = MASK_OF.get(units[i]);
|
|
389
|
+
if (masked)
|
|
390
|
+
units[i] = masked;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** The exact inverse of `maskNested`: every sentinel code point becomes its real delimiter character. */
|
|
394
|
+
export function unmaskNested(text) {
|
|
395
|
+
let out = "";
|
|
396
|
+
for (const ch of text)
|
|
397
|
+
out += UNMASK_OF.get(ch) ?? ch;
|
|
398
|
+
return out;
|
|
399
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./grammar.js";
|
|
2
|
+
export * from "./nodes.js";
|
|
3
|
+
export * from "./transformers.js";
|
|
4
|
+
export * from "./format.js";
|
|
5
|
+
export * from "./suggestion-mode.js";
|
|
6
|
+
export * from "./resolution.js";
|
|
7
|
+
export * from "./comment-authoring.js";
|
|
8
|
+
export * from "./comment-popup.js";
|
|
9
|
+
export * from "./plugin.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./grammar.js";
|
|
2
|
+
export * from "./nodes.js";
|
|
3
|
+
export * from "./transformers.js";
|
|
4
|
+
export * from "./format.js";
|
|
5
|
+
export * from "./suggestion-mode.js";
|
|
6
|
+
export * from "./resolution.js";
|
|
7
|
+
export * from "./comment-authoring.js";
|
|
8
|
+
export * from "./comment-popup.js";
|
|
9
|
+
export * from "./plugin.js";
|
package/dist/nodes.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five CriticMarkup node classes (decisions 4-6, 16).
|
|
3
|
+
*
|
|
4
|
+
* `InsertionNode`, `DeletionNode`, and `HighlightNode` are `ElementNode`s wrapping ordinary text —
|
|
5
|
+
* the shape `@lexical/link`'s `LinkNode` uses for an inline span with its own markdown delimiters —
|
|
6
|
+
* so they can hold a `BreakNode` alongside plain `TextNode` children. `CommentNode` is a
|
|
7
|
+
* `DecoratorNode`: a bare comment has no text of its own to put a caret in. `BreakNode` is a break
|
|
8
|
+
* carried inside an insertion or a deletion, not a mark kind of its own (decision 16) — the fifth
|
|
9
|
+
* node class, on a different axis from the other four.
|
|
10
|
+
*
|
|
11
|
+
* No markdown import/export here — that is `transformers.ts` (Track N2). No DOM paste/export
|
|
12
|
+
* override either; nothing in the spec asks for one.
|
|
13
|
+
*/
|
|
14
|
+
import { DecoratorNode, ElementNode, type DOMExportOutput, type EditorConfig, type LexicalEditor, type LexicalNode, type NodeKey, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
|
|
15
|
+
/**
|
|
16
|
+
* Shared behavior for the three ElementNode marks. Inline, never empty, and typing at its own
|
|
17
|
+
* boundary must not silently extend it (decision 6) — Lexical asks the node AT the selection
|
|
18
|
+
* boundary whether it accepts typed text, independent of where a prior `.select()` call pointed, so
|
|
19
|
+
* this has to be a node-level override, not a caret-placement fix. `InsertionNode` overrides both
|
|
20
|
+
* back to `true`: absorbing typing at its own edge is what lets "keep typing" extend one suggestion
|
|
21
|
+
* instead of a new mark per keystroke.
|
|
22
|
+
*/
|
|
23
|
+
declare abstract class MarkNode extends ElementNode {
|
|
24
|
+
isInline(): boolean;
|
|
25
|
+
canBeEmpty(): boolean;
|
|
26
|
+
updateDOM(_prevNode: this, _dom: HTMLElement, _config: EditorConfig): boolean;
|
|
27
|
+
canInsertTextBefore(): boolean;
|
|
28
|
+
canInsertTextAfter(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export type SerializedInsertionNode = SerializedElementNode;
|
|
31
|
+
/** `{++inserted++}`. Renders as `<ins class="dj-cm-insertion">`. */
|
|
32
|
+
export declare class InsertionNode extends MarkNode {
|
|
33
|
+
static getType(): string;
|
|
34
|
+
static clone(node: InsertionNode): InsertionNode;
|
|
35
|
+
createDOM(): HTMLElement;
|
|
36
|
+
static importJSON(serializedNode: SerializedInsertionNode): InsertionNode;
|
|
37
|
+
exportJSON(): SerializedInsertionNode;
|
|
38
|
+
/** The one exception to decision 6: typing at either edge extends the insertion. */
|
|
39
|
+
canInsertTextBefore(): boolean;
|
|
40
|
+
canInsertTextAfter(): boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function $createInsertionNode(): InsertionNode;
|
|
43
|
+
export declare function $isInsertionNode(node: LexicalNode | null | undefined): node is InsertionNode;
|
|
44
|
+
export type SerializedDeletionNode = SerializedElementNode;
|
|
45
|
+
/** `{--deleted--}`. Renders as `<del class="dj-cm-deletion">`. */
|
|
46
|
+
export declare class DeletionNode extends MarkNode {
|
|
47
|
+
static getType(): string;
|
|
48
|
+
static clone(node: DeletionNode): DeletionNode;
|
|
49
|
+
createDOM(): HTMLElement;
|
|
50
|
+
static importJSON(serializedNode: SerializedDeletionNode): DeletionNode;
|
|
51
|
+
exportJSON(): SerializedDeletionNode;
|
|
52
|
+
}
|
|
53
|
+
export declare function $createDeletionNode(): DeletionNode;
|
|
54
|
+
export declare function $isDeletionNode(node: LexicalNode | null | undefined): node is DeletionNode;
|
|
55
|
+
export type SerializedHighlightNode = Spread<{
|
|
56
|
+
comment: string | null;
|
|
57
|
+
}, SerializedElementNode>;
|
|
58
|
+
/**
|
|
59
|
+
* `{==highlight==}`, optionally carrying an anchored comment (`{>>note<<}`) as state rather than as
|
|
60
|
+
* a sibling node — decision 4: an anchored comment is a highlight CARRYING a note, not two marks.
|
|
61
|
+
* Renders as `<mark class="dj-cm-highlight">`, plus `dj-cm-has-comment` when a comment is set.
|
|
62
|
+
*/
|
|
63
|
+
export declare class HighlightNode extends MarkNode {
|
|
64
|
+
__comment: string | null;
|
|
65
|
+
static getType(): string;
|
|
66
|
+
static clone(node: HighlightNode): HighlightNode;
|
|
67
|
+
createDOM(): HTMLElement;
|
|
68
|
+
/** Only the comment presence can change post-creation; patch the class in place. */
|
|
69
|
+
updateDOM(_prevNode: this, dom: HTMLElement, _config: EditorConfig): boolean;
|
|
70
|
+
static importJSON(serializedNode: SerializedHighlightNode): HighlightNode;
|
|
71
|
+
exportJSON(): SerializedHighlightNode;
|
|
72
|
+
getComment(): string | null;
|
|
73
|
+
setComment(comment: string | null): this;
|
|
74
|
+
}
|
|
75
|
+
export declare function $createHighlightNode(): HighlightNode;
|
|
76
|
+
export declare function $isHighlightNode(node: LexicalNode | null | undefined): node is HighlightNode;
|
|
77
|
+
export type SerializedCommentNode = Spread<{
|
|
78
|
+
text: string;
|
|
79
|
+
}, SerializedLexicalNode>;
|
|
80
|
+
/**
|
|
81
|
+
* `{>>note<<}`, bare or anchored (anchored state lives on the `HighlightNode` it follows, per
|
|
82
|
+
* decision 4 — this class only ever represents a BARE comment). A `DecoratorNode`: it has no text of
|
|
83
|
+
* its own to put a caret in. Renders as an operable `<button>`, not a styled span (decision 5) — the
|
|
84
|
+
* popup behavior belongs to Track T3/T4; this is the minimal, correct shell it builds on.
|
|
85
|
+
*/
|
|
86
|
+
export declare class CommentNode extends DecoratorNode<HTMLElement> {
|
|
87
|
+
#private;
|
|
88
|
+
__text: string;
|
|
89
|
+
static getType(): string;
|
|
90
|
+
static clone(node: CommentNode): CommentNode;
|
|
91
|
+
constructor(text: string, key?: NodeKey);
|
|
92
|
+
isInline(): boolean;
|
|
93
|
+
static importJSON(serializedNode: SerializedCommentNode): CommentNode;
|
|
94
|
+
exportJSON(): SerializedCommentNode;
|
|
95
|
+
createDOM(): HTMLElement;
|
|
96
|
+
updateDOM(): boolean;
|
|
97
|
+
exportDOM(): DOMExportOutput;
|
|
98
|
+
/** The button the container mounts; accessible name is the comment text itself. */
|
|
99
|
+
decorate(_editor: LexicalEditor): HTMLElement;
|
|
100
|
+
getText(): string;
|
|
101
|
+
setText(text: string): this;
|
|
102
|
+
}
|
|
103
|
+
export declare function $createCommentNode(text: string): CommentNode;
|
|
104
|
+
export declare function $isCommentNode(node: LexicalNode | null | undefined): node is CommentNode;
|
|
105
|
+
export type SerializedBreakNode = SerializedLexicalNode;
|
|
106
|
+
/**
|
|
107
|
+
* A proposed paragraph break, carried as a token inside an insertion or a deletion (decision 16) —
|
|
108
|
+
* never a top-level mark kind of its own. Renders as a pilcrow pill.
|
|
109
|
+
*
|
|
110
|
+
* MUST override `getTextContent()`: a `DecoratorNode` returns `""` by default, and the insertion and
|
|
111
|
+
* deletion transformers export their content THROUGH `getTextContent()` — so without this override an
|
|
112
|
+
* insertion holding a break exports as `{++++}`, an empty mark, with nothing thrown to say why.
|
|
113
|
+
*/
|
|
114
|
+
export declare class BreakNode extends DecoratorNode<HTMLElement> {
|
|
115
|
+
static getType(): string;
|
|
116
|
+
static clone(node: BreakNode): BreakNode;
|
|
117
|
+
isInline(): boolean;
|
|
118
|
+
static importJSON(_serializedNode: SerializedBreakNode): BreakNode;
|
|
119
|
+
exportJSON(): SerializedBreakNode;
|
|
120
|
+
createDOM(): HTMLElement;
|
|
121
|
+
updateDOM(): boolean;
|
|
122
|
+
decorate(): HTMLElement;
|
|
123
|
+
getTextContent(): string;
|
|
124
|
+
}
|
|
125
|
+
export declare function $createBreakNode(): BreakNode;
|
|
126
|
+
export declare function $isBreakNode(node: LexicalNode | null | undefined): node is BreakNode;
|
|
127
|
+
/** Any of the four MARK classes (decision 4) — `BreakNode` is a different axis, not a mark kind. */
|
|
128
|
+
export declare function $isCriticMark(node: LexicalNode | null | undefined): node is InsertionNode | DeletionNode | HighlightNode | CommentNode;
|
|
129
|
+
/**
|
|
130
|
+
* Content CSS: insertion underlined, deletion struck through, highlight background, comment as an
|
|
131
|
+
* operable control, break as a small pill — all from `--dj-*` tokens, with a forced-colors fallback
|
|
132
|
+
* so every mark stays distinguishable when backgrounds flatten. Injected once via
|
|
133
|
+
* `ensureEditorStyles("dj-rich-text-criticmarkup", CONTENT_CSS)`, called from the plugin's own
|
|
134
|
+
* `setup()` (Track T), not from this module — node files don't touch the DOM at import time.
|
|
135
|
+
*/
|
|
136
|
+
export declare const CONTENT_CSS = "\ndj-rich-text ins.dj-cm-insertion { text-decoration: underline; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-success, #16a34a); text-decoration-skip-ink: none; background: var(--dj-color-success-100, #dcfce7); }\ndj-rich-text del.dj-cm-deletion { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dj-color-danger, #dc2626); background: var(--dj-color-danger-100, #fee2e2); }\ndj-rich-text mark.dj-cm-highlight { background: var(--dj-color-warning-100, #fef3c7); color: inherit; }\ndj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning, #d97706); }\ndj-rich-text .dj-cm-comment-button { display: inline-flex; align-items: center; justify-content: center; width: 1.1em; height: 1.1em; padding: 0; border: none; border-radius: 999px; background: var(--dj-color-primary, #2563eb); color: var(--dj-color-on-primary, #fff); font-size: .75em; line-height: 1; cursor: pointer; }\ndj-rich-text .dj-cm-comment-button::before { content: \"\\1F4AC\"; }\ndj-rich-text .dj-cm-break-pill { display: inline-block; padding: 0 .3em; border-radius: 3px; background: var(--dj-color-primary-100, #dbeafe); color: var(--dj-color-primary, #2563eb); font-size: .85em; }\n@media (forced-colors: active) {\n\tdj-rich-text ins.dj-cm-insertion, dj-rich-text del.dj-cm-deletion { background: transparent; text-decoration-color: CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight { background: Mark; color: MarkText; border: 1px solid CanvasText; }\n\tdj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: none; border-style: dashed; }\n\tdj-rich-text .dj-cm-comment-button { forced-color-adjust: none; background: Highlight; color: HighlightText; border: 1px solid CanvasText; }\n\tdj-rich-text .dj-cm-break-pill { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; }\n}\n";
|
|
137
|
+
export {};
|