@portone/docx-editor 0.1.0 → 0.2.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +17 -5
  3. package/dist/DocxEditor.d.ts +34 -10
  4. package/dist/DocxEditor.js +69 -24
  5. package/dist/core.d.ts +2 -0
  6. package/dist/core.js +2 -0
  7. package/dist/docx/commentOnlyChange.d.ts +45 -0
  8. package/dist/docx/commentOnlyChange.js +145 -0
  9. package/dist/docx/comments/constants.d.ts +8 -0
  10. package/dist/docx/comments/constants.js +6 -0
  11. package/dist/docx/comments/contentTypes.d.ts +7 -0
  12. package/dist/docx/comments/contentTypes.js +38 -0
  13. package/dist/docx/comments/model.d.ts +2 -0
  14. package/dist/docx/comments/model.js +3 -0
  15. package/dist/docx/comments/people.d.ts +39 -0
  16. package/dist/docx/comments/people.js +198 -0
  17. package/dist/docx/comments/reading.d.ts +16 -1
  18. package/dist/docx/comments/reading.js +15 -5
  19. package/dist/docx/comments/writing.d.ts +4 -1
  20. package/dist/docx/comments/writing.js +12 -32
  21. package/dist/docx/importParagraph.js +1 -0
  22. package/dist/editor/commands/breakCommands.js +4 -1
  23. package/dist/editor/commands/canRunCommand.d.ts +3 -2
  24. package/dist/editor/commands/canRunCommand.js +1 -1
  25. package/dist/editor/commands/comments/editing.js +6 -4
  26. package/dist/editor/commands/comments/model.d.ts +9 -0
  27. package/dist/editor/commands/comments/reading.d.ts +7 -0
  28. package/dist/editor/commands/comments/reading.js +14 -0
  29. package/dist/editor/commands/formatting/editing.js +6 -1
  30. package/dist/editor/commands/formatting/reading.js +13 -4
  31. package/dist/editor/commands/historyCommands.js +14 -6
  32. package/dist/editor/commands/index.d.ts +7 -1
  33. package/dist/editor/commands/index.js +4 -0
  34. package/dist/editor/commands/linkCommands.js +2 -0
  35. package/dist/editor/commands/lockCommands.js +2 -0
  36. package/dist/editor/commands/tabCommands.js +2 -1
  37. package/dist/editor/createEditor.d.ts +13 -3
  38. package/dist/editor/createEditor.js +21 -7
  39. package/dist/editor/insertImage.js +4 -1
  40. package/dist/editor/insertTable.js +2 -1
  41. package/dist/editor/paragraphEdits.d.ts +2 -0
  42. package/dist/editor/paragraphEdits.js +2 -0
  43. package/dist/editor/plugins/documentProtection.d.ts +21 -0
  44. package/dist/editor/plugins/documentProtection.js +44 -0
  45. package/dist/editor/plugins/imagePaste.js +4 -1
  46. package/dist/editor/plugins/keymap.d.ts +11 -1
  47. package/dist/editor/plugins/keymap.js +24 -4
  48. package/dist/editor/plugins/lockedContent.d.ts +4 -2
  49. package/dist/editor/plugins/lockedContent.js +1 -1
  50. package/dist/editor/plugins/tableContextMenu.js +2 -1
  51. package/dist/editor/plugins/textContextMenu.js +6 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/numbering/markers.js +13 -1
  54. package/dist/schema/docxSchema.js +5 -0
  55. package/dist/schema/locks.d.ts +31 -3
  56. package/dist/schema/locks.js +54 -3
  57. package/dist/schema/protection.d.ts +77 -0
  58. package/dist/schema/protection.js +170 -0
  59. package/dist/schema/protectionState.d.ts +20 -0
  60. package/dist/schema/protectionState.js +37 -0
  61. package/dist/styles/fontStack.d.ts +9 -0
  62. package/dist/styles/fontStack.js +9 -8
  63. package/dist/table/cellFormatting.js +3 -1
  64. package/dist/table/commands.js +1 -1
  65. package/dist/table/merge.js +2 -2
  66. package/dist/ui/CommentsPanel.d.ts +3 -3
  67. package/dist/ui/CommentsPanel.js +26 -18
  68. package/dist/ui/FontFamilySelect.js +21 -7
  69. package/dist/ui/LinkCard.d.ts +1 -2
  70. package/dist/ui/LinkCard.js +3 -4
  71. package/dist/ui/TextMenu.js +15 -10
  72. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ export type { CellFormat, CellVerticalAlign, DocumentDefaults, HighlightName, Li
22
22
  export type { DocxExportErrorCode, DocxImportErrorCode, } from "./ooxml/errors";
23
23
  export { DocxExportError, DocxImportError } from "./ooxml/errors";
24
24
  export { docxSchema } from "./schema";
25
+ /** Whose comments the panel offers to edit, which `DocxEditorMode` takes */
26
+ export type { EditableComments } from "./schema/protection";
25
27
  /**
26
28
  * The lists the built-in pickers offer. They are exported so that a toolbar of your own can
27
29
  * offer the same ones without writing them out again; the pickers themselves read these very
@@ -70,6 +70,18 @@ function advance(counters, list, ilvl, start) {
70
70
  if (deeper > ilvl) counters.delete(deeper);
71
71
  }
72
72
  }
73
+ var MARKER_SEGMENTER = new Intl.Segmenter();
74
+ function capped(shape) {
75
+ if (shape.length <= MAX_MARKER_CHARS) return shape;
76
+ let text = "";
77
+ for (const { segment } of MARKER_SEGMENTER.segment(
78
+ shape.slice(0, MAX_MARKER_CHARS * 2)
79
+ )) {
80
+ if (text.length + segment.length > MAX_MARKER_CHARS) break;
81
+ text += segment;
82
+ }
83
+ return text;
84
+ }
73
85
  function computeMarkers(paragraphs, numbering) {
74
86
  const countersByList = /* @__PURE__ */ new Map();
75
87
  return paragraphs.map((ref) => {
@@ -84,7 +96,7 @@ function computeMarkers(paragraphs, numbering) {
84
96
  }
85
97
  advance(counters, list, ref.ilvl, level.start);
86
98
  const shape = level.format === "bullet" ? level.text : fillLevels(level.text, list, counters);
87
- const text = shape.slice(0, MAX_MARKER_CHARS);
99
+ const text = capped(shape);
88
100
  return text ? { text, indent: levelIndentPt(level.indent) } : null;
89
101
  });
90
102
  }
