@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
@@ -14,9 +14,11 @@ import {
14
14
  unlockAllowed
15
15
  } from "../../schema/locks.js";
16
16
  import { editsShut } from "../../schema/protectionState.js";
17
- function lockedControlMark(id) {
17
+ import { innermostDepth, sharedWrappers } from "../../schema/wrappers.js";
18
+ function lockedControlMark(id, depth) {
18
19
  return docxSchema.marks.sdt.create({
19
20
  sdtPrefix: lockedControlPrefix(id),
21
+ depth,
20
22
  contentsLocked: true,
21
23
  deletionLocked: true
22
24
  });
@@ -45,11 +47,26 @@ function overlaps(a, b) {
45
47
  function reaches(a, b) {
46
48
  return a.from <= b.to && a.to >= b.from;
47
49
  }
50
+ function contains(a, b) {
51
+ return a.from <= b.from && a.to >= b.to;
52
+ }
48
53
  function stretchIn(block, reach) {
49
54
  const from = Math.max(block.start, reach.from);
50
55
  const to = Math.min(block.start + block.node.content.size, reach.to);
51
56
  return from < to ? { from, to } : null;
52
57
  }
58
+ function wrapperDepthOver(block, stretch, replaced = null) {
59
+ const covered = [];
60
+ block.node.forEach((child, offset) => {
61
+ const from = block.start + offset;
62
+ if (from < stretch.to && stretch.from < from + child.nodeSize) {
63
+ covered.push(child);
64
+ }
65
+ });
66
+ const shared = sharedWrappers(covered);
67
+ const at = replaced === null ? -1 : shared.findIndex((wrapper) => wrapper.eq(replaced));
68
+ return innermostDepth(at === -1 ? shared : shared.slice(0, at)) + 1;
69
+ }
53
70
  function gapsIn(stretch, spans) {
54
71
  const gaps = [];
55
72
  let at = stretch.from;
@@ -61,38 +78,56 @@ function gapsIn(stretch, spans) {
61
78
  if (at < stretch.to) gaps.push({ from: at, to: stretch.to });
62
79
  return gaps;
63
80
  }
64
- function takeover(met, stretch, open) {
81
+ function takeover(block, met, stretch, open) {
65
82
  const only = met.length === 1 ? met[0] : null;
66
83
  const prefix = only && prefixOf(only.mark.attrs);
67
84
  if (!only || isLocked(only.mark) || !prefix || !namesNothing(prefix)) {
68
85
  return null;
69
86
  }
70
- const mark = open ? withLock(only.mark, true) : null;
71
- return mark ? {
87
+ const locked = open ? withLock(only.mark, true) : null;
88
+ if (!locked) return null;
89
+ const widened = {
72
90
  from: Math.min(stretch.from, only.from),
73
- to: Math.max(stretch.to, only.to),
74
- mark
75
- } : null;
91
+ to: Math.max(stretch.to, only.to)
92
+ };
93
+ const depth = wrapperDepthOver(block, widened, only.mark);
94
+ return {
95
+ ...widened,
96
+ mark: depth === locked.attrs.depth ? locked : locked.type.create({ ...locked.attrs, depth }),
97
+ replaces: only.mark
98
+ };
99
+ }
100
+ function outermost(spans) {
101
+ const kept = [];
102
+ for (const span of spans) {
103
+ if (!kept.some((other) => overlaps(other, span))) kept.push(span);
104
+ }
105
+ return kept;
76
106
  }
77
107
  function blockLockEdits(state, block, spans, reach) {
78
108
  const stretch = stretchIn(block, reach);
79
109
  if (!stretch) return [];
80
110
  const open = (of) => openStretches(state, of, "mark");
81
- const met = spans.filter((span) => overlaps(span, stretch));
82
- const taken = takeover(met, stretch, open([stretch]).length > 0);
111
+ const met = outermost(spans.filter((span) => overlaps(span, stretch)));
112
+ const taken = takeover(block, met, stretch, open([stretch]).length > 0);
83
113
  if (taken) return [taken];
84
114
  const shut = open(met.filter((span) => !isLocked(span.mark))).flatMap(
85
115
  (span) => {
86
116
  const mark = withLock(span.mark, true);
87
- return mark ? [{ from: span.from, to: span.to, mark }] : [];
117
+ return mark ? [{ from: span.from, to: span.to, mark, replaces: span.mark }] : [];
88
118
  }
89
119
  );
90
120
  const fresh = open(gapsIn(stretch, spans)).map((gap) => ({
91
121
  ...gap,
92
- mark: lockedControlMark(newControlId())
122
+ mark: lockedControlMark(newControlId(), wrapperDepthOver(block, gap))
93
123
  }));
94
124
  return [...shut, ...fresh];
95
125
  }
126
+ function liftable(locked, reach) {
127
+ return locked.filter(
128
+ (span, index) => contains(reach, span) || !locked.slice(index + 1).some((inner) => contains(span, inner))
129
+ );
130
+ }
96
131
  function selectionLockDetail(state) {
97
132
  const selection = state.selection;
98
133
  const locking = !selection.empty && selection instanceof TextSelection;
@@ -106,9 +141,14 @@ function selectionLockDetail(state) {
106
141
  if (!node.isTextblock) return true;
107
142
  const block = { node, start: pos + 1 };
108
143
  const controls = controlSpans(block);
109
- for (const span of controls) {
110
- if (isLocked(span.mark) && reaches(span, reach)) spans.push(span);
111
- }
144
+ spans.push(
145
+ ...liftable(
146
+ controls.filter(
147
+ (span) => isLocked(span.mark) && reaches(span, reach)
148
+ ),
149
+ reach
150
+ )
151
+ );
112
152
  if (locking) {
113
153
  edits.push(...blockLockEdits(state, block, controls, reach));
114
154
  }
@@ -153,7 +193,10 @@ var lockSelection = guardedCommand((state) => {
153
193
  const edits = lockEditsOf(selectionLockDetail(state));
154
194
  if (edits.length === 0) return null;
155
195
  const tr = state.tr;
156
- for (const edit of edits) tr.addMark(edit.from, edit.to, edit.mark);
196
+ for (const edit of edits) {
197
+ if (edit.replaces) tr.removeMark(edit.from, edit.to, edit.replaces);
198
+ tr.addMark(edit.from, edit.to, edit.mark);
199
+ }
157
200
  return tr;
158
201
  });
159
202
  function unlockCell(tr, cell) {
@@ -171,8 +214,8 @@ var unlockSelection = guardedCommand((state) => {
171
214
  const tr = state.tr.setMeta(unlockAllowed, true);
172
215
  for (const span of spans) {
173
216
  const opened = withLock(span.mark, false);
217
+ tr.removeMark(span.from, span.to, span.mark);
174
218
  if (opened) tr.addMark(span.from, span.to, opened);
175
- else tr.removeMark(span.from, span.to, span.mark);
176
219
  }
177
220
  for (const cell of cells) unlockCell(tr, cell);
178
221
  return tr;
@@ -2,14 +2,14 @@
2
2
  import {
3
3
  paragraphAttrsOf,
4
4
  resolveParagraph,
5
- runMarkUnder,
6
- styleIdOf
5
+ runMarkUnder
7
6
  } from "../../docx/formatting.js";
8
7
  import {
9
8
  withParagraphAlign,
10
9
  withParagraphStyle
11
10
  } from "../../docx/paraProps.js";
12
11
  import { toParagraphFormat } from "../../model/format.js";
12
+ import { styleIdOf } from "../../ooxml/props.js";
13
13
  import { docxSchema } from "../../schema/index.js";
14
14
  import { lockedMarkOf } from "../../schema/locks.js";
15
15
  import { documentFormatting } from "../documentStyles.js";
@@ -26,6 +26,7 @@ import { imageFiles } from "./imageFiles.js";
26
26
  import { columnResize } from "./plugins/columnResize.js";
27
27
  import { commentComposer } from "./plugins/commentComposer.js";
28
28
  import { commentDecorations } from "./plugins/commentDecorations.js";
29
+ import { commentRestoration } from "./plugins/commentRestoration.js";
29
30
  import {
30
31
  displayDerivation,
31
32
  withDerivedDisplay
@@ -96,6 +97,9 @@ function createEditorState(doc, options = {}) {
96
97
  // The list beside the page, the lookup the comment commands ask, and these ranges are the
97
98
  // one walk this plugin holds (`plugins/commentDecorations`)
98
99
  commentDecorations(),
100
+ // A comment outlives the text it was written for: an edit that sweeps its reference away has
101
+ // it put back where the deletion left, detached from the page (`plugins/commentRestoration`)
102
+ commentRestoration(),
99
103
  // What the notes under the page are, worked out from the document the same way
100
104
  noteProjection.plugin,
101
105
  // Adjacent text tabs still need separate DOM ranges for layout and pointer selection.
@@ -34,7 +34,7 @@ function commentsIn(doc) {
34
34
  const id = stringAttr(node.attrs.id) ?? "";
35
35
  const start = markers.starts.get(id);
36
36
  const end = markers.ends.get(id);
37
- const hasRange = start !== void 0 && end !== void 0 && start < end;
37
+ const anchored = start !== void 0 && end !== void 0 && start < end;
38
38
  const point = start ?? end ?? pos;
39
39
  return {
40
40
  id,
@@ -43,9 +43,10 @@ function commentsIn(doc) {
43
43
  initials: stringAttr(node.attrs.initials),
44
44
  date: stringAttr(node.attrs.date),
45
45
  text: bodyText(doc, id),
46
- from: hasRange ? start + 1 : point,
47
- to: hasRange ? end : point,
46
+ from: anchored ? start + 1 : point,
47
+ to: anchored ? end : point,
48
48
  referencePos: pos,
49
+ anchored,
49
50
  resolved: node.attrs.resolved === true,
50
51
  replies: repliesAttr(node.attrs.replies).map((reply) => ({
51
52
  id: reply.id,
@@ -69,7 +70,7 @@ function deriveComments(doc) {
69
70
  byId,
70
71
  decorations: DecorationSet.create(
71
72
  doc,
72
- comments.filter((comment) => comment.from < comment.to).map(
73
+ comments.filter((comment) => !comment.resolved && comment.from < comment.to).map(
73
74
  (comment) => Decoration.inline(comment.from, comment.to, {
74
75
  class: editorClassNames.commentRange,
75
76
  "data-comment-id": comment.id
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Keeps a comment when the text it was written for goes away.
3
+ *
4
+ * Deleting commented text is an edit of the body, not a decision about the thread, so a body edit
5
+ * that takes a comment's nodes away has them put back at the spots the edit left: the thread stays
6
+ * anchored where one range marker survived, detached where both went (`DocumentComment.anchored`).
7
+ *
8
+ * The comment's story (`docx/story`) tells such an edit from `removeComment`: a body deletion does
9
+ * not reach the story, so a comment is put back while its story stands, which only `removeComment`
10
+ * takes away. The cost is that a reference the comments part held no body for is never put back.
11
+ *
12
+ * A restoration is an ordinary transaction the guards judge (`editor/plugins/lockedContent`): the
13
+ * nodes take the nearest spot left open, and a document leaving none loses the comment. A range
14
+ * marker with no home takes its surviving counterpart down, so no export carries half a range.
15
+ *
16
+ * A restoration needs no deferral around an open IME composition: the nodes go in beside the
17
+ * composed text rather than rewriting it (`e2e/hangulComposition.spec.ts`).
18
+ */
19
+ import { Plugin } from "prosemirror-state";
20
+ export declare function commentRestoration(): Plugin;
@@ -0,0 +1,168 @@
1
+ // src/editor/plugins/commentRestoration.ts
2
+ import {
3
+ Plugin,
4
+ TextSelection
5
+ } from "prosemirror-state";
6
+ import { storyKey, storyOf } from "../../docx/story.js";
7
+ import { docxSchema } from "../../schema/index.js";
8
+ import { editShut, transactionTouchesComments } from "../../schema/guards.js";
9
+ import { stringAttr } from "../commands/comments/model.js";
10
+ var NODE_FIELD = {
11
+ commentStart: "start",
12
+ commentEnd: "end",
13
+ commentReference: "reference"
14
+ };
15
+ var NODE_FIELDS = ["start", "end", "reference"];
16
+ var MAPPING_SIDE = {
17
+ start: 1,
18
+ end: -1,
19
+ reference: -1
20
+ };
21
+ var NO_NODES = { start: null, end: null, reference: null };
22
+ function commentNodesIn(doc) {
23
+ const found = /* @__PURE__ */ new Map();
24
+ doc.descendants((node, pos) => {
25
+ const field = NODE_FIELD[node.type.name];
26
+ if (field === void 0) return true;
27
+ const id = stringAttr(node.attrs.id);
28
+ if (id === null) return true;
29
+ const nodes = found.get(id) ?? { ...NO_NODES };
30
+ found.set(id, nodes);
31
+ if (nodes[field] === null) nodes[field] = { node, pos };
32
+ return true;
33
+ });
34
+ return found;
35
+ }
36
+ function inlineRanges(doc) {
37
+ const type = docxSchema.nodes.commentReference;
38
+ const ranges = [];
39
+ doc.descendants((node, pos) => {
40
+ if (!node.isTextblock) return true;
41
+ if (node.type.contentMatch.matchType(type) !== null) {
42
+ ranges.push({ from: pos + 1, to: pos + 1 + node.content.size });
43
+ }
44
+ return false;
45
+ });
46
+ return ranges;
47
+ }
48
+ function within(ranges, pos) {
49
+ return ranges.some((range) => pos >= range.from && pos <= range.to);
50
+ }
51
+ function* homes(ranges, wanted, caret) {
52
+ if (within(ranges, wanted)) yield wanted;
53
+ if (caret !== wanted && within(ranges, caret)) yield caret;
54
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
55
+ const range = ranges[index];
56
+ if (range !== void 0 && range.to < wanted) yield range.to;
57
+ }
58
+ for (const range of ranges) {
59
+ if (range.from > wanted) yield range.from;
60
+ }
61
+ }
62
+ function allowedHome(state, ranges, wanted, caret) {
63
+ for (const at of homes(ranges, wanted, caret)) {
64
+ if (!editShut(state, { kind: "insert", at })) return at;
65
+ }
66
+ return null;
67
+ }
68
+ function mappedThrough(transactions, pos, side) {
69
+ return transactions.reduce((at, tr) => tr.mapping.map(at, side), pos);
70
+ }
71
+ function restorable(node) {
72
+ return node.type.create(
73
+ node.attrs,
74
+ null,
75
+ node.marks.filter((mark) => mark.type === docxSchema.marks.run)
76
+ );
77
+ }
78
+ function planRestoration(transactions, oldState, newState) {
79
+ const before = commentNodesIn(oldState.doc);
80
+ const after = commentNodesIn(newState.doc);
81
+ const ranges = inlineRanges(newState.doc);
82
+ const caret = newState.selection.from;
83
+ const plan = { insertions: [], removals: [] };
84
+ for (const [id, was] of before) {
85
+ if (storyOf(newState.doc, storyKey("comment", id)) === null) continue;
86
+ const now = after.get(id) ?? NO_NODES;
87
+ for (const field of NODE_FIELDS) {
88
+ const lost = was[field];
89
+ if (lost === null || now[field] !== null) continue;
90
+ const counterpart = field === "start" ? now.end : field === "end" ? now.start : null;
91
+ if (field !== "reference" && counterpart === null) continue;
92
+ const at = allowedHome(
93
+ newState,
94
+ ranges,
95
+ mappedThrough(transactions, lost.pos, MAPPING_SIDE[field]),
96
+ caret
97
+ );
98
+ if (at !== null) {
99
+ plan.insertions.push({
100
+ node: restorable(lost.node),
101
+ at,
102
+ was: lost.pos
103
+ });
104
+ } else if (counterpart !== null && !markerShut(newState, counterpart)) {
105
+ plan.removals.push(counterpart);
106
+ }
107
+ }
108
+ }
109
+ return plan;
110
+ }
111
+ function markerShut(state, marker) {
112
+ return editShut(state, {
113
+ kind: "replace",
114
+ from: marker.pos,
115
+ to: marker.pos + marker.node.nodeSize
116
+ });
117
+ }
118
+ function pastCommentNodes(doc, pos, direction) {
119
+ let at = pos;
120
+ for (; ; ) {
121
+ const resolved = doc.resolve(at);
122
+ const next = direction < 0 ? resolved.nodeBefore : resolved.nodeAfter;
123
+ if (next === null || NODE_FIELD[next.type.name] === void 0) return at;
124
+ at += direction * next.nodeSize;
125
+ }
126
+ }
127
+ function withCaretPastMarkers(tr, oldState, newState) {
128
+ const { selection } = newState;
129
+ if (!(selection instanceof TextSelection) || !selection.empty) return tr;
130
+ if (!tr.doc.eq(oldState.doc)) return tr;
131
+ const direction = selection.from < oldState.selection.from ? -1 : 1;
132
+ return tr.setSelection(
133
+ TextSelection.create(
134
+ tr.doc,
135
+ pastCommentNodes(tr.doc, tr.selection.from, direction)
136
+ )
137
+ );
138
+ }
139
+ function restoration(transactions, oldState, newState) {
140
+ const plan = planRestoration(transactions, oldState, newState);
141
+ if (plan.insertions.length === 0 && plan.removals.length === 0) return null;
142
+ const tr = newState.tr;
143
+ for (const marker of [...plan.removals].sort(
144
+ (left, right) => right.pos - left.pos
145
+ )) {
146
+ tr.delete(marker.pos, marker.pos + marker.node.nodeSize);
147
+ }
148
+ const ordered = [...plan.insertions].sort(
149
+ (left, right) => left.at - right.at || left.was - right.was
150
+ );
151
+ for (const { node, at } of ordered) {
152
+ tr.insert(tr.mapping.map(at), node);
153
+ }
154
+ if (!tr.docChanged) return null;
155
+ return withCaretPastMarkers(tr, oldState, newState);
156
+ }
157
+ function commentRestoration() {
158
+ return new Plugin({
159
+ appendTransaction(transactions, oldState, newState) {
160
+ if (!transactions.some((tr) => tr.docChanged)) return null;
161
+ if (!transactions.some(transactionTouchesComments)) return null;
162
+ return restoration(transactions, oldState, newState);
163
+ }
164
+ });
165
+ }
166
+ export {
167
+ commentRestoration
168
+ };
@@ -118,3 +118,5 @@ export declare function editChild(props: Props, path: readonly [string, ...strin
118
118
  * null if its shape cannot be made out, in which case the caller leaves the display values alone.
119
119
  */
120
120
  export declare function parsePropsXml(xml: string): Element | null;
121
+ /** The style name the paragraph formatting XML points at. null if it points at none */
122
+ export declare function styleIdOf(pPr: unknown): string | null;
@@ -3,6 +3,7 @@ import { childOrderOf } from "./childOrder.js";
3
3
  import { attrsText, emptyTagXml } from "./element.js";
4
4
  import { wName } from "./names.js";
5
5
  import { parseAttrs, readTag } from "./tagScan.js";
6
+ import { childValue } from "./units.js";
6
7
  import { elementChildren, localPart, namespaceDecls, parseXml } from "./xml.js";
7
8
  function rawAttrs(source, tag) {
8
9
  const closeLength = tag.kind === "empty" ? 2 : 1;
@@ -207,6 +208,18 @@ function parsePropsXml(xml) {
207
208
  return null;
208
209
  }
209
210
  }
211
+ var styleIdsByPPr = /* @__PURE__ */ new Map();
212
+ var STYLE_ID_CACHE_LIMIT = 2e3;
213
+ function styleIdOf(pPr) {
214
+ if (typeof pPr !== "string" || !pPr.includes("pStyle")) return null;
215
+ const known = styleIdsByPPr.get(pPr);
216
+ if (known !== void 0) return known;
217
+ const el = parsePropsXml(pPr);
218
+ const id = el ? childValue(el, "pStyle") : null;
219
+ if (styleIdsByPPr.size >= STYLE_ID_CACHE_LIMIT) styleIdsByPPr.clear();
220
+ styleIdsByPPr.set(pPr, id);
221
+ return id;
222
+ }
210
223
  export {
211
224
  attrsOf,
212
225
  childElement,
@@ -220,5 +233,6 @@ export {
220
233
  renderProps,
221
234
  setChild,
222
235
  setChildren,
236
+ styleIdOf,
223
237
  withAttrs
224
238
  };
@@ -124,6 +124,12 @@ var NODE_ATTR_ROLES = {
124
124
  guarded: { role: "session", class: "derived" }
125
125
  }
126
126
  };
127
+ var WRAPPER_ATTR_ROLES = {
128
+ // Which wrapper of its kind this is, counted through the document as it was opened
129
+ key: { role: "session", class: "identity" },
130
+ // How deep inside the other wrappers this one stood in the file
131
+ depth: { role: "session", class: "identity" }
132
+ };
127
133
  var MARK_ATTR_ROLES = {
128
134
  run: {
129
135
  rPr: { role: "source", class: "preserved" },
@@ -132,15 +138,14 @@ var MARK_ATTR_ROLES = {
132
138
  },
133
139
  sdt: {
134
140
  sdtPrefix: { role: "source", class: "preserved" },
135
- // Counted through the document as it was opened, to tell one control from the next
136
- sdtKey: { role: "session", class: "identity" },
141
+ ...WRAPPER_ATTR_ROLES,
137
142
  contentsLocked: { role: "source", class: "derived" },
138
143
  deletionLocked: { role: "source", class: "derived" }
139
144
  },
140
145
  link: {
141
146
  linkPrefix: { role: "source", class: "preserved" },
142
147
  href: { role: "source", class: "model" },
143
- linkKey: { role: "session", class: "identity" }
148
+ ...WRAPPER_ATTR_ROLES
144
149
  },
145
150
  tab: { tabAttrs: { role: "source", class: "preserved" } }
146
151
  };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * What each node and mark looks like once it has left the editor.
3
+ *
4
+ * A node draws itself twice. `toDOM` draws the page, where the editor reads its own attributes
5
+ * back (an IME composition, a browser edit), so everything the document is written from stands on
6
+ * the element. A copy lands in Word, in a spreadsheet, in a mail: none of them can read a `w:pPr`,
7
+ * and none of them should be handed a comment's author or the body of a footnote. So the shape a
8
+ * copy takes is declared here, beside the schema, rather than worked out afterwards by taking the
9
+ * page's drawing apart.
10
+ *
11
+ * The two cannot drift apart unnoticed: `Readonly<Record<DocxNodeName, ...>>` refuses to compile
12
+ * until a node added to the schema says what it leaves as, and `clipboard.test.ts` asks the schema
13
+ * itself the same question at runtime.
14
+ */
15
+ import { type DOMOutputSpec, DOMSerializer, type Mark, type Node as PMNode, type Schema, type Slice } from "prosemirror-model";
16
+ import { type docxSchema } from "./docxSchema";
17
+ /**
18
+ * The paragraph style a copy carries, which is the one thing a paste needs that the drawing does
19
+ * not already say. It is the style's id alone, where the editor draws the whole `w:pPr`.
20
+ */
21
+ export declare const COPIED_STYLE_ATTRIBUTE = "data-style";
22
+ /**
23
+ * The address a link may carry, whichever way it travels.
24
+ *
25
+ * A copy writes one out and a paste reads one in, and an address this rule turns down is one
26
+ * neither end should act on, so both ask here.
27
+ */
28
+ export declare function safeHref(value: string | null): string | null;
29
+ export interface ClipboardNodeSpec {
30
+ /**
31
+ * The HTML a copy carries. `null` in place of a function says this kind of node never leaves the
32
+ * editor; a function answering null says this one leaves nothing. Either way neither the node
33
+ * nor anything inside it is written.
34
+ */
35
+ toClipboardDOM: ((node: PMNode) => DOMOutputSpec | null) | null;
36
+ /**
37
+ * The plain text a copy carries, given what its children said. A null answer leaves the node out
38
+ * of the text entirely, separator and all, so a block standing for something invisible does not
39
+ * open a blank line where it stood.
40
+ */
41
+ toClipboardText: ((node: PMNode, children: readonly string[]) => string | null) | null;
42
+ }
43
+ export interface ClipboardMarkSpec {
44
+ /** null writes no wrapper, and the content it covered goes out on its own */
45
+ toClipboardDOM: ((mark: Mark) => DOMOutputSpec) | null;
46
+ }
47
+ /**
48
+ * `Schema.nodes` is indexed by `string` as well as by the names it was built from, so `keyof` over
49
+ * it widens to `string`. The generic argument the schema literal was inferred with keeps them.
50
+ */
51
+ export type DocxNodeName = typeof docxSchema extends Schema<infer N, string> ? N : never;
52
+ export type DocxMarkName = typeof docxSchema extends Schema<string, infer M> ? M : never;
53
+ export declare const clipboardSpecs: {
54
+ nodes: Readonly<Record<DocxNodeName, ClipboardNodeSpec>>;
55
+ marks: Readonly<Record<DocxMarkName, ClipboardMarkSpec>>;
56
+ };
57
+ /**
58
+ * The serializer a copy is written with.
59
+ *
60
+ * `stampCopy` is handed the whole copy once it stands, and only once: `DOMSerializer` draws a
61
+ * node's children by calling `serializeFragment` again with the element it drew as the target, so
62
+ * a call carrying one is inside the copy rather than around it.
63
+ */
64
+ export declare function clipboardSerializer(stampCopy?: (copy: HTMLElement | DocumentFragment) => void): DOMSerializer;
65
+ /**
66
+ * The plain text a copy leaves beside the HTML.
67
+ *
68
+ * ProseMirror's own answer is the text content with a line between blocks, which loses a tab, a
69
+ * page break, and the difference between the next cell and the next row. Somewhere those are the
70
+ * whole of what was copied, a table pasted into a spreadsheet above all.
71
+ */
72
+ export declare function clipboardText(slice: Slice): string;