@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/nodes.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
15
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
16
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
17
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
18
|
+
};
|
|
19
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
20
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
21
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
22
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
23
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
24
|
+
};
|
|
25
|
+
var _CommentNode_el;
|
|
26
|
+
import { $applyNodeReplacement, DecoratorNode, ElementNode, } from "lexical";
|
|
27
|
+
import { PARAGRAPH_TOKEN } from "./grammar.js";
|
|
28
|
+
// --- MarkNode: shared base for the three ElementNode-based marks --------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Shared behavior for the three ElementNode marks. Inline, never empty, and typing at its own
|
|
31
|
+
* boundary must not silently extend it (decision 6) — Lexical asks the node AT the selection
|
|
32
|
+
* boundary whether it accepts typed text, independent of where a prior `.select()` call pointed, so
|
|
33
|
+
* this has to be a node-level override, not a caret-placement fix. `InsertionNode` overrides both
|
|
34
|
+
* back to `true`: absorbing typing at its own edge is what lets "keep typing" extend one suggestion
|
|
35
|
+
* instead of a new mark per keystroke.
|
|
36
|
+
*/
|
|
37
|
+
class MarkNode extends ElementNode {
|
|
38
|
+
isInline() {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
canBeEmpty() {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
updateDOM(_prevNode, _dom, _config) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
canInsertTextBefore() {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
canInsertTextAfter() {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function markDOM(tag, className) {
|
|
55
|
+
const el = document.createElement(tag);
|
|
56
|
+
el.className = className;
|
|
57
|
+
return el;
|
|
58
|
+
}
|
|
59
|
+
/** `{++inserted++}`. Renders as `<ins class="dj-cm-insertion">`. */
|
|
60
|
+
export class InsertionNode extends MarkNode {
|
|
61
|
+
static getType() {
|
|
62
|
+
return "dj-criticmarkup-insertion";
|
|
63
|
+
}
|
|
64
|
+
static clone(node) {
|
|
65
|
+
return new InsertionNode(node.__key);
|
|
66
|
+
}
|
|
67
|
+
createDOM() {
|
|
68
|
+
return markDOM("ins", "dj-cm-insertion");
|
|
69
|
+
}
|
|
70
|
+
static importJSON(serializedNode) {
|
|
71
|
+
const node = $createInsertionNode();
|
|
72
|
+
node.setFormat(serializedNode.format);
|
|
73
|
+
node.setIndent(serializedNode.indent);
|
|
74
|
+
node.setDirection(serializedNode.direction);
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
exportJSON() {
|
|
78
|
+
return { ...super.exportJSON(), type: "dj-criticmarkup-insertion", version: 1 };
|
|
79
|
+
}
|
|
80
|
+
/** The one exception to decision 6: typing at either edge extends the insertion. */
|
|
81
|
+
canInsertTextBefore() {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
canInsertTextAfter() {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function $createInsertionNode() {
|
|
89
|
+
return $applyNodeReplacement(new InsertionNode());
|
|
90
|
+
}
|
|
91
|
+
export function $isInsertionNode(node) {
|
|
92
|
+
return node instanceof InsertionNode;
|
|
93
|
+
}
|
|
94
|
+
/** `{--deleted--}`. Renders as `<del class="dj-cm-deletion">`. */
|
|
95
|
+
export class DeletionNode extends MarkNode {
|
|
96
|
+
static getType() {
|
|
97
|
+
return "dj-criticmarkup-deletion";
|
|
98
|
+
}
|
|
99
|
+
static clone(node) {
|
|
100
|
+
return new DeletionNode(node.__key);
|
|
101
|
+
}
|
|
102
|
+
createDOM() {
|
|
103
|
+
return markDOM("del", "dj-cm-deletion");
|
|
104
|
+
}
|
|
105
|
+
static importJSON(serializedNode) {
|
|
106
|
+
const node = $createDeletionNode();
|
|
107
|
+
node.setFormat(serializedNode.format);
|
|
108
|
+
node.setIndent(serializedNode.indent);
|
|
109
|
+
node.setDirection(serializedNode.direction);
|
|
110
|
+
return node;
|
|
111
|
+
}
|
|
112
|
+
exportJSON() {
|
|
113
|
+
return { ...super.exportJSON(), type: "dj-criticmarkup-deletion", version: 1 };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function $createDeletionNode() {
|
|
117
|
+
return $applyNodeReplacement(new DeletionNode());
|
|
118
|
+
}
|
|
119
|
+
export function $isDeletionNode(node) {
|
|
120
|
+
return node instanceof DeletionNode;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* `{==highlight==}`, optionally carrying an anchored comment (`{>>note<<}`) as state rather than as
|
|
124
|
+
* a sibling node — decision 4: an anchored comment is a highlight CARRYING a note, not two marks.
|
|
125
|
+
* Renders as `<mark class="dj-cm-highlight">`, plus `dj-cm-has-comment` when a comment is set.
|
|
126
|
+
*/
|
|
127
|
+
export class HighlightNode extends MarkNode {
|
|
128
|
+
constructor() {
|
|
129
|
+
super(...arguments);
|
|
130
|
+
this.__comment = null;
|
|
131
|
+
}
|
|
132
|
+
static getType() {
|
|
133
|
+
return "dj-criticmarkup-highlight";
|
|
134
|
+
}
|
|
135
|
+
static clone(node) {
|
|
136
|
+
const cloned = new HighlightNode(node.__key);
|
|
137
|
+
cloned.__comment = node.__comment;
|
|
138
|
+
return cloned;
|
|
139
|
+
}
|
|
140
|
+
createDOM() {
|
|
141
|
+
const el = markDOM("mark", "dj-cm-highlight");
|
|
142
|
+
if (this.__comment !== null)
|
|
143
|
+
el.classList.add("dj-cm-has-comment");
|
|
144
|
+
return el;
|
|
145
|
+
}
|
|
146
|
+
/** Only the comment presence can change post-creation; patch the class in place. */
|
|
147
|
+
updateDOM(_prevNode, dom, _config) {
|
|
148
|
+
dom.classList.toggle("dj-cm-has-comment", this.__comment !== null);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
static importJSON(serializedNode) {
|
|
152
|
+
const node = $createHighlightNode();
|
|
153
|
+
node.setComment(serializedNode.comment);
|
|
154
|
+
node.setFormat(serializedNode.format);
|
|
155
|
+
node.setIndent(serializedNode.indent);
|
|
156
|
+
node.setDirection(serializedNode.direction);
|
|
157
|
+
return node;
|
|
158
|
+
}
|
|
159
|
+
exportJSON() {
|
|
160
|
+
return {
|
|
161
|
+
...super.exportJSON(),
|
|
162
|
+
comment: this.getComment(),
|
|
163
|
+
type: "dj-criticmarkup-highlight",
|
|
164
|
+
version: 1,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
getComment() {
|
|
168
|
+
return this.getLatest().__comment;
|
|
169
|
+
}
|
|
170
|
+
setComment(comment) {
|
|
171
|
+
const writable = this.getWritable();
|
|
172
|
+
writable.__comment = comment;
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function $createHighlightNode() {
|
|
177
|
+
return $applyNodeReplacement(new HighlightNode());
|
|
178
|
+
}
|
|
179
|
+
export function $isHighlightNode(node) {
|
|
180
|
+
return node instanceof HighlightNode;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `{>>note<<}`, bare or anchored (anchored state lives on the `HighlightNode` it follows, per
|
|
184
|
+
* decision 4 — this class only ever represents a BARE comment). A `DecoratorNode`: it has no text of
|
|
185
|
+
* its own to put a caret in. Renders as an operable `<button>`, not a styled span (decision 5) — the
|
|
186
|
+
* popup behavior belongs to Track T3/T4; this is the minimal, correct shell it builds on.
|
|
187
|
+
*/
|
|
188
|
+
export class CommentNode extends DecoratorNode {
|
|
189
|
+
static getType() {
|
|
190
|
+
return "dj-criticmarkup-comment";
|
|
191
|
+
}
|
|
192
|
+
static clone(node) {
|
|
193
|
+
return new CommentNode(node.__text, node.__key);
|
|
194
|
+
}
|
|
195
|
+
constructor(text, key) {
|
|
196
|
+
super(key);
|
|
197
|
+
_CommentNode_el.set(this, void 0);
|
|
198
|
+
this.__text = text;
|
|
199
|
+
}
|
|
200
|
+
isInline() {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
static importJSON(serializedNode) {
|
|
204
|
+
return $createCommentNode(serializedNode.text);
|
|
205
|
+
}
|
|
206
|
+
exportJSON() {
|
|
207
|
+
// DecoratorNode does not implement exportJSON (the base throws); build the object directly.
|
|
208
|
+
return { type: "dj-criticmarkup-comment", version: 1, text: this.getText() };
|
|
209
|
+
}
|
|
210
|
+
createDOM() {
|
|
211
|
+
const span = document.createElement("span");
|
|
212
|
+
span.className = "dj-cm-comment";
|
|
213
|
+
return span;
|
|
214
|
+
}
|
|
215
|
+
updateDOM() {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
exportDOM() {
|
|
219
|
+
const el = document.createElement("span");
|
|
220
|
+
el.textContent = this.getText();
|
|
221
|
+
return { element: el };
|
|
222
|
+
}
|
|
223
|
+
/** The button the container mounts; accessible name is the comment text itself. */
|
|
224
|
+
decorate(_editor) {
|
|
225
|
+
if (!__classPrivateFieldGet(this, _CommentNode_el, "f") || __classPrivateFieldGet(this, _CommentNode_el, "f").textContent !== this.__text) {
|
|
226
|
+
const button = document.createElement("button");
|
|
227
|
+
button.type = "button";
|
|
228
|
+
button.className = "dj-cm-comment-button";
|
|
229
|
+
button.textContent = this.__text;
|
|
230
|
+
__classPrivateFieldSet(this, _CommentNode_el, button, "f");
|
|
231
|
+
}
|
|
232
|
+
return __classPrivateFieldGet(this, _CommentNode_el, "f");
|
|
233
|
+
}
|
|
234
|
+
getText() {
|
|
235
|
+
return this.getLatest().__text;
|
|
236
|
+
}
|
|
237
|
+
setText(text) {
|
|
238
|
+
const writable = this.getWritable();
|
|
239
|
+
writable.__text = text;
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
_CommentNode_el = new WeakMap();
|
|
244
|
+
export function $createCommentNode(text) {
|
|
245
|
+
return $applyNodeReplacement(new CommentNode(text));
|
|
246
|
+
}
|
|
247
|
+
export function $isCommentNode(node) {
|
|
248
|
+
return node instanceof CommentNode;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* A proposed paragraph break, carried as a token inside an insertion or a deletion (decision 16) —
|
|
252
|
+
* never a top-level mark kind of its own. Renders as a pilcrow pill.
|
|
253
|
+
*
|
|
254
|
+
* MUST override `getTextContent()`: a `DecoratorNode` returns `""` by default, and the insertion and
|
|
255
|
+
* deletion transformers export their content THROUGH `getTextContent()` — so without this override an
|
|
256
|
+
* insertion holding a break exports as `{++++}`, an empty mark, with nothing thrown to say why.
|
|
257
|
+
*/
|
|
258
|
+
export class BreakNode extends DecoratorNode {
|
|
259
|
+
static getType() {
|
|
260
|
+
return "dj-criticmarkup-break";
|
|
261
|
+
}
|
|
262
|
+
static clone(node) {
|
|
263
|
+
return new BreakNode(node.__key);
|
|
264
|
+
}
|
|
265
|
+
isInline() {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
static importJSON(_serializedNode) {
|
|
269
|
+
return $createBreakNode();
|
|
270
|
+
}
|
|
271
|
+
exportJSON() {
|
|
272
|
+
return { type: "dj-criticmarkup-break", version: 1 };
|
|
273
|
+
}
|
|
274
|
+
createDOM() {
|
|
275
|
+
const span = document.createElement("span");
|
|
276
|
+
span.className = "dj-cm-break";
|
|
277
|
+
return span;
|
|
278
|
+
}
|
|
279
|
+
updateDOM() {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
decorate() {
|
|
283
|
+
const pill = document.createElement("span");
|
|
284
|
+
pill.className = "dj-cm-break-pill";
|
|
285
|
+
pill.textContent = PARAGRAPH_TOKEN;
|
|
286
|
+
return pill;
|
|
287
|
+
}
|
|
288
|
+
getTextContent() {
|
|
289
|
+
return PARAGRAPH_TOKEN;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export function $createBreakNode() {
|
|
293
|
+
return $applyNodeReplacement(new BreakNode());
|
|
294
|
+
}
|
|
295
|
+
export function $isBreakNode(node) {
|
|
296
|
+
return node instanceof BreakNode;
|
|
297
|
+
}
|
|
298
|
+
// --- shared ----------------------------------------------------------------------------------
|
|
299
|
+
/** Any of the four MARK classes (decision 4) — `BreakNode` is a different axis, not a mark kind. */
|
|
300
|
+
export function $isCriticMark(node) {
|
|
301
|
+
return $isInsertionNode(node) || $isDeletionNode(node) || $isHighlightNode(node) || $isCommentNode(node);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Content CSS: insertion underlined, deletion struck through, highlight background, comment as an
|
|
305
|
+
* operable control, break as a small pill — all from `--dj-*` tokens, with a forced-colors fallback
|
|
306
|
+
* so every mark stays distinguishable when backgrounds flatten. Injected once via
|
|
307
|
+
* `ensureEditorStyles("dj-rich-text-criticmarkup", CONTENT_CSS)`, called from the plugin's own
|
|
308
|
+
* `setup()` (Track T), not from this module — node files don't touch the DOM at import time.
|
|
309
|
+
*/
|
|
310
|
+
export const CONTENT_CSS = `
|
|
311
|
+
dj-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); }
|
|
312
|
+
dj-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); }
|
|
313
|
+
dj-rich-text mark.dj-cm-highlight { background: var(--dj-color-warning-100, #fef3c7); color: inherit; }
|
|
314
|
+
dj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: inset 0 -2px 0 var(--dj-color-warning, #d97706); }
|
|
315
|
+
dj-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; }
|
|
316
|
+
dj-rich-text .dj-cm-comment-button::before { content: "\\1F4AC"; }
|
|
317
|
+
dj-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; }
|
|
318
|
+
@media (forced-colors: active) {
|
|
319
|
+
dj-rich-text ins.dj-cm-insertion, dj-rich-text del.dj-cm-deletion { background: transparent; text-decoration-color: CanvasText; }
|
|
320
|
+
dj-rich-text mark.dj-cm-highlight { background: Mark; color: MarkText; border: 1px solid CanvasText; }
|
|
321
|
+
dj-rich-text mark.dj-cm-highlight.dj-cm-has-comment { box-shadow: none; border-style: dashed; }
|
|
322
|
+
dj-rich-text .dj-cm-comment-button { forced-color-adjust: none; background: Highlight; color: HighlightText; border: 1px solid CanvasText; }
|
|
323
|
+
dj-rich-text .dj-cm-break-pill { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; }
|
|
324
|
+
}
|
|
325
|
+
`;
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assembled `RichTextPlugin` (Track T3): the toolbar (six controls in group `criticmarkup`),
|
|
3
|
+
* the `criticmarkup` format, and the `setup()` that ties every earlier track together — the
|
|
4
|
+
* suggestion-mode listener/commands (S1/S3), the decorator mount for `CommentNode`/`BreakNode`
|
|
5
|
+
* (deferred from N1), the content styles (deferred from N1), and the shared comment popup (T3/T4).
|
|
6
|
+
*/
|
|
7
|
+
import { type RichTextPlugin } from "@dojo-ng/rich-text";
|
|
8
|
+
import "@dojo-ng/popup-confirmation";
|
|
9
|
+
import "@dojo-ng/button";
|
|
10
|
+
import { type StructuralPolicy } from "./suggestion-mode.js";
|
|
11
|
+
export interface CriticMarkupOptions {
|
|
12
|
+
/** Start in suggestion mode. Default `false`. */
|
|
13
|
+
suggesting?: boolean;
|
|
14
|
+
/** What suggestion mode does with a block-structure change (decisions 14, 16). Default `"mark"`. */
|
|
15
|
+
structuralEdits?: StructuralPolicy;
|
|
16
|
+
/** The paragraph-break token (decision 16). Default `PARAGRAPH_TOKEN`. */
|
|
17
|
+
paragraphToken?: string;
|
|
18
|
+
/** Confirm accept-all / decline-all through `dj-popup-confirmation`. Default `true`. */
|
|
19
|
+
confirmBulk?: boolean;
|
|
20
|
+
/** Contribute the six toolbar controls. Default `true`. */
|
|
21
|
+
toolbar?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Build the CriticMarkup plugin. */
|
|
24
|
+
export declare function createCriticMarkupPlugin(options?: CriticMarkupOptions): RichTextPlugin;
|
|
25
|
+
/** The CriticMarkup plugin with the default options. */
|
|
26
|
+
export declare const criticMarkupPlugin: RichTextPlugin;
|
|
27
|
+
export default criticMarkupPlugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assembled `RichTextPlugin` (Track T3): the toolbar (six controls in group `criticmarkup`),
|
|
3
|
+
* the `criticmarkup` format, and the `setup()` that ties every earlier track together — the
|
|
4
|
+
* suggestion-mode listener/commands (S1/S3), the decorator mount for `CommentNode`/`BreakNode`
|
|
5
|
+
* (deferred from N1), the content styles (deferred from N1), and the shared comment popup (T3/T4).
|
|
6
|
+
*/
|
|
7
|
+
import { html } from "lit";
|
|
8
|
+
import { createRef, ref } from "lit/directives/ref.js";
|
|
9
|
+
import { $getNearestNodeFromDOMNode, $getSelection, $isRangeSelection } from "lexical";
|
|
10
|
+
import { mergeRegister } from "@lexical/utils";
|
|
11
|
+
import { getDefaultLocale, messages, registerDefaults } from "@dojo-ng/i18n";
|
|
12
|
+
import { defineRichTextPlugin, ensureEditorStyles } from "@dojo-ng/rich-text";
|
|
13
|
+
import "@dojo-ng/popup-confirmation";
|
|
14
|
+
import "@dojo-ng/button";
|
|
15
|
+
import { CONTENT_CSS, CommentNode, DeletionNode, HighlightNode, InsertionNode, BreakNode, $isCommentNode, $isHighlightNode, } from "./nodes.js";
|
|
16
|
+
import { deserializeCriticMarkup, serializeCriticMarkup } from "./format.js";
|
|
17
|
+
import { PARAGRAPH_TOKEN } from "./grammar.js";
|
|
18
|
+
import { setSuggestionMode, isSuggestionMode, configureSuggestionMode } from "./suggestion-mode.js";
|
|
19
|
+
import { markAtSelection, acceptMark, declineMark, acceptAllMarks, declineAllMarks } from "./resolution.js";
|
|
20
|
+
import { createCommentPopupController } from "./comment-popup.js";
|
|
21
|
+
const EN = {
|
|
22
|
+
suggestEdits: "Suggest edits",
|
|
23
|
+
acceptMark: "Accept suggestion",
|
|
24
|
+
rejectMark: "Reject suggestion",
|
|
25
|
+
acceptAll: "Accept all",
|
|
26
|
+
rejectAll: "Reject all",
|
|
27
|
+
addComment: "Add comment",
|
|
28
|
+
comment: "Comment",
|
|
29
|
+
save: "Save",
|
|
30
|
+
cancel: "Cancel",
|
|
31
|
+
removeNote: "Remove note",
|
|
32
|
+
removeHighlight: "Remove highlight",
|
|
33
|
+
confirmAcceptAll: "Accept every suggested change?",
|
|
34
|
+
confirmRejectAll: "Reject every suggested change?",
|
|
35
|
+
};
|
|
36
|
+
registerDefaults("dj", EN);
|
|
37
|
+
function msg(ctx, key) {
|
|
38
|
+
const locale = ctx.host.getAttribute("lang") || getDefaultLocale();
|
|
39
|
+
return messages.resolve("dj", locale, key) ?? EN[key] ?? key;
|
|
40
|
+
}
|
|
41
|
+
const POPUPS = new WeakMap();
|
|
42
|
+
const WIRED_COMMENT_BUTTONS = new WeakSet();
|
|
43
|
+
/** Build the CriticMarkup plugin. */
|
|
44
|
+
export function createCriticMarkupPlugin(options = {}) {
|
|
45
|
+
const structuralEdits = options.structuralEdits ?? "mark";
|
|
46
|
+
const paragraphToken = options.paragraphToken ?? PARAGRAPH_TOKEN;
|
|
47
|
+
const confirmBulk = options.confirmBulk ?? true;
|
|
48
|
+
const showToolbar = options.toolbar ?? true;
|
|
49
|
+
return defineRichTextPlugin({
|
|
50
|
+
name: "criticmarkup",
|
|
51
|
+
nodes: [InsertionNode, DeletionNode, HighlightNode, CommentNode, BreakNode],
|
|
52
|
+
formats: {
|
|
53
|
+
criticmarkup: {
|
|
54
|
+
serialize: (editor) => serializeCriticMarkup(editor),
|
|
55
|
+
deserialize: (editor, data) => deserializeCriticMarkup(editor, data, { paragraphToken }),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
inserts: [
|
|
59
|
+
{
|
|
60
|
+
id: "comment",
|
|
61
|
+
label: "Comment",
|
|
62
|
+
keywords: ["comment", "note", "annotate"],
|
|
63
|
+
run: (ctx) => POPUPS.get(ctx.editor)?.open({ anchor: ctx.host, mode: "insert-bare", text: "" }),
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
setup: (ctx) => {
|
|
67
|
+
ensureEditorStyles("dj-rich-text-criticmarkup", CONTENT_CSS);
|
|
68
|
+
configureSuggestionMode(ctx.editor, { structuralEdits });
|
|
69
|
+
if (options.suggesting)
|
|
70
|
+
setSuggestionMode(ctx.editor, true);
|
|
71
|
+
const popup = createCommentPopupController(ctx.editor, (key) => msg(ctx, key));
|
|
72
|
+
ctx.host.appendChild(popup.element);
|
|
73
|
+
POPUPS.set(ctx.editor, popup);
|
|
74
|
+
// The highlight's own affordance (decision 15): clicking a highlight that carries a
|
|
75
|
+
// comment opens the same popup an anchored comment is edited through, event-delegated
|
|
76
|
+
// off the host rather than wired per-node (HighlightNode is a plain ElementNode, not a
|
|
77
|
+
// decorator, so it has no per-instance mount hook to attach a listener from).
|
|
78
|
+
const onHostClick = (event) => {
|
|
79
|
+
const target = event.target;
|
|
80
|
+
if (!(target instanceof Element))
|
|
81
|
+
return;
|
|
82
|
+
const mark = target.closest(".dj-cm-highlight.dj-cm-has-comment");
|
|
83
|
+
if (!mark)
|
|
84
|
+
return;
|
|
85
|
+
let node = null;
|
|
86
|
+
let text = "";
|
|
87
|
+
// editor.read(), not editorState.read(): $getNearestNodeFromDOMNode needs the
|
|
88
|
+
// ACTIVE EDITOR (for the DOM-node-to-key lookup), which only editor.read() sets —
|
|
89
|
+
// editorState.read() sets only the active editor STATE and throws Lexical error
|
|
90
|
+
// #196 ("unable to find an active editor") the moment it's called.
|
|
91
|
+
ctx.editor.read(() => {
|
|
92
|
+
const found = $getNearestNodeFromDOMNode(mark);
|
|
93
|
+
if (found && $isHighlightNode(found)) {
|
|
94
|
+
node = found;
|
|
95
|
+
text = found.getComment() ?? "";
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (node)
|
|
99
|
+
popup.open({ anchor: mark, mode: "edit-highlight", text, node });
|
|
100
|
+
};
|
|
101
|
+
ctx.host.addEventListener("click", onHostClick);
|
|
102
|
+
return mergeRegister(
|
|
103
|
+
// Mount CommentNode/BreakNode decorate() output (deferred from N1) and wire the
|
|
104
|
+
// comment button to open the popup, once per button — the click handler resolves
|
|
105
|
+
// the CURRENT node fresh each time rather than closing over one, so it never shows
|
|
106
|
+
// stale text if the note changed by some other path since the button was wired.
|
|
107
|
+
ctx.editor.registerDecoratorListener((decorators) => {
|
|
108
|
+
for (const [key, el] of Object.entries(decorators)) {
|
|
109
|
+
const container = ctx.editor.getElementByKey(key);
|
|
110
|
+
if (!container)
|
|
111
|
+
continue;
|
|
112
|
+
// `replaceChildren`, not a conditional `appendChild`: `CommentNode`/`BreakNode`
|
|
113
|
+
// both hand back a NEW element instance on some `decorate()` calls (an unrelated
|
|
114
|
+
// sibling edit is enough — Lexical re-clones a node whose OWN `__next`/`__prev`
|
|
115
|
+
// pointer just changed, even though nothing the node itself owns did, and a
|
|
116
|
+
// fresh clone's `decorate()` cache starts empty). `appendChild`-if-not-already-a-
|
|
117
|
+
// child leaves the STALE element from the previous call sitting in `container`
|
|
118
|
+
// forever, so the same single comment/break visibly multiplies with every nearby
|
|
119
|
+
// edit. Clearing first makes `container` hold exactly the current `el`, however
|
|
120
|
+
// many times `decorate()` has run for this key.
|
|
121
|
+
if (container.firstChild !== el || container.childNodes.length > 1)
|
|
122
|
+
container.replaceChildren(el);
|
|
123
|
+
// tagName, not `instanceof HTMLButtonElement` — a real button either way, but
|
|
124
|
+
// this avoids depending on that specific global existing (some DOM test
|
|
125
|
+
// environments curate a narrower global set than a real browser's).
|
|
126
|
+
if (el.tagName === "BUTTON" && !WIRED_COMMENT_BUTTONS.has(el)) {
|
|
127
|
+
WIRED_COMMENT_BUTTONS.add(el);
|
|
128
|
+
el.addEventListener("click", () => {
|
|
129
|
+
let node = null;
|
|
130
|
+
let text = "";
|
|
131
|
+
ctx.editor.read(() => {
|
|
132
|
+
const found = $getNearestNodeFromDOMNode(el);
|
|
133
|
+
if (found && $isCommentNode(found)) {
|
|
134
|
+
node = found;
|
|
135
|
+
text = found.getText();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (node)
|
|
139
|
+
popup.open({ anchor: el, mode: "edit-comment", text, node });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}), () => ctx.host.removeEventListener("click", onHostClick), () => POPUPS.delete(ctx.editor));
|
|
144
|
+
},
|
|
145
|
+
toolbar: showToolbar ? (ctx) => toolbarItems(ctx, confirmBulk) : undefined,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** The CriticMarkup plugin with the default options. */
|
|
149
|
+
export const criticMarkupPlugin = createCriticMarkupPlugin();
|
|
150
|
+
export default criticMarkupPlugin;
|
|
151
|
+
// --- toolbar ------------------------------------------------------------------------------------
|
|
152
|
+
function toolbarItems(ctx, confirmBulk) {
|
|
153
|
+
const addCommentRef = createRef();
|
|
154
|
+
const acceptAllContent = confirmBulk
|
|
155
|
+
? html `<dj-popup-confirmation confirm-label=${msg(ctx, "acceptAll")} @dj-confirm=${() => acceptAllMarks(ctx.editor)}>
|
|
156
|
+
<span slot="content">${msg(ctx, "confirmAcceptAll")}</span>
|
|
157
|
+
<dj-button kind="text" title=${msg(ctx, "acceptAll")}>${msg(ctx, "acceptAll")}</dj-button>
|
|
158
|
+
</dj-popup-confirmation>`
|
|
159
|
+
: html `<dj-button kind="text" title=${msg(ctx, "acceptAll")} @click=${() => acceptAllMarks(ctx.editor)}
|
|
160
|
+
>${msg(ctx, "acceptAll")}</dj-button
|
|
161
|
+
>`;
|
|
162
|
+
const rejectAllContent = confirmBulk
|
|
163
|
+
? html `<dj-popup-confirmation confirm-label=${msg(ctx, "rejectAll")} @dj-confirm=${() => declineAllMarks(ctx.editor)}>
|
|
164
|
+
<span slot="content">${msg(ctx, "confirmRejectAll")}</span>
|
|
165
|
+
<dj-button kind="text" title=${msg(ctx, "rejectAll")}>${msg(ctx, "rejectAll")}</dj-button>
|
|
166
|
+
</dj-popup-confirmation>`
|
|
167
|
+
: html `<dj-button kind="text" title=${msg(ctx, "rejectAll")} @click=${() => declineAllMarks(ctx.editor)}
|
|
168
|
+
>${msg(ctx, "rejectAll")}</dj-button
|
|
169
|
+
>`;
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
id: "criticmarkup-suggest",
|
|
173
|
+
group: "criticmarkup",
|
|
174
|
+
order: 0,
|
|
175
|
+
label: msg(ctx, "suggestEdits"),
|
|
176
|
+
isActive: (c) => isSuggestionMode(c.editor),
|
|
177
|
+
run: (c) => setSuggestionMode(c.editor, !isSuggestionMode(c.editor)),
|
|
178
|
+
// A custom render, not the core's default icon+aria-label button: axe reports that shape
|
|
179
|
+
// (aria-label on a role-less host, an aria-hidden-only icon inside shadow DOM) as an
|
|
180
|
+
// unnamed button in this environment — the same VISIBLE-TEXT shape the other four items
|
|
181
|
+
// already use here is what actually passes. `isActive`/`run` stay set too, so this item's
|
|
182
|
+
// state is still checkable directly (headless tests read `item.isActive(ctx)`), even
|
|
183
|
+
// though the core ignores them once `render` is present.
|
|
184
|
+
render: (c) => html `<dj-button
|
|
185
|
+
kind="text"
|
|
186
|
+
title=${msg(ctx, "suggestEdits")}
|
|
187
|
+
aria-pressed=${isSuggestionMode(c.editor)}
|
|
188
|
+
@click=${() => setSuggestionMode(c.editor, !isSuggestionMode(c.editor))}
|
|
189
|
+
>${msg(ctx, "suggestEdits")}</dj-button
|
|
190
|
+
>`,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "criticmarkup-accept",
|
|
194
|
+
group: "criticmarkup",
|
|
195
|
+
order: 10,
|
|
196
|
+
label: msg(ctx, "acceptMark"),
|
|
197
|
+
isDisabled: (c) => markAtSelection(c.editor) === null,
|
|
198
|
+
run: (c) => {
|
|
199
|
+
const node = markAtSelection(c.editor);
|
|
200
|
+
if (node)
|
|
201
|
+
acceptMark(c.editor, node);
|
|
202
|
+
},
|
|
203
|
+
render: (c) => {
|
|
204
|
+
const disabled = markAtSelection(c.editor) === null;
|
|
205
|
+
return html `<dj-button
|
|
206
|
+
kind="text"
|
|
207
|
+
title=${msg(ctx, "acceptMark")}
|
|
208
|
+
?disabled=${disabled}
|
|
209
|
+
@click=${() => {
|
|
210
|
+
const node = markAtSelection(c.editor);
|
|
211
|
+
if (node)
|
|
212
|
+
acceptMark(c.editor, node);
|
|
213
|
+
}}
|
|
214
|
+
>${msg(ctx, "acceptMark")}</dj-button
|
|
215
|
+
>`;
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "criticmarkup-reject",
|
|
220
|
+
group: "criticmarkup",
|
|
221
|
+
order: 20,
|
|
222
|
+
label: msg(ctx, "rejectMark"),
|
|
223
|
+
isDisabled: (c) => markAtSelection(c.editor) === null,
|
|
224
|
+
run: (c) => {
|
|
225
|
+
const node = markAtSelection(c.editor);
|
|
226
|
+
if (node)
|
|
227
|
+
declineMark(c.editor, node);
|
|
228
|
+
},
|
|
229
|
+
render: (c) => {
|
|
230
|
+
const disabled = markAtSelection(c.editor) === null;
|
|
231
|
+
return html `<dj-button
|
|
232
|
+
kind="text"
|
|
233
|
+
title=${msg(ctx, "rejectMark")}
|
|
234
|
+
?disabled=${disabled}
|
|
235
|
+
@click=${() => {
|
|
236
|
+
const node = markAtSelection(c.editor);
|
|
237
|
+
if (node)
|
|
238
|
+
declineMark(c.editor, node);
|
|
239
|
+
}}
|
|
240
|
+
>${msg(ctx, "rejectMark")}</dj-button
|
|
241
|
+
>`;
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "criticmarkup-accept-all",
|
|
246
|
+
group: "criticmarkup",
|
|
247
|
+
order: 30,
|
|
248
|
+
label: msg(ctx, "acceptAll"),
|
|
249
|
+
render: () => acceptAllContent,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "criticmarkup-reject-all",
|
|
253
|
+
group: "criticmarkup",
|
|
254
|
+
order: 40,
|
|
255
|
+
label: msg(ctx, "rejectAll"),
|
|
256
|
+
render: () => rejectAllContent,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: "criticmarkup-add-comment",
|
|
260
|
+
group: "criticmarkup",
|
|
261
|
+
order: 50,
|
|
262
|
+
label: msg(ctx, "addComment"),
|
|
263
|
+
render: () => html `<dj-button
|
|
264
|
+
${ref(addCommentRef)}
|
|
265
|
+
kind="text"
|
|
266
|
+
title=${msg(ctx, "addComment")}
|
|
267
|
+
@click=${() => {
|
|
268
|
+
const anchor = addCommentRef.value;
|
|
269
|
+
const popup = POPUPS.get(ctx.editor);
|
|
270
|
+
if (!anchor || !popup)
|
|
271
|
+
return;
|
|
272
|
+
const collapsed = isSelectionCollapsed(ctx.editor);
|
|
273
|
+
popup.open({ anchor, mode: collapsed ? "insert-bare" : "insert-anchored", text: "" });
|
|
274
|
+
}}
|
|
275
|
+
>${msg(ctx, "addComment")}</dj-button
|
|
276
|
+
>`,
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
function isSelectionCollapsed(editor) {
|
|
281
|
+
let collapsed = true;
|
|
282
|
+
editor.getEditorState().read(() => {
|
|
283
|
+
const selection = $getSelection();
|
|
284
|
+
if ($isRangeSelection(selection))
|
|
285
|
+
collapsed = selection.isCollapsed();
|
|
286
|
+
});
|
|
287
|
+
return collapsed;
|
|
288
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution (Track T1/T2): `markAtSelection`, `acceptMark`/`declineMark` (one mark), and
|
|
3
|
+
* `acceptAllMarks`/`declineAllMarks` (the whole document). Decision 10's rules, applied directly to
|
|
4
|
+
* the live tree rather than round-tripping through markdown — the two paths are cross-checked in the
|
|
5
|
+
* test file against `grammar.accept`/`decline` on the same starting `value`, which is the point of
|
|
6
|
+
* having one grammar.
|
|
7
|
+
*
|
|
8
|
+
* A substitution is never resolved as a lone deletion or insertion: `acceptMark`/`declineMark` on
|
|
9
|
+
* EITHER half of an adjacent deletion-then-insertion pair (decision 4's shape) resolves the whole
|
|
10
|
+
* pair. A kept insertion/deletion holding a `BreakNode` (decision 16 — a proposed paragraph
|
|
11
|
+
* split/merge) performs the real block split/merge at that point when kept, mirroring exactly what
|
|
12
|
+
* `grammar.accept`/`decline` do to the paragraph token at the string level.
|
|
13
|
+
*
|
|
14
|
+
* No confirmation here — `dj-popup-confirmation` gating `confirmBulk` is a toolbar-level concern
|
|
15
|
+
* (Track T3), not baked into these functions themselves.
|
|
16
|
+
*/
|
|
17
|
+
import { type LexicalEditor, type LexicalNode } from "lexical";
|
|
18
|
+
import type { MarkKind } from "./grammar.js";
|
|
19
|
+
export interface CriticMarkupChangeDetail {
|
|
20
|
+
kind: MarkKind;
|
|
21
|
+
action: "accept" | "decline";
|
|
22
|
+
}
|
|
23
|
+
declare global {
|
|
24
|
+
interface GlobalEventHandlersEventMap {
|
|
25
|
+
"dj-criticmarkup-change": CustomEvent<CriticMarkupChangeDetail>;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The nearest CriticMarkup mark (any of the four kinds) containing the selection's anchor, or
|
|
29
|
+
* `null` if the caret sits in plain prose or there is no range selection. */
|
|
30
|
+
export declare function markAtSelection(editor: LexicalEditor): LexicalNode | null;
|
|
31
|
+
export declare function acceptMark(editor: LexicalEditor, node: LexicalNode): void;
|
|
32
|
+
export declare function declineMark(editor: LexicalEditor, node: LexicalNode): void;
|
|
33
|
+
export declare function acceptAllMarks(editor: LexicalEditor): void;
|
|
34
|
+
export declare function declineAllMarks(editor: LexicalEditor): void;
|