@@ -556,6 +556,9 @@ var docxSchema = new Schema({
556
556
  id: { default: null },
557
557
  referenceXml: { default: null },
558
558
  author: { default: null },
559
+ // The identity behind the display name, read from and written to the people part. Null for
560
+ // a comment nobody's identity is recorded for, which every comment made in Word is here
561
+ authorId: { default: null },
559
562
  initials: { default: null },
560
563
  date: { default: null },
561
564
  text: { default: "" },
@@ -576,6 +579,7 @@ var docxSchema = new Schema({
576
579
  "data-comment-id": text(node.attrs.id),
577
580
  "data-reference-xml": text(node.attrs.referenceXml),
578
581
  "data-comment-author": text(node.attrs.author),
582
+ "data-comment-author-id": text(node.attrs.authorId),
579
583
  "data-comment-initials": text(node.attrs.initials),
580
584
  "data-comment-date": text(node.attrs.date),
581
585
  "data-comment-text": text(node.attrs.text),
@@ -596,6 +600,7 @@ var docxSchema = new Schema({
596
600
  id: dom.getAttribute("data-comment-id"),
597
601
  referenceXml: dom.getAttribute("data-reference-xml"),
598
602
  author: dom.getAttribute("data-comment-author"),
603
+ authorId: dom.getAttribute("data-comment-author-id"),
599
604
  initials: dom.getAttribute("data-comment-initials"),
600
605
  date: dom.getAttribute("data-comment-date"),
601
606
  text: dom.getAttribute("data-comment-text") ?? "",
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Evaluates OOXML content and deletion locks for inline controls and whole table cells. Commands
3
3
  * query these predicates before editing; `editor/plugins/lockedContent` enforces them at runtime.
4
+ *
5
+ * `transactionAllowed` is the one guard, and it answers for the protection the editor runs under
6
+ * (`./protection`) as well as for the locks, so that a command asking it asks both.
4
7
  */
5
8
  import type { Mark, Node as PMNode } from "prosemirror-model";
6
- import { PluginKey, type Selection, type Transaction } from "prosemirror-state";
9
+ import { type EditorState, PluginKey, type Selection, type Transaction } from "prosemirror-state";
10
+ import { type ProtectionState } from "./protection";
7
11
  /**
8
12
  * The pass that lets a transaction through the guard, which is how a lock can be lifted at all.
9
13
  * A plugin key is used as the name so that it cannot collide with a consumer's own metadata.
@@ -99,6 +103,27 @@ export declare function selectionShut(selection: Selection, doc: PMNode): boolea
99
103
  * it would have gone through.
100
104
  */
101
105
  export declare function replacementShut(selection: Selection, doc: PMNode): boolean;
106
+ /**
107
+ * Whether any step of the transaction reaches a comment node: puts one in, takes one out, or
108
+ * rewrites the one where it stands, which is how a body, a reply and a resolution change.
109
+ *
110
+ * Every comment lives in its three nodes, so a change that reaches none of them cannot have
111
+ * changed a comment. That is what lets the guard settle the common transaction - typing, and
112
+ * nothing more - over the stretch it rewrote rather than over the whole document.
113
+ *
114
+ * A step of a kind this does not know - one a consumer brought - is answered as reaching one,
115
+ * since what it rewrote is not known either. The whole-document judgement then has the say, and
116
+ * an unknown step costs a comparison rather than a hole in the guard.
117
+ */
118
+ export declare function transactionTouchesComments(tr: Transaction): boolean;
119
+ /**
120
+ * Whether the protection lets this transaction through (`./protection`).
121
+ *
122
+ * The whole-document judgement is reached for only when a comment is touched at all
123
+ * (`transactionTouchesComments`). A change that touches none is a body edit: through under `none`,
124
+ * refused under `comments`, and nothing about ownership to ask.
125
+ */
126
+ export declare function protectionAllowsTransaction(tr: Transaction, rules: ProtectionState): boolean;
102
127
  /**
103
128
  * Whether the guard would let this transaction through, decided and nothing else.
104
129
  *
@@ -106,7 +131,10 @@ export declare function replacementShut(selection: Selection, doc: PMNode): bool
106
131
  * (`editor/plugins/lockedContent`) - which a query about a button's state may not set off, so the
107
132
  * decision stands apart from it and every caller building an edit asks this rather than handing
108
133
  * the transaction to a state.
109
- * `doc` only stands in for a step the transaction kept no document for.
134
+ *
135
+ * The protection is judged before the passes and without them: a replayed edit is still an edit,
136
+ * and a pass that lifts a lock lifts no protection. Its judgement is over the whole change rather
137
+ * than step by step, which is what lets it tell a comment from everything else.
110
138
  */
111
- export declare function transactionAllowed(tr: Transaction, doc: PMNode): boolean;
139
+ export declare function transactionAllowed(tr: Transaction, state: EditorState): boolean;
112
140
  export {};
@@ -1,5 +1,7 @@
1
1
  // src/schema/locks.ts
2
- import { PluginKey } from "prosemirror-state";
2
+ import {
3
+ PluginKey
4
+ } from "prosemirror-state";
3
5
  import {
4
6
  AddMarkStep,
5
7
  AddNodeMarkStep,
@@ -10,6 +12,11 @@ import {
10
12
  ReplaceStep
11
13
  } from "prosemirror-transform";
12
14
  import { docxSchema } from "./index.js";
15
+ import {
16
+ isCommentNode,
17
+ protectionAllows
18
+ } from "./protection.js";
19
+ import { protectionOf } from "./protectionState.js";
13
20
  var unlockAllowed = new PluginKey("docxEditorUnlockAllowed");
14
21
  var historyReplay = new PluginKey("docxEditorHistoryReplay");
15
22
  var OPEN = { contents: false, deletion: false };
@@ -188,11 +195,53 @@ function stepAllowed(step, doc) {
188
195
  function carriesPass(tr) {
189
196
  return tr.getMeta(unlockAllowed) === true || tr.getMeta(historyReplay) === true;
190
197
  }
191
- function transactionAllowed(tr, doc) {
198
+ function rangeHoldsComment(doc, from, to) {
199
+ let found = false;
200
+ doc.nodesBetween(from, to, (node) => {
201
+ if (found) return false;
202
+ if (isCommentNode(node)) found = true;
203
+ return !found;
204
+ });
205
+ return found;
206
+ }
207
+ function transactionTouchesComments(tr) {
208
+ return tr.steps.some((step, index) => {
209
+ const before = tr.docs[index];
210
+ const after = tr.docs[index + 1] ?? tr.doc;
211
+ if (step instanceof AttrStep || step instanceof AddNodeMarkStep || step instanceof RemoveNodeMarkStep) {
212
+ const node = before.nodeAt(step.pos);
213
+ return node !== null && isCommentNode(node);
214
+ }
215
+ if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep || step instanceof AddMarkStep || step instanceof RemoveMarkStep)) {
216
+ return true;
217
+ }
218
+ let touched = false;
219
+ step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
220
+ touched ||= rangeHoldsComment(before, oldStart, oldEnd) || rangeHoldsComment(after, newStart, newEnd);
221
+ });
222
+ return touched;
223
+ });
224
+ }
225
+ function protectionAllowsTransaction(tr, rules) {
226
+ switch (rules.protection) {
227
+ case "readOnly":
228
+ return false;
229
+ case "none":
230
+ return !transactionTouchesComments(tr) || protectionAllows(tr.before, tr.doc, rules);
231
+ case "comments":
232
+ return transactionTouchesComments(tr) && protectionAllows(tr.before, tr.doc, rules);
233
+ default: {
234
+ const unmodelled = rules.protection;
235
+ return unmodelled;
236
+ }
237
+ }
238
+ }
239
+ function transactionAllowed(tr, state) {
192
240
  if (!tr.docChanged) return true;
241
+ if (!protectionAllowsTransaction(tr, protectionOf(state))) return false;
193
242
  if (carriesPass(tr)) return true;
194
243
  return tr.steps.every(
195
- (step, index) => stepAllowed(step, tr.docs[index] ?? doc)
244
+ (step, index) => stepAllowed(step, tr.docs[index] ?? state.doc)
196
245
  );
197
246
  }
198
247
  export {
@@ -203,9 +252,11 @@ export {
203
252
  insideLockedCell,
204
253
  isLockedCell,
205
254
  lockedMarkOf,
255
+ protectionAllowsTransaction,
206
256
  rangeTouchesLocked,
207
257
  replacementShut,
208
258
  selectionShut,
209
259
  transactionAllowed,
260
+ transactionTouchesComments,
210
261
  unlockAllowed
211
262
  };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The protection an editor runs under: what the document as a whole may receive, over and above
3
+ * the locks its own controls carry (`./locks`).
4
+ *
5
+ * The levels are named after OOXML `ST_DocProtect`, the enumeration `w:documentProtection/@w:edit`
6
+ * takes (ECMA-376 Part 1 §17.15.1.29): `readOnly` takes no edit at all, `comments` takes comments
7
+ * and nothing else, `none` takes every body edit. No level lifts the ownership rule over comments:
8
+ * whose comment may be edited is `editableComments`' question at every level, `none` included.
9
+ * `trackedChanges` and `forms`, the two values of the enumeration not modelled yet, would be added
10
+ * here.
11
+ *
12
+ * The protection is editor state rather than document state: it is the reader's standing, not the
13
+ * file's, so nothing here is written to or read from OOXML. `editor/plugins/documentProtection`
14
+ * holds it in the state and `./protectionState` reads it back; the guard (`transactionAllowed` in
15
+ * `./locks`) asks the judgements here. They take documents alone, so that the `./core` entry can
16
+ * make the same judgement over a file without an editor.
17
+ */
18
+ import { type Node as PMNode } from "prosemirror-model";
19
+ export type EditingProtection = "none" | "readOnly" | "comments";
20
+ /**
21
+ * Whose comments may be edited or deleted. `own` is a comment carrying no recognised identity, or
22
+ * the very identity comments are being written under; `all` is every comment there is, which is
23
+ * the standing of a moderator.
24
+ */
25
+ export type EditableComments = "own" | "all";
26
+ export interface ProtectionState {
27
+ protection: EditingProtection;
28
+ /** The identity comments are written under. Null where nobody is writing any, a reader above all */
29
+ authorId: string | null;
30
+ editableComments: EditableComments;
31
+ }
32
+ /** Whether this node is one of the three a comment stands in the story as */
33
+ export declare function isCommentNode(node: PMNode): boolean;
34
+ /** Whether the two documents differ in nothing but their comments */
35
+ export declare function changesOnlyComments(before: PMNode, after: PMNode): boolean;
36
+ /**
37
+ * Whether a comment written under this identity may be edited or deleted.
38
+ *
39
+ * A comment carrying no recognised identity belongs to nobody in particular and stays open to
40
+ * everyone, which is what every comment was before identities were recorded and what a comment
41
+ * made in Word is; one carrying an identity is its author's alone.
42
+ */
43
+ export declare function commentOwned(rules: ProtectionState, authorId: string | null): boolean;
44
+ /**
45
+ * Whether every comment and reply that was already there kept the identity it was written under.
46
+ *
47
+ * An identity is not an edit anyone may make, its own author included: a comment rewritten into
48
+ * another author's name would be that author's to edit from then on, and one rewritten into
49
+ * nobody's would be everyone's. Only a comment that appears carries an identity chosen for it.
50
+ */
51
+ export declare function commentIdentitiesKept(before: PMNode, after: PMNode): boolean;
52
+ /**
53
+ * Whether every comment edited, moved or taken away between the two documents belonged to whoever
54
+ * did it. Adding a comment, replying, and settling a thread as resolved or open belong to
55
+ * everyone.
56
+ *
57
+ * Moving a comment onto other text is an edit of that comment rather than of the body, since the
58
+ * body reads the same afterwards, and it is the owner's to make.
59
+ * A root taken away takes its replies with it whoever wrote them: a thread hangs off its root,
60
+ * and the root's owner may take the thread down.
61
+ */
62
+ export declare function commentEditsOwned(before: PMNode, after: PMNode, rules: ProtectionState): boolean;
63
+ /**
64
+ * Whether the protection lets a document go from `before` to `after`.
65
+ *
66
+ * Ownership is judged only over a change that is nothing but comments. A body edit that sweeps a
67
+ * comment away with the text it was anchored to is a body edit, and the standing to make one is
68
+ * the protection's question; under `comments` no such edit goes through in the first place.
69
+ */
70
+ export declare function protectionAllows(before: PMNode, after: PMNode, rules: ProtectionState): boolean;
71
+ /**
72
+ * Whether every comment and reply that appeared between the two documents was written under this
73
+ * identity, and every one that was already there kept the identity it was written under. The
74
+ * editor takes an addition from anyone, since it writes the author itself; a server taking a file
75
+ * back does not, since the file could claim any author.
76
+ */
77
+ export declare function commentAdditionsBy(before: PMNode, after: PMNode, authorId: string): boolean;
@@ -0,0 +1,170 @@
1
+ // src/schema/protection.ts
2
+ import { Fragment } from "prosemirror-model";
3
+ var COMMENT_NODES = /* @__PURE__ */ new Set([
4
+ "commentStart",
5
+ "commentEnd",
6
+ "commentReference"
7
+ ]);
8
+ function isCommentNode(node) {
9
+ return COMMENT_NODES.has(node.type.name);
10
+ }
11
+ function withoutComments(node) {
12
+ if (node.isLeaf) return node;
13
+ const kept = [];
14
+ node.forEach((child) => {
15
+ if (!isCommentNode(child)) kept.push(withoutComments(child));
16
+ });
17
+ return node.copy(Fragment.fromArray(kept));
18
+ }
19
+ function changesOnlyComments(before, after) {
20
+ return withoutComments(before).eq(withoutComments(after));
21
+ }
22
+ function stringOrNull(value) {
23
+ return typeof value === "string" ? value : null;
24
+ }
25
+ function bodyOf(attrs) {
26
+ return {
27
+ authorId: stringOrNull(attrs.authorId),
28
+ author: stringOrNull(attrs.author),
29
+ initials: stringOrNull(attrs.initials),
30
+ date: stringOrNull(attrs.date),
31
+ text: stringOrNull(attrs.text) ?? ""
32
+ };
33
+ }
34
+ function replyBodies(value) {
35
+ const replies = /* @__PURE__ */ new Map();
36
+ if (!Array.isArray(value)) return replies;
37
+ for (const entry of value) {
38
+ if (typeof entry !== "object" || entry === null) continue;
39
+ const reply = entry;
40
+ const id = stringOrNull(reply.id);
41
+ if (id === null) continue;
42
+ replies.set(id, bodyOf(reply));
43
+ }
44
+ return replies;
45
+ }
46
+ function threadsIn(doc) {
47
+ const threads = /* @__PURE__ */ new Map();
48
+ doc.descendants((node) => {
49
+ if (node.type.name !== "commentReference") return true;
50
+ const id = stringOrNull(node.attrs.id);
51
+ if (id === null || threads.has(id)) return true;
52
+ threads.set(id, {
53
+ ...bodyOf(node.attrs),
54
+ replies: replyBodies(node.attrs.replies)
55
+ });
56
+ return true;
57
+ });
58
+ return threads;
59
+ }
60
+ function commentOwned(rules, authorId) {
61
+ if (rules.editableComments === "all") return true;
62
+ return authorId === null || authorId === rules.authorId;
63
+ }
64
+ function commentAnchors(doc) {
65
+ const markers = /* @__PURE__ */ new Map();
66
+ const visit = (node, start) => {
67
+ if (isCommentNode(node)) {
68
+ const id = stringOrNull(node.attrs.id);
69
+ if (id !== null) {
70
+ const at = markers.get(id) ?? [];
71
+ at.push(`${node.type.name}@${start}`);
72
+ markers.set(id, at);
73
+ }
74
+ return 0;
75
+ }
76
+ if (node.isLeaf) return node.nodeSize;
77
+ let content = 0;
78
+ node.forEach((child) => {
79
+ content += visit(child, start + 1 + content);
80
+ });
81
+ return content + 2;
82
+ };
83
+ visit(doc, -1);
84
+ return new Map(
85
+ Array.from(markers, ([id, at]) => [id, at.join(" ")])
86
+ );
87
+ }
88
+ function sameIdentity(was, now) {
89
+ return was.authorId === now.authorId && was.author === now.author;
90
+ }
91
+ function sameWords(was, now) {
92
+ return was.text === now.text && was.initials === now.initials && was.date === now.date;
93
+ }
94
+ function commentIdentitiesKept(before, after) {
95
+ const now = threadsIn(after);
96
+ for (const [id, thread] of threadsIn(before)) {
97
+ const current = now.get(id);
98
+ if (current === void 0) continue;
99
+ if (!sameIdentity(thread, current)) return false;
100
+ for (const [replyId, reply] of thread.replies) {
101
+ const currentReply = current.replies.get(replyId);
102
+ if (currentReply !== void 0 && !sameIdentity(reply, currentReply)) {
103
+ return false;
104
+ }
105
+ }
106
+ }
107
+ return true;
108
+ }
109
+ function commentEditsOwned(before, after, rules) {
110
+ if (!commentIdentitiesKept(before, after)) return false;
111
+ if (rules.editableComments === "all") return true;
112
+ const now = threadsIn(after);
113
+ const anchoredBefore = commentAnchors(before);
114
+ const anchoredAfter = commentAnchors(after);
115
+ for (const [id, thread] of threadsIn(before)) {
116
+ const current = now.get(id);
117
+ const changed = current === void 0 || !sameWords(thread, current) || anchoredBefore.get(id) !== anchoredAfter.get(id);
118
+ if (changed && !commentOwned(rules, thread.authorId)) return false;
119
+ if (current === void 0) continue;
120
+ for (const [replyId, reply] of thread.replies) {
121
+ const currentReply = current.replies.get(replyId);
122
+ const replyChanged = currentReply === void 0 || !sameWords(reply, currentReply);
123
+ if (replyChanged && !commentOwned(rules, reply.authorId)) return false;
124
+ }
125
+ }
126
+ return true;
127
+ }
128
+ function protectionAllows(before, after, rules) {
129
+ switch (rules.protection) {
130
+ case "readOnly":
131
+ return false;
132
+ case "comments":
133
+ return changesOnlyComments(before, after) && commentEditsOwned(before, after, rules);
134
+ case "none":
135
+ return !changesOnlyComments(before, after) || commentEditsOwned(before, after, rules);
136
+ default: {
137
+ const unmodelled = rules.protection;
138
+ return unmodelled;
139
+ }
140
+ }
141
+ }
142
+ function commentAdditionsBy(before, after, authorId) {
143
+ if (!commentIdentitiesKept(before, after)) return false;
144
+ const was = threadsIn(before);
145
+ for (const [id, thread] of threadsIn(after)) {
146
+ const earlier = was.get(id);
147
+ if (earlier === void 0) {
148
+ if (thread.authorId !== authorId) return false;
149
+ for (const reply of thread.replies.values()) {
150
+ if (reply.authorId !== authorId) return false;
151
+ }
152
+ continue;
153
+ }
154
+ for (const [replyId, reply] of thread.replies) {
155
+ if (!earlier.replies.has(replyId) && reply.authorId !== authorId) {
156
+ return false;
157
+ }
158
+ }
159
+ }
160
+ return true;
161
+ }
162
+ export {
163
+ changesOnlyComments,
164
+ commentAdditionsBy,
165
+ commentEditsOwned,
166
+ commentIdentitiesKept,
167
+ commentOwned,
168
+ isCommentNode,
169
+ protectionAllows
170
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Where the protection (`./protection`) stands in an editor state, and the questions commands ask
3
+ * of it. Kept apart from the judgements so that those stay free of editor state for the `./core`
4
+ * entry.
5
+ */
6
+ import { type EditorState, PluginKey } from "prosemirror-state";
7
+ import type { EditingProtection, ProtectionState } from "./protection";
8
+ /** What a state built without the protection plugin runs under, which is what every consumer had before it existed */
9
+ export declare const UNPROTECTED: ProtectionState;
10
+ export declare const protectionKey: PluginKey<ProtectionState>;
11
+ export declare function protectionOf(state: EditorState): ProtectionState;
12
+ export declare function editingProtection(state: EditorState): EditingProtection;
13
+ /**
14
+ * Whether the protection shuts every edit to the body, comments aside.
15
+ *
16
+ * This is the question a command that changes text, formatting or structure asks before it reports
17
+ * that it applies, the way it asks the lock predicates (`./locks`): a command reporting true and
18
+ * then being refused by the guard draws a live control that swallows the click.
19
+ */
20
+ export declare function editsShut(state: EditorState): boolean;
@@ -0,0 +1,37 @@
1
+ // src/schema/protectionState.ts
2
+ import { PluginKey } from "prosemirror-state";
3
+ var UNPROTECTED = {
4
+ protection: "none",
5
+ authorId: null,
6
+ editableComments: "own"
7
+ };
8
+ var protectionKey = new PluginKey(
9
+ "docxEditorProtection"
10
+ );
11
+ function protectionOf(state) {
12
+ return protectionKey.getState(state) ?? UNPROTECTED;
13
+ }
14
+ function editingProtection(state) {
15
+ return protectionOf(state).protection;
16
+ }
17
+ function editsShut(state) {
18
+ const protection = editingProtection(state);
19
+ switch (protection) {
20
+ case "none":
21
+ return false;
22
+ case "readOnly":
23
+ case "comments":
24
+ return true;
25
+ default: {
26
+ const unmodelled = protection;
27
+ return unmodelled;
28
+ }
29
+ }
30
+ }
31
+ export {
32
+ UNPROTECTED,
33
+ editingProtection,
34
+ editsShut,
35
+ protectionKey,
36
+ protectionOf
37
+ };
@@ -36,6 +36,15 @@ export interface FontFallbacks {
36
36
  * own set (`fontFallbacks` on `DocxEditor`).
37
37
  */
38
38
  export declare const DEFAULT_FONT_FALLBACKS: FontFallbacks;
39
+ /**
40
+ * The form a name is compared in. Quotes, letter case, and Unicode normalization all vary
41
+ * from document to document, so two spellings of one name have to compare equal.
42
+ *
43
+ * NFC only. An NFK* form is lossy and would fold the fullwidth Latin of a name such as
44
+ * `DFKaiShu` into ASCII. The result is a comparison key and is never
45
+ * written into a document or shown: the original spelling is what gets stored and exported.
46
+ */
47
+ export declare function comparableFontName(name: string): string;
39
48
  /**
40
49
  * Whether this font name belongs to a script written in Han characters, kana or
41
50
  * Hangul.
@@ -189,15 +189,15 @@ var DEFAULT_FONT_FALLBACKS = {
189
189
  defaultStack: "Arial, Helvetica, sans-serif",
190
190
  defaultFontName: "Arial"
191
191
  };
192
- function comparableName(name) {
193
- return name.trim().replace(/^["']|["']$/g, "").toLowerCase();
192
+ function comparableFontName(name) {
193
+ return name.trim().replace(/^["']|["']$/g, "").toLowerCase().normalize("NFC");
194
194
  }
195
195
  var EAST_ASIAN_CHARACTER = /[\u1100-\u11ff\u3000-\u30ff\u3130-\u318f\u31f0-\u31ff\u3400-\u4dbf\u4e00-\u9fff\ua960-\ua97f\uac00-\ud7ff\uf900-\ufaff\uff00-\uffef\u{20000}-\u{2fa1f}]/u;
196
196
  var eastAsianNames = new Set(
197
- EAST_ASIAN_GROUPS.flatMap((group) => group.names.map(comparableName))
197
+ EAST_ASIAN_GROUPS.flatMap((group) => group.names.map(comparableFontName))
198
198
  );
199
199
  function isEastAsianFontName(name) {
200
- return EAST_ASIAN_CHARACTER.test(name) || eastAsianNames.has(comparableName(name));
200
+ return EAST_ASIAN_CHARACTER.test(name) || eastAsianNames.has(comparableFontName(name));
201
201
  }
202
202
  var stacksByFallbacks = /* @__PURE__ */ new WeakMap();
203
203
  function stackByName(fallbacks) {
@@ -206,7 +206,7 @@ function stackByName(fallbacks) {
206
206
  const stacks = /* @__PURE__ */ new Map();
207
207
  for (const group of fallbacks.groups) {
208
208
  for (const name of group.names)
209
- stacks.set(comparableName(name), group.stack);
209
+ stacks.set(comparableFontName(name), group.stack);
210
210
  }
211
211
  stacksByFallbacks.set(fallbacks, stacks);
212
212
  return stacks;
@@ -215,7 +215,7 @@ function matchedStacks(cssNames, fallbacks) {
215
215
  const stacks = stackByName(fallbacks);
216
216
  const matched = [];
217
217
  for (const name of cssNames.split(",")) {
218
- const stack = stacks.get(comparableName(name));
218
+ const stack = stacks.get(comparableFontName(name));
219
219
  if (stack !== void 0 && !matched.includes(stack)) matched.push(stack);
220
220
  }
221
221
  return matched;
@@ -235,12 +235,12 @@ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
235
235
  "emoji"
236
236
  ]);
237
237
  function isGenericFamily(name) {
238
- return GENERIC_FAMILIES.has(comparableName(name));
238
+ return GENERIC_FAMILIES.has(comparableFontName(name));
239
239
  }
240
240
  function distinctNames(names) {
241
241
  const seen = /* @__PURE__ */ new Set();
242
242
  return names.filter((name) => {
243
- const comparable = comparableName(name);
243
+ const comparable = comparableFontName(name);
244
244
  if (seen.has(comparable)) return false;
245
245
  seen.add(comparable);
246
246
  return true;
@@ -257,6 +257,7 @@ function withFontFallback(cssNames, fallbacks = DEFAULT_FONT_FALLBACKS) {
257
257
  }
258
258
  export {
259
259
  DEFAULT_FONT_FALLBACKS,
260
+ comparableFontName,
260
261
  isEastAsianFontName,
261
262
  withFontFallback
262
263
  };
@@ -9,6 +9,7 @@ import {
9
9
  toCellFormat
10
10
  } from "../model/format.js";
11
11
  import { isLockedCell, transactionAllowed } from "../schema/locks.js";
12
+ import { editsShut } from "../schema/protectionState.js";
12
13
  import { cellDefaultsAt, tableCellSources } from "./gridBorders.js";
13
14
  function text(value) {
14
15
  return typeof value === "string" ? value : null;
@@ -59,7 +60,7 @@ function cellFormatCommand(edit) {
59
60
  const changes = planChanges(rect, edit);
60
61
  if (changes.length === 0) return false;
61
62
  const transaction = applyChanges(state.tr, rect.tableStart, changes);
62
- if (!transactionAllowed(transaction, state.doc)) return false;
63
+ if (!transactionAllowed(transaction, state)) return false;
63
64
  dispatch?.(transaction);
64
65
  return true;
65
66
  };
@@ -118,6 +119,7 @@ function cellFormats(state) {
118
119
  return selectedCells(state).map((cell) => toCellFormat(cell.attrs.format));
119
120
  }
120
121
  function canSetCellFormatting(state) {
122
+ if (editsShut(state)) return false;
121
123
  const cells = selectedCells(state);
122
124
  return cells.length > 0 && cells.every((cell) => !isLockedCell(cell));
123
125
  }
@@ -21,7 +21,7 @@ function isInTable(state) {
21
21
  function toCommand(build) {
22
22
  return (state, dispatch) => {
23
23
  const tr = build(state);
24
- if (!tr || !transactionAllowed(tr, state.doc)) return false;
24
+ if (!tr || !transactionAllowed(tr, state)) return false;
25
25
  dispatch?.(tr);
26
26
  return true;
27
27
  };
@@ -26,11 +26,11 @@ function cellAt(rect, row, col) {
26
26
  }
27
27
  function canMergeCells(state) {
28
28
  const tr = buildMergeCellsTransaction(state);
29
- return tr !== null && transactionAllowed(tr, state.doc);
29
+ return tr !== null && transactionAllowed(tr, state);
30
30
  }
31
31
  function canSplitCell(state) {
32
32
  const tr = buildSplitCellTransaction(state);
33
- return tr !== null && transactionAllowed(tr, state.doc);
33
+ return tr !== null && transactionAllowed(tr, state);
34
34
  }
35
35
  function coveredWidthSum(rect, type) {
36
36
  let sum = 0;
@@ -5,11 +5,11 @@ import { type CommentAuthor } from "../editor/commands/commentCommands";
5
5
  export interface CommentsPanelProps {
6
6
  view: EditorView;
7
7
  state: EditorState;
8
- readOnly: boolean;
9
8
  composerOpen: boolean;
10
- author: CommentAuthor;
9
+ /** Whose comments the composers write. Null for a reader, who is offered none */
10
+ author: CommentAuthor | null;
11
11
  closeComposer: () => void;
12
12
  scrollContainer: HTMLElement | null;
13
13
  allCommentsOpen: boolean;
14
14
  }
15
- export declare function CommentsPanel({ view, state, readOnly, composerOpen, author, closeComposer, scrollContainer, allCommentsOpen, }: CommentsPanelProps): ReactElement;
15
+ export declare function CommentsPanel({ view, state, composerOpen, author, closeComposer, scrollContainer, allCommentsOpen, }: CommentsPanelProps): ReactElement;