@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/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
+ `;
@@ -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;