@portone/docx-editor 0.5.0 → 0.6.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 (55) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CONTRIBUTING.md +10 -2
  3. package/README.md +1 -1
  4. package/dist/DocxEditor.js +3 -3
  5. package/dist/docx/formatting/styles.d.ts +0 -2
  6. package/dist/docx/formatting/styles.js +2 -15
  7. package/dist/docx/identities.js +17 -26
  8. package/dist/docx/importParagraph.d.ts +6 -5
  9. package/dist/docx/importParagraph.js +15 -62
  10. package/dist/docx/importPolicy.js +10 -5
  11. package/dist/docx/serializeParagraph.d.ts +5 -4
  12. package/dist/docx/serializeParagraph.js +22 -58
  13. package/dist/docx/tableTemplate.d.ts +25 -0
  14. package/dist/docx/tableTemplate.js +30 -16
  15. package/dist/docx/wrappers.d.ts +67 -0
  16. package/dist/docx/wrappers.js +117 -0
  17. package/dist/editor/clipboard/blockReaders.d.ts +74 -0
  18. package/dist/editor/clipboard/blockReaders.js +193 -0
  19. package/dist/editor/clipboard/htmlReader.d.ts +2 -6
  20. package/dist/editor/clipboard/htmlReader.js +98 -24
  21. package/dist/editor/clipboard/inlineFormatting.d.ts +0 -7
  22. package/dist/editor/clipboard/inlineFormatting.js +1 -7
  23. package/dist/editor/clipboard/plugin.js +7 -139
  24. package/dist/editor/clipboard/readContext.d.ts +10 -0
  25. package/dist/editor/clipboard/readContext.js +8 -2
  26. package/dist/editor/clipboard/source.d.ts +12 -0
  27. package/dist/editor/clipboard/source.js +21 -0
  28. package/dist/editor/commands/comments/editing.d.ts +4 -1
  29. package/dist/editor/commands/comments/editing.js +3 -4
  30. package/dist/editor/commands/comments/model.d.ts +7 -0
  31. package/dist/editor/commands/linkCommands.js +20 -5
  32. package/dist/editor/commands/lockCommands.js +59 -16
  33. package/dist/editor/commands/paragraphCommands.js +2 -2
  34. package/dist/editor/createEditor.js +4 -0
  35. package/dist/editor/plugins/commentDecorations.js +5 -4
  36. package/dist/editor/plugins/commentRestoration.d.ts +20 -0
  37. package/dist/editor/plugins/commentRestoration.js +168 -0
  38. package/dist/ooxml/props.d.ts +2 -0
  39. package/dist/ooxml/props.js +14 -0
  40. package/dist/schema/attrRoles.js +8 -3
  41. package/dist/schema/clipboard.d.ts +72 -0
  42. package/dist/schema/clipboard.js +288 -0
  43. package/dist/schema/docxSchema.js +27 -27
  44. package/dist/schema/locks.d.ts +3 -0
  45. package/dist/schema/locks.js +29 -12
  46. package/dist/schema/rendering.d.ts +14 -0
  47. package/dist/schema/rendering.js +24 -11
  48. package/dist/schema/wrappers.d.ts +58 -0
  49. package/dist/schema/wrappers.js +77 -0
  50. package/dist/styles/classNames.d.ts +2 -0
  51. package/dist/styles/classNames.js +2 -0
  52. package/dist/styles.css +7 -4
  53. package/dist/ui/CommentsPanel.d.ts +6 -1
  54. package/dist/ui/CommentsPanel.js +26 -8
  55. package/package.json +6 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # @portone/docx-editor
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#131](https://github.com/portone-io/docx-editor/pull/131) [`5dd468a`](https://github.com/portone-io/docx-editor/commit/5dd468ae14d6210070ab6c8275d16e03b20cf64c) Thanks [@Deea222](https://github.com/Deea222)! - A comment now outlives the text it was written for, and a resolved one stops marking the page.
8
+
9
+ Deleting commented text used to delete the comment and everything said under it. The thread now
10
+ stays. Trim the highlighted text - type over part of it, backspace through its edge - and the
11
+ comment follows what is left. Delete all of it and the comment comes back detached: no longer drawn
12
+ beside the page, since there is nothing to draw it next to, and listed under All comments marked
13
+ "Original content deleted". Cutting commented text leaves it behind the same way instead of dropping
14
+ it. Word has no detached state, so such a comment reaches the saved file as an ordinary comment
15
+ sitting where the deleted text was. Deleting a comment from its own Delete button still deletes it,
16
+ and undo and redo still take a deletion back and make it again.
17
+
18
+ Resolving a thread now takes its highlight off the text, and reopening puts it back. Clicking a
19
+ resolved comment's card in All comments selects the text it was written about and scrolls it into
20
+ view, which `selectComment` now does for every caller.
21
+
22
+ `documentComments` reports a new `anchored` field on every comment, `false` for one that no longer
23
+ marks a stretch of the document. Its existing fields are unchanged, so a panel of your own keeps
24
+ working and can read the new field to decide what a click on a card should do.
25
+
26
+ ## 0.5.1
27
+
28
+ ### Patch Changes
29
+
30
+ - [#125](https://github.com/portone-io/docx-editor/pull/125) [`88ff33a`](https://github.com/portone-io/docx-editor/commit/88ff33abac21cd56d87723af60d95ab597ccff39) Thanks [@Deea222](https://github.com/Deea222)! - Pasting from Word, Google Docs or LibreOffice now keeps tables as tables and Word lists as lists.
31
+
32
+ A table on the clipboard used to arrive as a single paragraph holding the text of every cell run
33
+ together. It now arrives as a table: one paragraph for each block a cell held, the columns and rows
34
+ a cell reached across kept, and the same width and lines a table inserted here has.
35
+
36
+ Word writes list items as ordinary paragraphs that name the list they belong to and draw their own
37
+ bullet or number, so pasting one used to give unnumbered paragraphs each beginning with a stray
38
+ marker. The items of one Word list now join one list, at the level Word gives them, counted when
39
+ the marker Word drew counts.
40
+
41
+ A table's caption is kept as the paragraph above it, a table inside a list item is read as a table
42
+ rather than as the item's own text, and a table too large for the editor to hold is read as one
43
+ paragraph per row instead of as a single run-on one.
44
+
45
+ A copy wrapped in a single element, which is how Google Docs writes one, no longer has its
46
+ paragraphs pressed into one.
47
+
48
+ - [#123](https://github.com/portone-io/docx-editor/pull/123) [`3d6b0a5`](https://github.com/portone-io/docx-editor/commit/3d6b0a58ea9e0a5ca651a5579d5dd0a8b4646ff6) Thanks [@Deea222](https://github.com/Deea222)! - Copied HTML and text now follow one declared outbound shape per node and mark, so what leaves the editor is decided beside the schema rather than filtered out of the page's own drawing afterwards. A copy pasted into another application carries paragraphs, styles, runs, links, images at the size they were drawn, and tables as tables, and none of the document's internals. A comment marker, a bookmark, and the placeholder standing for content the editor could not model now leave nothing behind them, where before they left an element carrying the editor's own name for what stood there.
49
+
50
+ Copied plain text reads as the page does. A table pasted into a spreadsheet keeps one row per row, with everything a cell holds on the one line that cell stands on, and a block the editor only kept no longer opens a blank line where it stood. A kept line break and a kept character, such as a no-break hyphen, travel with the text they stand in, and so does the text a placeholder draws: a field's cached result and the words of a tracked insertion read in a copy as they read on the page.
51
+
52
+ - [#124](https://github.com/portone-io/docx-editor/pull/124) [`936b2a0`](https://github.com/portone-io/docx-editor/commit/936b2a08d0d26b1a9b77cf39c718bb85b70a834c) Thanks [@Deea222](https://github.com/Deea222)! - Hyperlinks that hold content controls, and nested controls, open as editable text.
53
+
54
+ Only one of the arrangements used to be readable - a control holding a link. A link holding a
55
+ control, or a control holding a control, brought the inner one in as a small box naming the element
56
+ it stood for, and the text it held could not be typed in. Any depth and any order of the two is now
57
+ ordinary text wearing a wrapper each, and goes back out nested as it came. A hyperlink inside a
58
+ hyperlink is the one arrangement still kept whole, since a link marks the text it covers once.
59
+
60
+ A link made on text inside a control is written inside that control, and a lock put on a stretch
61
+ that sits wholly inside a link is written inside that link, instead of either wrapper being split
62
+ around the other. Where a control holds a control, locking shuts the outer one and lifting a lock
63
+ opens the inner one the selection stands in.
64
+
65
+ Plugins reading the drawn page will find `data-key` and `data-depth` on a control and a link in
66
+ place of `data-sdt-key` and `data-link-key`.
67
+
3
68
  ## 0.5.0
4
69
 
5
70
  ### Minor Changes
package/CONTRIBUTING.md CHANGED
@@ -44,25 +44,33 @@ Run a specialized check when your change affects its area:
44
44
  pnpm test:package
45
45
  pnpm verify:package
46
46
  pnpm test:e2e
47
+ pnpm bench
47
48
  ```
48
49
 
50
+ A pull request that changes performance-sensitive code attaches its `pnpm bench` output to the description.
51
+
49
52
  [Testing](https://github.com/portone-io/docx-editor/blob/main/docs/testing.md) explains the scope and prerequisites of every check.
53
+ CI runs `pnpm test:package` on every pull request in a separate job; it is not part of the local `pnpm check` command.
50
54
 
51
55
  A command added to `./commands` or `./table` needs a writer probe in `src/docx/__testing__/writerProbes.ts` with a `check` of its immediate effect, so its export is validated before another command can overwrite it, or an entry in `NOT_A_WRITER` giving the reason it reaches no writer.
52
56
 
53
57
  ## Changesets
54
58
 
55
- If you changed anything under `src/`, run `pnpm changeset` and commit the file it writes.
59
+ If you changed production code under `src/`, run `pnpm changeset` and commit the file it writes.
56
60
  It becomes the CHANGELOG entry, so write it for someone reading release notes rather than the diff.
61
+ Changes limited to tests, fixtures, scripts, or documentation do not need a changeset.
57
62
 
58
- The public API is the exported names and types of the four entry points - `.`, `./core`, `./commands`, and `./table` - together with the parts of the document model that [What a plugin may rely on](https://docx-editor.portone.io/docs/custom-controls#what-a-plugin-may-rely-on) calls stable.
63
+ The public API is the exported names and types of the four entry points - `.`, `./core`, `./commands`, and `./table` - together with the parts of the document model that [What a plugin may rely on](https://docx-editor.portone.io/docs/editor-api/plugins-and-presets#what-a-plugin-may-rely-on) calls stable.
59
64
  An attr that page calls internal is not part of it, and neither is anything under `src/` that no entry point reaches.
60
65
 
61
66
  Choose `patch` unless the change adds to that surface, takes something out of it, or changes what a part of it means.
62
67
  Those are `minor` while the package is below 1.0.
68
+ A diff to `src/schema/attrRoles.ts` is where to check this: a change to a stable document-model attr is `minor` even when the declaration reports do not change.
63
69
  A declaration added, removed, or rewritten in `etc/*.api.md` after running `pnpm api:update` is what that looks like; a line that only gains or loses an `(undocumented)` marker is not.
64
70
  Commit the regenerated report in the same commit as the changeset.
65
71
 
72
+ The changesets that have landed become a release the way [Releasing](https://github.com/portone-io/docx-editor/blob/main/docs/releasing.md) describes.
73
+
66
74
  By contributing, you agree that your contribution is licensed under the Apache License 2.0 used by this project.
67
75
 
68
76
  ## Commit messages
package/README.md CHANGED
@@ -47,7 +47,7 @@ export function Editor({
47
47
  - Preserves document structures and package parts across edits, and records who wrote each comment.
48
48
  - Reads document styles, theme fonts, page layout, and CJK font information for display.
49
49
 
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).
50
+ See [Feature support](https://docx-editor.portone.io/docs/features) for the support matrix. Build controls of your own with the [Editor API](https://docx-editor.portone.io/docs/editor-api) or use [programmatic DOCX import and export](https://docx-editor.portone.io/docs/core).
51
51
 
52
52
  The full [documentation](https://docx-editor.portone.io/docs) lives on the documentation site.
53
53
 
@@ -31,7 +31,7 @@ import { usePageLayout } from "./page/usePageLayout.js";
31
31
  import { editingProtection, protectionOf } from "./schema/protectionState.js";
32
32
  import { storyNodeOf } from "./schema/stories.js";
33
33
  import { editorClassNames } from "./styles/classNames.js";
34
- import { CommentsPanel } from "./ui/CommentsPanel.js";
34
+ import { CommentsPanel, shownBesideThePage } from "./ui/CommentsPanel.js";
35
35
  import { LinkCard } from "./ui/LinkCard.js";
36
36
  import { LinkPanel } from "./ui/LinkPanel.js";
37
37
  import { NotesPanel } from "./ui/NotesPanel.js";
@@ -280,9 +280,9 @@ function DocxEditorSurface({
280
280
  const tableAnchor = live && !textAnchor ? tableMenuAnchor(live.state) : null;
281
281
  const linkAtCursor = live && !live.view.composing && !isLinkPanelOpen(live.state) ? activeLinkSpan(live.state) : null;
282
282
  const comments = live?.state === void 0 ? [] : commentProjection.read(live.state).comments;
283
- const hasUnresolvedComments = comments.some((comment) => !comment.resolved);
283
+ const hasRailComments = comments.some(shownBesideThePage);
284
284
  const composerOpen = live !== null && isCommentComposerOpen(live.state);
285
- const showComments = live !== null && (commentsOpen || composerOpen || hasUnresolvedComments);
285
+ const showComments = live !== null && (commentsOpen || composerOpen || hasRailComments);
286
286
  const commentsPanel = live && showComments && /* @__PURE__ */ jsx(
287
287
  CommentsPanel,
288
288
  {
@@ -75,8 +75,6 @@ export interface ParagraphStyleOption {
75
75
  * `defaultParagraphStyleIdOf` reads them by.
76
76
  */
77
77
  export declare function readParagraphStyles(styles: Document): ParagraphStyleOption[];
78
- /** The style name the paragraph formatting XML points at. null if it points at none */
79
- export declare function styleIdOf(pPr: unknown): string | null;
80
78
  /**
81
79
  * The values the style a paragraph wears lays down.
82
80
  *
@@ -5,7 +5,7 @@ import {
5
5
  toRunFormat,
6
6
  toTableFormat
7
7
  } from "../../model/format.js";
8
- import { parsePropsXml } from "../../ooxml/props.js";
8
+ import { styleIdOf } from "../../ooxml/props.js";
9
9
  import { ST_OnOff } from "../../ooxml/simpleTypes.js";
10
10
  import { childValue, isOn, wAttr } from "../../ooxml/units.js";
11
11
  import { childByLocalName, elementChildren } from "../../ooxml/xml.js";
@@ -201,18 +201,6 @@ function readParagraphStyles(styles) {
201
201
  }
202
202
  return options;
203
203
  }
204
- var styleIdsByPPr = /* @__PURE__ */ new Map();
205
- var STYLE_ID_CACHE_LIMIT = 2e3;
206
- function styleIdOf(pPr) {
207
- if (typeof pPr !== "string" || !pPr.includes("pStyle")) return null;
208
- const known = styleIdsByPPr.get(pPr);
209
- if (known !== void 0) return known;
210
- const el = parsePropsXml(pPr);
211
- const id = el ? childValue(el, "pStyle") : null;
212
- if (styleIdsByPPr.size >= STYLE_ID_CACHE_LIMIT) styleIdsByPPr.clear();
213
- styleIdsByPPr.set(pPr, id);
214
- return id;
215
- }
216
204
  function paragraphStyleFormat(pPr, styles, defaultStyleId = null) {
217
205
  const id = styleIdOf(pPr) ?? defaultStyleId;
218
206
  return id === null ? void 0 : styles.get(id);
@@ -241,6 +229,5 @@ export {
241
229
  numberingStyleLinks,
242
230
  paragraphStyleFormat,
243
231
  readParagraphStyles,
244
- readStyles,
245
- styleIdOf
232
+ readStyles
246
233
  };
@@ -4,8 +4,9 @@ import { DocxExportError } from "../ooxml/errors.js";
4
4
  import { qualify } from "../ooxml/names.js";
5
5
  import { parseAttrs } from "../ooxml/tagScan.js";
6
6
  import { docxSchema } from "../schema/index.js";
7
+ import { wrappersOf } from "../schema/wrappers.js";
7
8
  import { withoutParagraphIds } from "./cloning.js";
8
- import { copiedControlPrefix, newControlId } from "./sdt.js";
9
+ import { wrapperKindOf } from "./wrappers.js";
9
10
  var sourceBlockRule = {
10
11
  name: "sourceBlock",
11
12
  visit(block, written) {
@@ -51,21 +52,16 @@ var paragraphIdRule = {
51
52
  );
52
53
  }
53
54
  };
54
- function sdtMarkOf(node) {
55
- return node.marks.find((mark) => mark.type === docxSchema.marks.sdt) ?? null;
55
+ function sdtMarksOf(node) {
56
+ return wrappersOf(node, docxSchema.marks.sdt);
56
57
  }
57
58
  function controlName(mark) {
58
- const key = mark.attrs.sdtKey;
59
+ const key = mark.attrs.key;
59
60
  const prefix = mark.attrs.sdtPrefix;
60
61
  return `${typeof key === "number" ? key : 0} ${typeof prefix === "string" ? prefix : ""}`;
61
62
  }
62
63
  function copiedMark(mark) {
63
- const prefix = mark.attrs.sdtPrefix;
64
- if (typeof prefix !== "string") return mark;
65
- return mark.type.create({
66
- ...mark.attrs,
67
- sdtPrefix: copiedControlPrefix(prefix, newControlId())
68
- });
64
+ return wrapperKindOf(mark).copy?.(mark) ?? mark;
69
65
  }
70
66
  function claim(mark, written) {
71
67
  const name = controlName(mark);
@@ -75,25 +71,20 @@ function claim(mark, written) {
75
71
  }
76
72
  function rewriteParagraph(paragraph, written) {
77
73
  const inline = [];
78
- let running = null;
74
+ let running = [];
79
75
  let renamed = false;
80
76
  paragraph.forEach((child) => {
81
- const mark = sdtMarkOf(child);
82
- if (!mark) {
83
- running = null;
84
- inline.push(child);
85
- return;
86
- }
87
- const control = running?.mark.eq(mark) ? running : claim(mark, written);
88
- running = control;
89
- if (!control.copy) {
90
- inline.push(child);
91
- return;
92
- }
93
- renamed = true;
94
- inline.push(
95
- child.mark(control.copy.addToSet(mark.removeFromSet(child.marks)))
77
+ const controls = sdtMarksOf(child).map(
78
+ (mark) => running.find((control) => control.mark.eq(mark)) ?? claim(mark, written)
96
79
  );
80
+ running = controls;
81
+ let marks = child.marks;
82
+ for (const control of controls) {
83
+ if (!control.copy) continue;
84
+ renamed = true;
85
+ marks = control.copy.addToSet(control.mark.removeFromSet(marks));
86
+ }
87
+ inline.push(marks === child.marks ? child : child.mark(marks));
97
88
  });
98
89
  return renamed ? paragraph.copy(Fragment.fromArray(inline)) : paragraph;
99
90
  }
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * A `w:sdt` content control and a `w:hyperlink` standing inside the paragraph are both unwrapped, so
7
7
  * the text they hold stays editable, and the wrapper each came in rides along on a mark to go back
8
- * out around the same text. A control may hold a link; a link holding a control is a nesting the
9
- * marks cannot record in that order, and that control stays whole as one preserved fragment.
8
+ * out around the same text. Either may hold the other and a control may hold a control: the nesting
9
+ * is read as the file wrote it and recorded on the marks (`docx/wrappers`). A link inside a link is
10
+ * the one arrangement no mark can record, and the inner one stays whole as it always has.
10
11
  *
11
12
  * Nothing here demotes a paragraph. What the editor has no model for - a field character, a
12
13
  * tracked insertion, a symbol, a drawing nobody could read - is kept where it stood, inside its
@@ -37,8 +38,8 @@ export declare const NO_IMPORT_SOURCES: ImportSources;
37
38
  /**
38
39
  * Moves a paragraph into an editable node.
39
40
  *
40
- * Every paragraph opens editable. A child the reader has no model for, and a control or a link
41
- * whose shape it could not put back together, is kept whole where it stood instead of standing
42
- * the whole paragraph down.
41
+ * Every paragraph opens editable. A child the reader has no model for, and a wrapper whose shape
42
+ * it could not put back together, is kept whole where it stood instead of standing the whole
43
+ * paragraph down.
43
44
  */
44
45
  export declare function buildParagraph(el: Element, srcId: string | null, sources?: ImportSources): PMNode;
@@ -12,10 +12,7 @@ import { docxSchema } from "../schema/index.js";
12
12
  import { commentParaId, importedCommentReplies } from "./comments/model.js";
13
13
  import { NO_COMMENTS } from "./comments/reading.js";
14
14
  import { readParagraphFormat, readRunFormat } from "./formatting.js";
15
- import {
16
- NO_LINK_TARGETS,
17
- readHyperlinkWrapper
18
- } from "./hyperlink.js";
15
+ import { NO_LINK_TARGETS } from "./hyperlink.js";
19
16
  import { policyFor } from "./importPolicy.js";
20
17
  import {
21
18
  buildPreservedInline,
@@ -24,8 +21,8 @@ import {
24
21
  } from "./importPreserved.js";
25
22
  import { NO_IMAGES } from "./media.js";
26
23
  import { NO_NOTES, noteById } from "./notes.js";
27
- import { readSdtWrapper } from "./sdt.js";
28
24
  import { NO_THEME_FONTS } from "./theme.js";
25
+ import { wrapperFits, wrapperKindFor } from "./wrappers.js";
29
26
  function runMark(run, themeFonts) {
30
27
  const rPr = childByLocalName(run, "rPr");
31
28
  return docxSchema.marks.run.create({
@@ -166,13 +163,6 @@ function buildRunNodes(run, images, themeFonts, comments, notes, noteLabel, wrap
166
163
  }
167
164
  return nodes;
168
165
  }
169
- var controlCounts = /* @__PURE__ */ new WeakMap();
170
- var linkCounts = /* @__PURE__ */ new WeakMap();
171
- function nextKey(counts, el) {
172
- const key = counts.get(el.ownerDocument) ?? 0;
173
- counts.set(el.ownerDocument, key + 1);
174
- return key;
175
- }
176
166
  function annotationId(el) {
177
167
  return Array.from(el.attributes).find((attribute) => attribute.localName === "id")?.value ?? null;
178
168
  }
@@ -190,7 +180,7 @@ var NO_IMPORT_SOURCES = {
190
180
  notes: NO_NOTES,
191
181
  noteLabel: () => "?"
192
182
  };
193
- function buildModelledWrapperChild(child, sources, wrappers, linkAllowed) {
183
+ function buildModelledInline(child, sources, depth, wrappers) {
194
184
  if (child.localName === "r") {
195
185
  return buildRunNodes(
196
186
  child,
@@ -202,23 +192,25 @@ function buildModelledWrapperChild(child, sources, wrappers, linkAllowed) {
202
192
  wrappers
203
193
  );
204
194
  }
205
- if (child.localName === "hyperlink") {
206
- return linkAllowed ? buildHyperlinkNodes(child, sources, wrappers) : null;
195
+ const kind = wrapperKindFor(child);
196
+ if (kind) {
197
+ if (!wrapperFits(kind, wrappers)) return null;
198
+ const reading = kind.read(child, depth, sources);
199
+ if (!reading) return null;
200
+ return buildContent(reading.content, sources, depth + 1, [
201
+ ...wrappers,
202
+ reading.mark
203
+ ]);
207
204
  }
208
205
  const commentMarker = commentRangeNode(child, wrappers);
209
206
  return commentMarker === null ? null : [commentMarker];
210
207
  }
211
- function buildWrappedNodes(content, sources, wrappers, linkAllowed) {
208
+ function buildContent(content, sources, depth, wrappers) {
212
209
  const nodes = [];
213
210
  for (const child of elementChildren(content)) {
214
211
  const policy = policyFor(child, "wrapper");
215
212
  if (policy.tier === "model") {
216
- const built = buildModelledWrapperChild(
217
- child,
218
- sources,
219
- wrappers,
220
- linkAllowed
221
- );
213
+ const built = buildModelledInline(child, sources, depth, wrappers);
222
214
  if (built !== null) {
223
215
  nodes.push(...built);
224
216
  continue;
@@ -231,45 +223,6 @@ function buildWrappedNodes(content, sources, wrappers, linkAllowed) {
231
223
  if (nodes.length === 0) return null;
232
224
  return nodes;
233
225
  }
234
- function buildHyperlinkNodes(el, sources, wrappers) {
235
- const wrapper = readHyperlinkWrapper(el);
236
- if (!wrapper) return null;
237
- const mark = docxSchema.marks.link.create({
238
- linkPrefix: wrapper.prefix,
239
- href: wrapper.relId === null ? null : sources.links.get(wrapper.relId) ?? null,
240
- linkKey: nextKey(linkCounts, el)
241
- });
242
- return buildWrappedNodes(el, sources, [...wrappers, mark], false);
243
- }
244
- function buildSdtNodes(el, sources) {
245
- const wrapper = readSdtWrapper(el);
246
- if (!wrapper) return null;
247
- const mark = docxSchema.marks.sdt.create({
248
- sdtPrefix: wrapper.prefix,
249
- sdtKey: nextKey(controlCounts, el),
250
- contentsLocked: wrapper.contentsLocked,
251
- deletionLocked: wrapper.deletionLocked
252
- });
253
- return buildWrappedNodes(wrapper.content, sources, [mark], true);
254
- }
255
- function buildModelledParagraphChild(child, sources) {
256
- if (child.localName === "r") {
257
- return buildRunNodes(
258
- child,
259
- sources.images,
260
- sources.themeFonts,
261
- sources.comments,
262
- sources.notes,
263
- sources.noteLabel
264
- );
265
- }
266
- if (child.localName === "sdt") return buildSdtNodes(child, sources);
267
- if (child.localName === "hyperlink") {
268
- return buildHyperlinkNodes(child, sources, []);
269
- }
270
- const commentMarker = commentRangeNode(child);
271
- return commentMarker === null ? null : [commentMarker];
272
- }
273
226
  function buildParagraph(el, srcId, sources = NO_IMPORT_SOURCES) {
274
227
  const inline = [];
275
228
  let pPrElement = null;
@@ -280,7 +233,7 @@ function buildParagraph(el, srcId, sources = NO_IMPORT_SOURCES) {
280
233
  pPrElement = child;
281
234
  continue;
282
235
  }
283
- const built = buildModelledParagraphChild(child, sources);
236
+ const built = buildModelledInline(child, sources, 0, []);
284
237
  if (built !== null) {
285
238
  inline.push(...built);
286
239
  continue;
@@ -5,6 +5,7 @@ import {
5
5
  PERMISSION_MARKERS,
6
6
  RANGE_MARKERS
7
7
  } from "../ooxml/rangeMarkers.js";
8
+ import { WRAPPER_KINDS } from "./wrappers.js";
8
9
  var MODEL = { tier: "model" };
9
10
  var HIDDEN_MARKER = {
10
11
  tier: "marker",
@@ -87,20 +88,24 @@ var PARAGRAPH_CHILDREN = [
87
88
  [REVISION_CONTAINERS, INLINE_CHIP],
88
89
  [["fldSimple", "smartTag", "customXml", "dir", "bdo", "subDoc"], INLINE_CHIP]
89
90
  ];
91
+ var WRAPPER_ELEMENTS = WRAPPER_KINDS.map(
92
+ (kind) => kind.element
93
+ );
90
94
  var PARAGRAPH_LEVEL = new Map([
91
95
  ...rules([
92
- [["pPr", "r", "hyperlink", "sdt"], MODEL],
96
+ [["pPr", "r"], MODEL],
93
97
  [COMMENT_RANGE_MARKERS, MODEL],
94
- ...PARAGRAPH_CHILDREN
98
+ ...PARAGRAPH_CHILDREN,
99
+ [WRAPPER_ELEMENTS, MODEL]
95
100
  ]),
96
101
  ...MATH.map((name) => [name, INLINE_CHIP])
97
102
  ]);
98
103
  var WRAPPER_LEVEL = new Map([
99
104
  ...rules([
100
- [["r", "hyperlink"], MODEL],
105
+ [["r"], MODEL],
101
106
  [COMMENT_RANGE_MARKERS, MODEL],
102
- [["sdt"], INLINE_CHIP],
103
- ...PARAGRAPH_CHILDREN
107
+ ...PARAGRAPH_CHILDREN,
108
+ [WRAPPER_ELEMENTS, MODEL]
104
109
  ]),
105
110
  ...MATH.map((name) => [name, INLINE_CHIP])
106
111
  ]);
@@ -7,10 +7,11 @@
7
7
  * a piece of that run, and one kept beside the runs is written between them, which is what the
8
8
  * two content models admit.
9
9
  *
10
- * The inlines are grouped three times over: neighbours that share their formatting become one run,
11
- * the runs that share a hyperlink (`w:hyperlink`) go back inside it, and the links and runs that
12
- * share a content control (`w:sdt`) go back inside the wrapper that mark carries. The control is
13
- * the outer of the two wrappers, which is the nesting the mark order records (`schema`).
10
+ * The inlines are grouped from the outside in: neighbours sharing the outermost wrapper each stands
11
+ * inside go back into it, that grouping is made again one wrapper deeper, and at the bottom the
12
+ * neighbours that share their formatting become one run. `schema/wrappers` says which wrapper is
13
+ * which depth, and `docx/wrappers` says what each of them opens and closes as, so a wrapper of a
14
+ * new kind is written here without this file knowing anything about it.
14
15
  */
15
16
  import type { Node as PMNode } from "prosemirror-model";
16
17
  import { type ExportRefs } from "./exportRefs";
@@ -9,8 +9,9 @@ import {
9
9
  } from "../ooxml/image.js";
10
10
  import { wName } from "../ooxml/names.js";
11
11
  import { escapeXml } from "../ooxml/xml.js";
12
+ import { wrapperMarks } from "../schema/wrappers.js";
12
13
  import { NO_EXPORT_REFS } from "./exportRefs.js";
13
- import { relIdIn, withRelId } from "./hyperlink.js";
14
+ import { wrapperKindOf } from "./wrappers.js";
14
15
  function sameMark(a, b) {
15
16
  if (a === null || b === null) return a === b;
16
17
  return a.eq(b);
@@ -141,76 +142,39 @@ function addInline(parts, child, images) {
141
142
  parts.push({ kind: "run", mark, pieces: [piece] });
142
143
  }
143
144
  }
144
- function splitParagraphGroups(paragraph, refs) {
145
- const groups = [];
146
- paragraph.forEach((child) => {
147
- const sdt = markOf(child, "sdt");
148
- const link = markOf(child, "link");
149
- let group = groups.at(-1);
150
- if (!group || !sameMark(group.sdt, sdt)) {
151
- group = { sdt, links: [] };
152
- groups.push(group);
153
- }
154
- let inLink = group.links.at(-1);
155
- if (!inLink || !sameMark(inLink.link, link)) {
156
- inLink = { link, parts: [] };
157
- group.links.push(inLink);
158
- }
159
- addInline(inLink.parts, child, refs.images);
160
- });
161
- return groups;
162
- }
163
145
  function renderParagraphPart(part) {
164
146
  if (part.kind === "raw") return part.xml;
165
147
  const open = openTagXml(wName("r"), rawAttrsOf(part.mark?.attrs.rAttrs));
166
148
  const rPr = part.mark?.attrs.rPr;
167
149
  return open + (typeof rPr === "string" ? rPr : "") + part.pieces.join("") + "</w:r>";
168
150
  }
169
- function openLinkTag(mark, links) {
170
- const prefix = mark.attrs.linkPrefix;
171
- const original = typeof prefix === "string" ? prefix : null;
172
- const href = mark.attrs.href;
173
- if (typeof href !== "string") {
174
- if (original !== null) return original;
175
- throw new DocxExportError(
176
- "lost-original",
177
- "a hyperlink carries neither an address nor the opening XML it goes back out as"
178
- );
179
- }
180
- const relId = links.relIdOf(
181
- href,
182
- original === null ? null : relIdIn(original)
183
- );
184
- if (relId === void 0) {
185
- if (original !== null) return original;
186
- throw new DocxExportError(
187
- "unsupported-content",
188
- "an inserted hyperlink has no relationship to point at; export it through exportDocx"
189
- );
151
+ function groupsAtDepth(children, depth) {
152
+ const groups = [];
153
+ for (const child of children) {
154
+ const mark = wrapperMarks(child)[depth] ?? null;
155
+ const last = groups.at(-1);
156
+ if (last && sameMark(last.mark, mark)) last.children.push(child);
157
+ else groups.push({ mark, children: [child] });
190
158
  }
191
- return withRelId(original ?? "<w:hyperlink>", relId);
159
+ return groups;
192
160
  }
193
- function renderLinkGroup(group, links) {
194
- const body = group.parts.map(renderParagraphPart).join("");
195
- if (!group.link) return body;
196
- return `${openLinkTag(group.link, links)}${body}</w:hyperlink>`;
161
+ function renderParts(children, images) {
162
+ const parts = [];
163
+ for (const child of children) addInline(parts, child, images);
164
+ return parts.map(renderParagraphPart).join("");
197
165
  }
198
- function renderParagraphGroup(group, refs) {
199
- const body = group.links.map((inLink) => renderLinkGroup(inLink, refs.links)).join("");
200
- if (!group.sdt) return body;
201
- const prefix = group.sdt.attrs.sdtPrefix;
202
- if (typeof prefix !== "string") {
203
- throw new DocxExportError(
204
- "lost-original",
205
- "a content control has lost the opening XML it goes back out as"
206
- );
207
- }
208
- return `${prefix}<w:sdtContent>${body}</w:sdtContent></w:sdt>`;
166
+ function renderWrapped(children, depth, refs) {
167
+ return groupsAtDepth(children, depth).map((group) => {
168
+ if (group.mark === null) return renderParts(group.children, refs.images);
169
+ const kind = wrapperKindOf(group.mark);
170
+ const body = renderWrapped(group.children, depth + 1, refs);
171
+ return kind.open(group.mark, refs) + body + kind.close(group.mark);
172
+ }).join("");
209
173
  }
210
174
  function serializeParagraph(node, refs = NO_EXPORT_REFS) {
211
175
  const open = openTagXml(wName("p"), rawAttrsOf(node.attrs.pAttrs));
212
176
  const pPr = node.attrs.pPr;
213
- const body = splitParagraphGroups(node, refs).map((group) => renderParagraphGroup(group, refs)).join("");
177
+ const body = renderWrapped(node.children, 0, refs);
214
178
  return open + (typeof pPr === "string" ? pPr : "") + body + "</w:p>";
215
179
  }
216
180
  export {
@@ -8,8 +8,24 @@
8
8
  */
9
9
  import type { Node as PMNode } from "prosemirror-model";
10
10
  import { type PageGeometry } from "./pageGeometry";
11
+ import { type GridRect } from "./tableFormatting";
12
+ /**
13
+ * The limit on how many rows and cells one table can hold.
14
+ * The toolbar grid lets you pick from something smaller (6x6); this only blocks absurd values.
15
+ */
16
+ export declare const MAX_TABLE_SIDE = 50;
11
17
  /** Whether the value can be used as a row count or a cell count */
12
18
  export declare function isTableSide(value: number): boolean;
19
+ /**
20
+ * One cell of a table being built: the block of the grid it covers, and the blocks it holds.
21
+ *
22
+ * A cell reaching across columns or down rows exists once, as the cell it starts at, which is how
23
+ * the model holds a merge and what `serializeTable` writes `w:gridSpan` and `w:vMerge` from.
24
+ */
25
+ export interface TableCellPlan {
26
+ rect: GridRect;
27
+ content?: readonly PMNode[];
28
+ }
13
29
  /**
14
30
  * A single table made up of nothing but empty cells.
15
31
  *
@@ -20,3 +36,12 @@ export declare function isTableSide(value: number): boolean;
20
36
  * The row and cell counts must be values that have passed through `isTableSide`.
21
37
  */
22
38
  export declare function createTableNode(rows: number, cols: number, geometry?: PageGeometry): PMNode;
39
+ /**
40
+ * The same table, laid out and filled by the caller: the shape a table read off the clipboard
41
+ * takes, where the cells are neither all one wide nor all empty.
42
+ *
43
+ * Everything the table itself wears is the same as a newly inserted one's, so a pasted table is
44
+ * drawn and written exactly like a table the toolbar put there. The grid the cells are measured
45
+ * and dressed against is the one they cover between them, row by row.
46
+ */
47
+ export declare function createTableNodeFrom(plan: readonly (readonly TableCellPlan[])[], geometry?: PageGeometry): PMNode;