@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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @portone/docx-editor
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#45](https://github.com/portone-io/docx-editor/pull/45) [`3190a35`](https://github.com/portone-io/docx-editor/commit/3190a359e0686bdc14166e22b1d96c761dabe251) Thanks [@Deea222](https://github.com/Deea222)! - Add a `comment` mode and record who wrote each comment.
8
+
9
+ **Breaking:** `mode` is now required and the `commentAuthor` prop is removed.
10
+ `author: { id, name, initials? }` moves into `mode` for its `comment` and `edit` kinds.
11
+ `contextMenus` moves off `mode` onto `DocxEditor` itself, so `mode: { kind: "edit", contextMenus: false }` becomes `contextMenus={false}`.
12
+
13
+ In `comment` mode the text can be selected and copied but not changed, while comments can be written, answered, resolved, and reopened.
14
+ A comment records its author's identity in the document, so two authors sharing a display name stay distinct.
15
+ Only a comment's author may edit or delete it, and anyone may reply, resolve, and reopen.
16
+ `mode.editableComments: "all"` opens every comment to a moderator.
17
+ The `canEditComment` and `editingProtection` queries report what the current mode allows.
18
+ A server can check a file coming back with `onlyCommentsChangedBy` from `@portone/docx-editor/core`, which answers `{ ok: true }` or `{ ok: false, reason }` for whether the file differs in nothing but one author's comments.
19
+
20
+ ## 0.1.1
21
+
22
+ ### Patch Changes
23
+
24
+ - [#36](https://github.com/portone-io/docx-editor/pull/36) [`ab518b9`](https://github.com/portone-io/docx-editor/commit/ab518b9c551afc1d647ad612b265ec5895601240) Thanks [@Deea222](https://github.com/Deea222)! - Read two Unicode spellings of one font name as the same font.
25
+
26
+ A name a document writes down can be composed or decomposed (NFC or NFD) while the same name elsewhere in the document, or among the toolbar's presets, is written the other way.
27
+ Those spellings used to compare unequal, so the font dropdown listed the same font twice and a selection that all carried one font could report as mixed and blank the dropdown.
28
+ Names are now compared in composed form, and the spelling the document wrote is what stays stored, exported, and shown.
29
+
30
+ A list marker cut to its length cap is also cut between characters now, instead of possibly splitting an emoji or a combining sequence in half and drawing a replacement glyph.
31
+
3
32
  ## 0.1.0
4
33
 
5
34
  The first release.
package/README.md CHANGED
@@ -18,21 +18,33 @@ import "@portone/docx-editor/styles.css";
18
18
  import { DocxEditor, type DocxEditorHandle } from "@portone/docx-editor";
19
19
  import { useRef } from "react";
20
20
 
21
- export function Editor({ file }: { file: File }) {
21
+ export function Editor({
22
+ file,
23
+ user,
24
+ }: {
25
+ file: File;
26
+ user: { id: string; name: string };
27
+ }) {
22
28
  const editorRef = useRef<DocxEditorHandle | null>(null);
23
29
 
24
- return <DocxEditor ref={editorRef} document={file} />;
30
+ return (
31
+ <DocxEditor
32
+ ref={editorRef}
33
+ document={file}
34
+ mode={{ kind: "edit", author: { id: user.id, name: user.name } }}
35
+ />
36
+ );
25
37
  }
26
38
  ```
27
39
 
28
- `document` accepts a `File`, `Blob`, `ArrayBuffer`, or `Uint8Array`; use the ref to export the edited document as bytes.
40
+ `document` accepts a `File`, `Blob`, `ArrayBuffer`, or `Uint8Array`; `mode` selects read-only, comment-only, or full editing and names the author new comments are attributed to; use the ref to export the edited document as bytes.
29
41
 
30
42
  ## What it does
31
43
 
32
- - Provides browser editing with built-in controls, read-only mode, and document locking.
44
+ - Provides browser editing with built-in controls, read-only and comment-only modes, and document locking.
33
45
  - Fits the page to the available width by default and lets readers choose a fixed zoom level.
34
46
  - Exports edited documents as DOCX bytes or browser downloads.
35
- - Preserves document structures and package parts across edits.
47
+ - Preserves document structures and package parts across edits, and records who wrote each comment.
36
48
  - Reads document styles, theme fonts, page layout, and CJK font information for display.
37
49
 
38
50
  See [Feature support](https://docx-editor.portone.io/docs/features) for the support matrix. Build [custom controls](https://docx-editor.portone.io/docs/custom-controls) or use [programmatic DOCX import and export](https://docx-editor.portone.io/docs/core).
@@ -13,6 +13,7 @@ import { type CSSProperties, type ReactNode } from "react";
13
13
  import { type DocxSource } from "./docx/importDocx";
14
14
  import { type CommentAuthor } from "./editor/commands/commentCommands";
15
15
  import { DocxImportError } from "./ooxml/errors";
16
+ import type { EditableComments } from "./schema/protection";
16
17
  import type { FontFallbacks } from "./styles/fontStack";
17
18
  import type { DocxEditorPresets } from "./ui/presets";
18
19
  import { type DocxEditorZoom } from "./ui/zoom";
@@ -24,22 +25,37 @@ export interface DocxEditorHandle {
24
25
  /**
25
26
  * What the editor is for, which decides what it offers.
26
27
  *
27
- * A read-only editor takes no edits, so it has no toolbar and no right click menus either: what
28
- * used to be three booleans of which one silently emptied the other two is one choice here.
28
+ * The three kinds are the standings OOXML document protection names (`ST_DocProtect`) and the
29
+ * ones a shared document gives its readers: a reader, a commenter, an editor. A read-only editor
30
+ * takes no edits, so it has no toolbar and no right click menus either: what used to be three
31
+ * booleans of which one silently emptied the other two is one choice here. A `comment` editor is
32
+ * the `comments` protection - the body may not be changed, comments may be written, answered and
33
+ * settled - which is what a reviewer who must not touch the text is handed. Both kinds that write
34
+ * comments name whose they are, so a reader is never asked for an identity it has no use for.
35
+ *
36
+ * `editableComments` is whose comments the panel offers to edit or delete: one's own, which is a
37
+ * comment carrying no recognised identity or the very identity given here, or every one, which
38
+ * is a moderator's standing. Replying and resolving are open to every commenter either way.
39
+ *
29
40
  * `locking` is what a screen where a template is written gets: settling a part of a document is
30
41
  * an authoring act, not something every reader of a form should be handed. A lock the document
31
42
  * already carries holds whichever mode is chosen.
32
43
  *
33
- * `contextMenus: false` leaves the right click to the browser, for a consumer drawing menus of
34
- * its own. The plugins that take the browser's menu away go into the editor state, which is built
35
- * when the editor mounts, so this is read once there like `plugins`.
44
+ * The kind, the author and `editableComments` are read on every render and take effect on the
45
+ * open document. Whether the right click opens the editor's own menus is `contextMenus`, a prop
46
+ * of its own, since the plugins behind it are read when the editor mounts rather than per render.
36
47
  */
37
48
  export type DocxEditorMode = {
38
49
  kind: "readOnly";
50
+ } | {
51
+ kind: "comment";
52
+ author: CommentAuthor;
53
+ editableComments?: EditableComments;
39
54
  } | {
40
55
  kind: "edit";
56
+ author: CommentAuthor;
57
+ editableComments?: EditableComments;
41
58
  toolbar?: boolean;
42
- contextMenus?: boolean;
43
59
  locking?: boolean;
44
60
  };
45
61
  export interface DocxEditorProps {
@@ -58,8 +74,8 @@ export interface DocxEditorProps {
58
74
  * never silent either way. Hand one in to write the refusal in your own words.
59
75
  */
60
76
  renderImportError?: (error: DocxImportError) => ReactNode;
61
- /** What the editor is for. Editing with the toolbar shown and no locking when none is given */
62
- mode?: DocxEditorMode;
77
+ /** What the editor is for: a reader's, a commenter's or an editor's surface */
78
+ mode: DocxEditorMode;
63
79
  /** Whether to draw approximate page boundaries over the document's own paper. Drawn when read only too */
64
80
  showPageGuides?: boolean;
65
81
  /**
@@ -96,8 +112,6 @@ export interface DocxEditorProps {
96
112
  * takes effect without a remount.
97
113
  */
98
114
  presets?: DocxEditorPresets;
99
- /** The identity written by the built-in comment and reply composers */
100
- commentAuthor?: CommentAuthor;
101
115
  /**
102
116
  * ProseMirror plugins handed in by the consumer, which is how mentions, highlights,
103
117
  * autocomplete and the like are added from outside the package.
@@ -110,6 +124,16 @@ export interface DocxEditorProps {
110
124
  * `key` and remount.
111
125
  */
112
126
  plugins?: readonly Plugin[];
127
+ /**
128
+ * Whether the right click opens the editor's own menus. `false` leaves it to the browser,
129
+ * which is what a consumer drawing menus of its own wants. Defaults to `true`.
130
+ *
131
+ * Read once, when the editor mounts, like `plugins`: the plugins that take the browser's menu
132
+ * away go into the editor state, which is built there. A menu the protection has nothing to
133
+ * offer from stands down by itself, so a mode change needs nothing here. Later changes are
134
+ * ignored; to turn the menus around, change the `key` and remount.
135
+ */
136
+ contextMenus?: boolean;
113
137
  className?: string;
114
138
  style?: CSSProperties;
115
139
  onReady?: (view: EditorView) => void;
@@ -15,6 +15,7 @@ import {
15
15
  } from "./editor/commands/commentCommands.js";
16
16
  import { activeLinkSpan } from "./editor/commands/linkCommands.js";
17
17
  import { createEditorState, createEditorView } from "./editor/createEditor.js";
18
+ import { setProtection } from "./editor/plugins/documentProtection.js";
18
19
  import { isLinkPanelOpen } from "./editor/plugins/linkPanel.js";
19
20
  import { tableMenuAnchor } from "./editor/plugins/tableContextMenu.js";
20
21
  import { textMenuAnchor } from "./editor/plugins/textContextMenu.js";
@@ -23,6 +24,7 @@ import { DocxImportError } from "./ooxml/errors.js";
23
24
  import { PageGuides } from "./page/PageGuides.js";
24
25
  import { A4_PAGE_PIXELS, pagePixels } from "./page/pageLayout.js";
25
26
  import { usePageLayout } from "./page/usePageLayout.js";
27
+ import { editingProtection, protectionOf } from "./schema/protectionState.js";
26
28
  import { editorClassNames } from "./styles/classNames.js";
27
29
  import { CommentsPanel } from "./ui/CommentsPanel.js";
28
30
  import { LinkCard } from "./ui/LinkCard.js";
@@ -34,6 +36,38 @@ import { Toolbar } from "./ui/Toolbar.js";
34
36
  import { useFitWidthZoom } from "./ui/useFitWidthZoom.js";
35
37
  import { normalizeZoom } from "./ui/zoom.js";
36
38
  import { jsx, jsxs } from "react/jsx-runtime";
39
+ function affordancesOf(mode) {
40
+ switch (mode.kind) {
41
+ case "readOnly":
42
+ return {
43
+ protection: "readOnly",
44
+ author: null,
45
+ editableComments: "own",
46
+ toolbar: false,
47
+ locking: false
48
+ };
49
+ case "comment":
50
+ return {
51
+ protection: "comments",
52
+ author: mode.author ?? null,
53
+ editableComments: mode.editableComments ?? "own",
54
+ toolbar: false,
55
+ locking: false
56
+ };
57
+ case "edit":
58
+ return {
59
+ protection: "none",
60
+ author: mode.author ?? null,
61
+ editableComments: mode.editableComments ?? "own",
62
+ toolbar: mode.toolbar ?? true,
63
+ locking: mode.locking ?? false
64
+ };
65
+ default: {
66
+ const unmodelled = mode;
67
+ return unmodelled;
68
+ }
69
+ }
70
+ }
37
71
  function openDocument(bytes) {
38
72
  try {
39
73
  const { doc, session } = importDocx(bytes);
@@ -97,32 +131,28 @@ function zoomVariable(factor) {
97
131
  function DocxEditorSurface({
98
132
  document: source,
99
133
  renderImportError,
100
- mode = { kind: "edit" },
134
+ mode,
101
135
  showPageGuides = true,
102
136
  zoom,
103
137
  defaultZoom = "fit-width",
104
138
  onZoomChange,
105
139
  fontFallbacks,
106
140
  presets,
107
- commentAuthor = { name: "Anonymous" },
108
141
  plugins,
142
+ contextMenus,
109
143
  className,
110
144
  style,
111
145
  onReady,
112
146
  onChange
113
147
  }, ref) {
114
- const readOnly = mode.kind === "readOnly";
115
- const showToolbar = mode.kind === "edit" && (mode.toolbar ?? true);
116
- const allowLocking = mode.kind === "edit" && (mode.locking ?? false);
148
+ const { protection, author, editableComments, toolbar, locking } = affordancesOf(mode);
117
149
  const bytes = useDocumentBytes(source);
118
150
  const opened = useMemo(
119
151
  () => bytes === null ? null : openDocument(bytes),
120
152
  [bytes]
121
153
  );
122
154
  const mountedPlugins = useRef(plugins).current;
123
- const mountedContextMenus = useRef(
124
- mode.kind !== "edit" || (mode.contextMenus ?? true)
125
- ).current;
155
+ const mountedContextMenus = useRef(contextMenus ?? true).current;
126
156
  const mountedFontFallbacks = useRef(fontFallbacks).current;
127
157
  const layerRef = useRef(null);
128
158
  const rootRef = useRef(null);
@@ -173,11 +203,13 @@ function DocxEditorSurface({
173
203
  ...opened.session.comments.extendedOrdered.map(
174
204
  (extension) => extension.paraId
175
205
  )
176
- ]
206
+ ],
207
+ protection,
208
+ author,
209
+ editableComments
177
210
  }),
178
211
  defaults: opened.session.defaults,
179
212
  geometry: opened.session.geometry,
180
- readOnly,
181
213
  fontFallbacks: mountedFontFallbacks,
182
214
  onStateChange: (state) => {
183
215
  keptState.current = { of: opened, state };
@@ -196,19 +228,32 @@ function DocxEditorSurface({
196
228
  };
197
229
  }, [
198
230
  opened,
199
- readOnly,
200
231
  mountedContextMenus,
201
232
  mountedFontFallbacks,
202
233
  mountedPlugins,
203
234
  latestOnReady,
204
235
  latestOnChange
205
236
  ]);
237
+ const authorId = author?.id ?? null;
238
+ const mounted = live !== null;
239
+ useLayoutEffect(() => {
240
+ if (protection === "readOnly") setCommentComposerOpen(false);
241
+ const view = viewRef.current;
242
+ if (!view || !mounted) return;
243
+ const held = protectionOf(view.state);
244
+ if (held.protection === protection && held.authorId === authorId && held.editableComments === editableComments) {
245
+ return;
246
+ }
247
+ view.dispatch(
248
+ setProtection(view.state.tr, { protection, author, editableComments })
249
+ );
250
+ }, [mounted, protection, authorId, editableComments]);
206
251
  useImperativeHandle(ref, () => {
207
252
  const view = viewRef.current;
208
253
  if (!view || opened?.status !== "opened") return null;
209
254
  const session = opened.session;
210
255
  return { view, exportBytes: () => exportDocx(view.state.doc, session) };
211
- }, [opened, readOnly]);
256
+ }, [opened]);
212
257
  const overlay = usePageLayout({
213
258
  view: live?.view ?? null,
214
259
  layer: layerRef,
@@ -219,21 +264,22 @@ function DocxEditorSurface({
219
264
  if (opened?.status === "rejected") {
220
265
  return renderImportError ? renderImportError(opened.error) : /* @__PURE__ */ jsx(ImportRejection, { error: opened.error });
221
266
  }
222
- const textAnchor = live && !readOnly ? textMenuAnchor(live.state) : null;
223
- const tableAnchor = live && !readOnly && !textAnchor ? tableMenuAnchor(live.state) : null;
267
+ const bodyOpen = live !== null && editingProtection(live.state) === "none";
268
+ const commentsOpenToWrite = live !== null && editingProtection(live.state) !== "readOnly";
269
+ const textAnchor = live ? textMenuAnchor(live.state) : null;
270
+ const tableAnchor = live && !textAnchor ? tableMenuAnchor(live.state) : null;
224
271
  const linkAtCursor = live && !live.view.composing && !isLinkPanelOpen(live.state) ? activeLinkSpan(live.state) : null;
225
272
  const comments = live?.state === void 0 ? [] : documentComments(live.state);
226
273
  const hasUnresolvedComments = comments.some((comment) => !comment.resolved);
227
- const effectiveComposerOpen = commentComposerOpen && !readOnly;
274
+ const effectiveComposerOpen = commentComposerOpen && commentsOpenToWrite;
228
275
  const showComments = live !== null && (commentsOpen || effectiveComposerOpen || hasUnresolvedComments);
229
276
  const commentsPanel = live && showComments && /* @__PURE__ */ jsx(
230
277
  CommentsPanel,
231
278
  {
232
279
  view: live.view,
233
280
  state: live.state,
234
- readOnly,
235
281
  composerOpen: effectiveComposerOpen,
236
- author: commentAuthor,
282
+ author,
237
283
  closeComposer: () => setCommentComposerOpen(false),
238
284
  scrollContainer: rootRef.current,
239
285
  allCommentsOpen: commentsOpen
@@ -245,7 +291,7 @@ function DocxEditorSurface({
245
291
  className: [editorClassNames.frame, className].filter(Boolean).join(" "),
246
292
  style,
247
293
  children: [
248
- live && showToolbar && /* @__PURE__ */ jsx(
294
+ live && toolbar && /* @__PURE__ */ jsx(
249
295
  Toolbar,
250
296
  {
251
297
  view: live.view,
@@ -270,7 +316,7 @@ function DocxEditorSurface({
270
316
  "data-comments": showComments ? "visible" : void 0,
271
317
  style: zoomVariable(effectiveZoom),
272
318
  children: [
273
- live && readOnly && comments.length > 0 && /* @__PURE__ */ jsx(
319
+ live && !bodyOpen && comments.length > 0 && /* @__PURE__ */ jsx(
274
320
  "button",
275
321
  {
276
322
  type: "button",
@@ -306,14 +352,13 @@ function DocxEditorSurface({
306
352
  ]
307
353
  }
308
354
  ),
309
- live && !readOnly && isLinkPanelOpen(live.state) && /* @__PURE__ */ jsx(LinkPanel, { view: live.view, state: live.state }),
355
+ live && bodyOpen && isLinkPanelOpen(live.state) && /* @__PURE__ */ jsx(LinkPanel, { view: live.view, state: live.state }),
310
356
  live && linkAtCursor && /* @__PURE__ */ jsx(
311
357
  LinkCard,
312
358
  {
313
359
  view: live.view,
314
360
  state: live.state,
315
- link: linkAtCursor,
316
- readOnly
361
+ link: linkAtCursor
317
362
  },
318
363
  linkAtCursor.from
319
364
  ),
@@ -323,7 +368,7 @@ function DocxEditorSurface({
323
368
  view: live.view,
324
369
  state: live.state,
325
370
  anchor: textAnchor,
326
- allowLocking,
371
+ allowLocking: locking,
327
372
  onAddComment: () => {
328
373
  setCommentComposerOpen(true);
329
374
  }
@@ -335,7 +380,7 @@ function DocxEditorSurface({
335
380
  view: live.view,
336
381
  state: live.state,
337
382
  anchor: tableAnchor,
338
- allowLocking
383
+ allowLocking: locking
339
384
  }
340
385
  )
341
386
  ]
package/dist/core.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import type { Node as PMNode } from "prosemirror-model";
6
6
  import { type DocxBytes } from "./docx/importDocx";
7
7
  import type { DocxSession } from "./docx/session";
8
+ export type { CommentOnlyVerdict } from "./docx/commentOnlyChange";
9
+ export { onlyCommentsChangedBy } from "./docx/commentOnlyChange";
8
10
  export { exportDocx } from "./docx/exportDocx";
9
11
  export type { ParagraphStyleOption } from "./docx/formatting";
10
12
  export type { DocxBytes } from "./docx/importDocx";
package/dist/core.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/core.ts
2
2
  import { importDocx as openDocx } from "./docx/importDocx.js";
3
+ import { onlyCommentsChangedBy } from "./docx/commentOnlyChange.js";
3
4
  import { exportDocx } from "./docx/exportDocx.js";
4
5
  import { documentNumbering, documentPartPath } from "./docx/session.js";
5
6
  import {
@@ -26,6 +27,7 @@ export {
26
27
  emuToPx,
27
28
  exportDocx,
28
29
  importDocx,
30
+ onlyCommentsChangedBy,
29
31
  parseNumbering,
30
32
  pxToEmu,
31
33
  toCellFormat,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Whether a file handed back changed in nothing but comments one author is allowed to have made.
3
+ *
4
+ * The editor's own refusal under a comment protection (`schema/protection`) is a courtesy to the
5
+ * user: the browser holds the file, so the rule is held again here, where a server takes it in.
6
+ * The judgement is over the package rather than over the document alone, since a submission is
7
+ * free to rewrite anything the story does not carry - the paper the document is written on, its
8
+ * styles, its headers - and a document comparison would see none of it.
9
+ */
10
+ import { type EditableComments } from "../schema/protection";
11
+ import { type DocxBytes } from "./importDocx";
12
+ /**
13
+ * Why a file is not the one it claims to be. `part-changed` and `relationship-changed` name the
14
+ * part they were reached over; the other three are about the document story itself.
15
+ */
16
+ export type CommentOnlyVerdict = {
17
+ ok: true;
18
+ } | {
19
+ ok: false;
20
+ reason: "body-changed" | "comment-not-owned" | "comment-author-forged";
21
+ } | {
22
+ ok: false;
23
+ reason: "part-changed" | "relationship-changed";
24
+ part: string;
25
+ };
26
+ /**
27
+ * Whether the submitted file differs from the original in nothing but comments, every one of them
28
+ * added, edited, moved, deleted, replied to or settled by the author with this identity.
29
+ *
30
+ * Every part of the package has to arrive as it left, save for the three a comment is written
31
+ * across and the relationship and content type they are declared with; the document story itself
32
+ * has to read as it did, comments aside. A comment carrying no recorded identity is everyone's to
33
+ * edit here as it is in the editor (`schema/protection`), while a comment that appeared has to
34
+ * carry this identity: a file can claim any author, and the editor's own hand in writing it is
35
+ * not there to vouch for it. An identity already recorded is nobody's to rewrite.
36
+ *
37
+ * `editableComments: "all"` judges the file of an editor opened for a moderator, where every
38
+ * comment was theirs to edit; an identity is nobody's to rewrite under either setting.
39
+ *
40
+ * Bytes that are not a readable docx are turned down the way opening one is, with a
41
+ * `DocxImportError`, rather than being answered as a file that changed.
42
+ */
43
+ export declare function onlyCommentsChangedBy(original: DocxBytes, submitted: DocxBytes, authorId: string, { editableComments }?: {
44
+ editableComments?: EditableComments;
45
+ }): CommentOnlyVerdict;
@@ -0,0 +1,145 @@
1
+ // src/docx/commentOnlyChange.ts
2
+ import { decodeUtf8, elementChildren, parseXml } from "../ooxml/xml.js";
3
+ import {
4
+ changesOnlyComments,
5
+ commentAdditionsBy,
6
+ commentEditsOwned,
7
+ commentIdentitiesKept
8
+ } from "../schema/protection.js";
9
+ import {
10
+ COMMENTS_CONTENT_TYPE,
11
+ COMMENTS_EXTENDED_CONTENT_TYPE,
12
+ COMMENTS_EXTENDED_REL_TYPE,
13
+ COMMENTS_REL_TYPE,
14
+ CONTENT_TYPES_PATH,
15
+ PEOPLE_CONTENT_TYPE,
16
+ PEOPLE_REL_TYPE
17
+ } from "./comments/constants.js";
18
+ import { importDocx } from "./importDocx.js";
19
+ import {
20
+ readRelationships,
21
+ relsPathOf,
22
+ resolveTarget
23
+ } from "./relationships.js";
24
+ var COMMENT_REL_TYPES = [
25
+ COMMENTS_REL_TYPE,
26
+ COMMENTS_EXTENDED_REL_TYPE,
27
+ PEOPLE_REL_TYPE
28
+ ];
29
+ var COMMENT_CONTENT_TYPES = [
30
+ COMMENTS_CONTENT_TYPE,
31
+ COMMENTS_EXTENDED_CONTENT_TYPE,
32
+ PEOPLE_CONTENT_TYPE
33
+ ];
34
+ var refused = (reason) => ({ ok: false, reason });
35
+ function sameBytes(before, after) {
36
+ return before.length === after.length && before.every((byte, index) => byte === after[index]);
37
+ }
38
+ function commentPartPaths(session) {
39
+ const related = readRelationships(
40
+ session.parts,
41
+ relsPathOf(session.mainPartPath)
42
+ ).filter(
43
+ (entry) => !entry.external && COMMENT_REL_TYPES.includes(entry.type)
44
+ );
45
+ return new Set(
46
+ related.map((entry) => resolveTarget(session.mainPartPath, entry.target))
47
+ );
48
+ }
49
+ function aroundTheStory(session) {
50
+ const preserved = session.blocks.filter(
51
+ (block) => block.node.type.name !== "paragraph" && block.node.type.name !== "table"
52
+ ).map((block) => block.xml).join("");
53
+ return session.documentPrefix + preserved + session.documentSuffix;
54
+ }
55
+ function relationshipsKept(before, after) {
56
+ const now = new Map(after.map((entry) => [entry.id, entry]));
57
+ const kept = before.every((entry) => {
58
+ const current = now.get(entry.id);
59
+ return current !== void 0 && current.type === entry.type && current.target === entry.target && current.external === entry.external;
60
+ });
61
+ const ids = new Set(before.map((entry) => entry.id));
62
+ return kept && after.every(
63
+ (entry) => ids.has(entry.id) || COMMENT_REL_TYPES.includes(entry.type)
64
+ );
65
+ }
66
+ function contentTypes(bytes) {
67
+ if (bytes === void 0) return /* @__PURE__ */ new Map();
68
+ const declared = /* @__PURE__ */ new Map();
69
+ for (const el of elementChildren(
70
+ parseXml(decodeUtf8(bytes).text).documentElement
71
+ )) {
72
+ const key = el.localName === "Default" ? el.getAttribute("Extension") : el.localName === "Override" ? el.getAttribute("PartName") : null;
73
+ if (key !== null) declared.set(key, el.getAttribute("ContentType") ?? "");
74
+ }
75
+ return declared;
76
+ }
77
+ function contentTypesKept(before, after) {
78
+ for (const [key, type] of before) {
79
+ if (after.get(key) !== type) return false;
80
+ }
81
+ for (const [key, type] of after) {
82
+ if (!before.has(key) && !COMMENT_CONTENT_TYPES.includes(type)) return false;
83
+ }
84
+ return true;
85
+ }
86
+ function packageKept(before, after) {
87
+ if (before.mainPartPath !== after.mainPartPath) {
88
+ return { ok: false, reason: "part-changed", part: before.mainPartPath };
89
+ }
90
+ const relsPath = relsPathOf(before.mainPartPath);
91
+ const untouched = /* @__PURE__ */ new Set([
92
+ before.mainPartPath,
93
+ relsPath,
94
+ CONTENT_TYPES_PATH,
95
+ ...commentPartPaths(before),
96
+ ...commentPartPaths(after)
97
+ ]);
98
+ for (const path of /* @__PURE__ */ new Set([...before.parts.keys(), ...after.parts.keys()])) {
99
+ if (untouched.has(path)) continue;
100
+ const was = before.parts.get(path);
101
+ const now = after.parts.get(path);
102
+ if (was === void 0 || now === void 0 || !sameBytes(was, now)) {
103
+ return { ok: false, reason: "part-changed", part: path };
104
+ }
105
+ }
106
+ if (!relationshipsKept(
107
+ readRelationships(before.parts, relsPath),
108
+ readRelationships(after.parts, relsPath)
109
+ )) {
110
+ return { ok: false, reason: "relationship-changed", part: relsPath };
111
+ }
112
+ if (!contentTypesKept(
113
+ contentTypes(before.parts.get(CONTENT_TYPES_PATH)),
114
+ contentTypes(after.parts.get(CONTENT_TYPES_PATH))
115
+ )) {
116
+ return { ok: false, reason: "part-changed", part: CONTENT_TYPES_PATH };
117
+ }
118
+ if (aroundTheStory(before) !== aroundTheStory(after)) {
119
+ return { ok: false, reason: "part-changed", part: before.mainPartPath };
120
+ }
121
+ return { ok: true };
122
+ }
123
+ function storyKept(before, after, authorId, editableComments) {
124
+ if (!changesOnlyComments(before, after)) return refused("body-changed");
125
+ if (!commentIdentitiesKept(before, after) || !commentAdditionsBy(before, after, authorId)) {
126
+ return refused("comment-author-forged");
127
+ }
128
+ if (!commentEditsOwned(before, after, {
129
+ protection: "comments",
130
+ authorId,
131
+ editableComments
132
+ })) {
133
+ return refused("comment-not-owned");
134
+ }
135
+ return { ok: true };
136
+ }
137
+ function onlyCommentsChangedBy(original, submitted, authorId, { editableComments = "own" } = {}) {
138
+ const before = importDocx(original);
139
+ const after = importDocx(submitted);
140
+ const packaged = packageKept(before.session, after.session);
141
+ return packaged.ok ? storyKept(before.doc, after.doc, authorId, editableComments) : packaged;
142
+ }
143
+ export {
144
+ onlyCommentsChangedBy
145
+ };
@@ -6,3 +6,11 @@ export declare const CONTENT_TYPES_PATH = "[Content_Types].xml";
6
6
  export declare const W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml";
7
7
  export declare const W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
8
8
  export declare const MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006";
9
+ export declare const PEOPLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/people";
10
+ export declare const PEOPLE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml";
11
+ /**
12
+ * The `w15:providerId` under which this editor records a comment author's identity. An identity
13
+ * another provider recorded, Word's directory above all, is one this editor cannot vouch for and
14
+ * is read as none.
15
+ */
16
+ export declare const COMMENT_AUTHOR_PROVIDER = "portone-docx-editor";
@@ -8,13 +8,19 @@ var CONTENT_TYPES_PATH = "[Content_Types].xml";
8
8
  var W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml";
9
9
  var W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
10
10
  var MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006";
11
+ var PEOPLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/people";
12
+ var PEOPLE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml";
13
+ var COMMENT_AUTHOR_PROVIDER = "portone-docx-editor";
11
14
  export {
12
15
  COMMENTS_CONTENT_TYPE,
13
16
  COMMENTS_EXTENDED_CONTENT_TYPE,
14
17
  COMMENTS_EXTENDED_REL_TYPE,
15
18
  COMMENTS_REL_TYPE,
19
+ COMMENT_AUTHOR_PROVIDER,
16
20
  CONTENT_TYPES_PATH,
17
21
  MC_NS,
22
+ PEOPLE_CONTENT_TYPE,
23
+ PEOPLE_REL_TYPE,
18
24
  W14_NS,
19
25
  W15_NS
20
26
  };
@@ -0,0 +1,7 @@
1
+ /** Declares a part an export adds in `[Content_Types].xml`, leaving the original text as it came. */
2
+ /**
3
+ * The content types part with an override for this part, or null when it already declares one.
4
+ * `current` is the part as an earlier addition in the same export left it, so that two additions
5
+ * do not each write over the other's declaration.
6
+ */
7
+ export declare function withContentType(parts: Map<string, Uint8Array>, partPath: string, contentType: string, current: Uint8Array | undefined): Uint8Array | null;