@lotics/docx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- package/src/serialize/serializer.ts +114 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import type { Command, EditorState, Transaction } from "prosemirror-state";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import {
|
|
4
|
+
EMPTY_PARAGRAPH_PROPERTIES,
|
|
5
|
+
EMPTY_RUN_PROPERTIES,
|
|
6
|
+
type ParagraphProperties,
|
|
7
|
+
} from "../model/properties";
|
|
8
|
+
import {
|
|
9
|
+
resolveParagraphProperties,
|
|
10
|
+
resolveRunProperties,
|
|
11
|
+
} from "../model/style_resolution";
|
|
12
|
+
import type { StyleTable } from "../model/style_table";
|
|
13
|
+
import type {
|
|
14
|
+
Orientation,
|
|
15
|
+
PageMargins,
|
|
16
|
+
PageSize,
|
|
17
|
+
SectionProperties,
|
|
18
|
+
} from "../model/sections";
|
|
19
|
+
|
|
20
|
+
function paragraphRange(state: EditorState): { from: number; to: number } {
|
|
21
|
+
const { from, to } = state.selection;
|
|
22
|
+
const $from = state.doc.resolve(from);
|
|
23
|
+
const startBlock = $from.before($from.depth);
|
|
24
|
+
const $to = state.doc.resolve(to);
|
|
25
|
+
const endBlock = $to.after($to.depth);
|
|
26
|
+
return { from: startBlock, to: endBlock };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function forEachParagraphInRange(
|
|
30
|
+
state: EditorState,
|
|
31
|
+
visit: (node: PMNode, pos: number) => void,
|
|
32
|
+
): void {
|
|
33
|
+
const { from, to } = paragraphRange(state);
|
|
34
|
+
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
35
|
+
if (node.type.name === "paragraph") {
|
|
36
|
+
visit(node, pos);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function patchParagraphProperties(
|
|
44
|
+
tr: Transaction,
|
|
45
|
+
pos: number,
|
|
46
|
+
node: PMNode,
|
|
47
|
+
patch: (current: ParagraphProperties) => ParagraphProperties,
|
|
48
|
+
styleTable: StyleTable | null,
|
|
49
|
+
): void {
|
|
50
|
+
const current =
|
|
51
|
+
(node.attrs.properties as ParagraphProperties | null) ??
|
|
52
|
+
{ ...EMPTY_PARAGRAPH_PROPERTIES };
|
|
53
|
+
const next = patch(current);
|
|
54
|
+
const resolvedProperties = styleTable
|
|
55
|
+
? resolveParagraphProperties(styleTable, next)
|
|
56
|
+
: null;
|
|
57
|
+
const resolvedBaseRun = styleTable
|
|
58
|
+
? resolveRunProperties(styleTable, next.styleId, { ...EMPTY_RUN_PROPERTIES })
|
|
59
|
+
: null;
|
|
60
|
+
tr.setNodeMarkup(pos, undefined, {
|
|
61
|
+
...node.attrs,
|
|
62
|
+
properties: next,
|
|
63
|
+
resolvedProperties,
|
|
64
|
+
resolvedBaseRun,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CommandContext = {
|
|
69
|
+
styleTable?: StyleTable | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function getActiveParagraphProperties(
|
|
73
|
+
state: EditorState,
|
|
74
|
+
): ParagraphProperties | null {
|
|
75
|
+
const $from = state.doc.resolve(state.selection.from);
|
|
76
|
+
for (let depth = $from.depth; depth >= 0; depth--) {
|
|
77
|
+
const node = $from.node(depth);
|
|
78
|
+
if (node.type.name === "paragraph") {
|
|
79
|
+
return (node.attrs.properties as ParagraphProperties | null) ?? null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function setParagraphStyle(
|
|
86
|
+
styleId: string | null,
|
|
87
|
+
ctx: CommandContext = {},
|
|
88
|
+
): Command {
|
|
89
|
+
return (state, dispatch) => {
|
|
90
|
+
let touched = false;
|
|
91
|
+
const tr = state.tr;
|
|
92
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
93
|
+
patchParagraphProperties(
|
|
94
|
+
tr,
|
|
95
|
+
pos,
|
|
96
|
+
node,
|
|
97
|
+
(cur) => ({ ...cur, styleId }),
|
|
98
|
+
ctx.styleTable ?? null,
|
|
99
|
+
);
|
|
100
|
+
touched = true;
|
|
101
|
+
});
|
|
102
|
+
if (!touched) return false;
|
|
103
|
+
if (dispatch) dispatch(tr);
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function applyHeading(
|
|
109
|
+
level: number,
|
|
110
|
+
ctx: CommandContext = {},
|
|
111
|
+
): Command {
|
|
112
|
+
if (level < 1 || level > 6) {
|
|
113
|
+
return () => false;
|
|
114
|
+
}
|
|
115
|
+
return setParagraphStyle(`Heading${level}`, ctx);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearParagraphStyle(ctx: CommandContext = {}): Command {
|
|
119
|
+
return setParagraphStyle(null, ctx);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setParagraphAlignment(
|
|
123
|
+
alignment: ParagraphProperties["alignment"],
|
|
124
|
+
ctx: CommandContext = {},
|
|
125
|
+
): Command {
|
|
126
|
+
return (state, dispatch) => {
|
|
127
|
+
let touched = false;
|
|
128
|
+
const tr = state.tr;
|
|
129
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
130
|
+
patchParagraphProperties(
|
|
131
|
+
tr,
|
|
132
|
+
pos,
|
|
133
|
+
node,
|
|
134
|
+
(cur) => ({ ...cur, alignment }),
|
|
135
|
+
ctx.styleTable ?? null,
|
|
136
|
+
);
|
|
137
|
+
touched = true;
|
|
138
|
+
});
|
|
139
|
+
if (!touched) return false;
|
|
140
|
+
if (dispatch) dispatch(tr);
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function setParagraphNumbering(
|
|
146
|
+
numbering: { numId: number; ilvl: number } | null,
|
|
147
|
+
ctx: CommandContext = {},
|
|
148
|
+
): Command {
|
|
149
|
+
return (state, dispatch) => {
|
|
150
|
+
let touched = false;
|
|
151
|
+
const tr = state.tr;
|
|
152
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
153
|
+
patchParagraphProperties(
|
|
154
|
+
tr,
|
|
155
|
+
pos,
|
|
156
|
+
node,
|
|
157
|
+
(cur) => ({ ...cur, numbering }),
|
|
158
|
+
ctx.styleTable ?? null,
|
|
159
|
+
);
|
|
160
|
+
touched = true;
|
|
161
|
+
});
|
|
162
|
+
if (!touched) return false;
|
|
163
|
+
if (dispatch) dispatch(tr);
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const DEFAULT_BULLET_NUM_ID = 1;
|
|
169
|
+
const DEFAULT_NUMBERED_NUM_ID = 2;
|
|
170
|
+
|
|
171
|
+
export function toggleBulletList(ctx: CommandContext = {}): Command {
|
|
172
|
+
return toggleListImpl(DEFAULT_BULLET_NUM_ID, ctx);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function toggleNumberedList(ctx: CommandContext = {}): Command {
|
|
176
|
+
return toggleListImpl(DEFAULT_NUMBERED_NUM_ID, ctx);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toggleListImpl(numId: number, ctx: CommandContext): Command {
|
|
180
|
+
return (state, dispatch) => {
|
|
181
|
+
let touched = false;
|
|
182
|
+
const tr = state.tr;
|
|
183
|
+
let allInList = true;
|
|
184
|
+
forEachParagraphInRange(state, (node) => {
|
|
185
|
+
const props = node.attrs.properties as ParagraphProperties | null;
|
|
186
|
+
const numbering = props?.numbering ?? null;
|
|
187
|
+
if (!numbering || numbering.numId !== numId) allInList = false;
|
|
188
|
+
});
|
|
189
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
190
|
+
patchParagraphProperties(
|
|
191
|
+
tr,
|
|
192
|
+
pos,
|
|
193
|
+
node,
|
|
194
|
+
(cur) => ({
|
|
195
|
+
...cur,
|
|
196
|
+
numbering: allInList ? null : { numId, ilvl: cur.numbering?.ilvl ?? 0 },
|
|
197
|
+
}),
|
|
198
|
+
ctx.styleTable ?? null,
|
|
199
|
+
);
|
|
200
|
+
touched = true;
|
|
201
|
+
});
|
|
202
|
+
if (!touched) return false;
|
|
203
|
+
if (dispatch) dispatch(tr);
|
|
204
|
+
return true;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function increaseListIndent(ctx: CommandContext = {}): Command {
|
|
209
|
+
return (state, dispatch) => {
|
|
210
|
+
let touched = false;
|
|
211
|
+
const tr = state.tr;
|
|
212
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
213
|
+
const props = node.attrs.properties as ParagraphProperties | null;
|
|
214
|
+
const numbering = props?.numbering;
|
|
215
|
+
if (!numbering) return;
|
|
216
|
+
const nextLevel = Math.min(numbering.ilvl + 1, 8);
|
|
217
|
+
if (nextLevel === numbering.ilvl) return;
|
|
218
|
+
patchParagraphProperties(
|
|
219
|
+
tr,
|
|
220
|
+
pos,
|
|
221
|
+
node,
|
|
222
|
+
(cur) => ({
|
|
223
|
+
...cur,
|
|
224
|
+
numbering: { numId: numbering.numId, ilvl: nextLevel },
|
|
225
|
+
}),
|
|
226
|
+
ctx.styleTable ?? null,
|
|
227
|
+
);
|
|
228
|
+
touched = true;
|
|
229
|
+
});
|
|
230
|
+
if (!touched) return false;
|
|
231
|
+
if (dispatch) dispatch(tr);
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Spacing & indentation commands ───────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
const POINT_TO_TWIPS = 20;
|
|
239
|
+
const TAB_TWIPS = 720; // 0.5"
|
|
240
|
+
|
|
241
|
+
export function setLineSpacing(
|
|
242
|
+
multiplier: number,
|
|
243
|
+
ctx: CommandContext = {},
|
|
244
|
+
): Command {
|
|
245
|
+
return (state, dispatch) => {
|
|
246
|
+
let touched = false;
|
|
247
|
+
const tr = state.tr;
|
|
248
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
249
|
+
patchParagraphProperties(
|
|
250
|
+
tr,
|
|
251
|
+
pos,
|
|
252
|
+
node,
|
|
253
|
+
(cur) => ({
|
|
254
|
+
...cur,
|
|
255
|
+
spacing: {
|
|
256
|
+
before: cur.spacing?.before ?? null,
|
|
257
|
+
after: cur.spacing?.after ?? null,
|
|
258
|
+
line: Math.round(240 * multiplier),
|
|
259
|
+
lineRule: "auto",
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
ctx.styleTable ?? null,
|
|
263
|
+
);
|
|
264
|
+
touched = true;
|
|
265
|
+
});
|
|
266
|
+
if (!touched) return false;
|
|
267
|
+
if (dispatch) dispatch(tr);
|
|
268
|
+
return true;
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function setParagraphSpacing(
|
|
273
|
+
before: number | null,
|
|
274
|
+
after: number | null,
|
|
275
|
+
ctx: CommandContext = {},
|
|
276
|
+
): Command {
|
|
277
|
+
return (state, dispatch) => {
|
|
278
|
+
let touched = false;
|
|
279
|
+
const tr = state.tr;
|
|
280
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
281
|
+
patchParagraphProperties(
|
|
282
|
+
tr,
|
|
283
|
+
pos,
|
|
284
|
+
node,
|
|
285
|
+
(cur) => ({
|
|
286
|
+
...cur,
|
|
287
|
+
spacing: {
|
|
288
|
+
before: before === null ? null : before * POINT_TO_TWIPS,
|
|
289
|
+
after: after === null ? null : after * POINT_TO_TWIPS,
|
|
290
|
+
line: cur.spacing?.line ?? null,
|
|
291
|
+
lineRule: cur.spacing?.lineRule ?? null,
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
ctx.styleTable ?? null,
|
|
295
|
+
);
|
|
296
|
+
touched = true;
|
|
297
|
+
});
|
|
298
|
+
if (!touched) return false;
|
|
299
|
+
if (dispatch) dispatch(tr);
|
|
300
|
+
return true;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function applyIndentDelta(
|
|
305
|
+
delta: number,
|
|
306
|
+
ctx: CommandContext,
|
|
307
|
+
): Command {
|
|
308
|
+
return (state, dispatch) => {
|
|
309
|
+
let touched = false;
|
|
310
|
+
const tr = state.tr;
|
|
311
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
312
|
+
patchParagraphProperties(
|
|
313
|
+
tr,
|
|
314
|
+
pos,
|
|
315
|
+
node,
|
|
316
|
+
(cur) => {
|
|
317
|
+
const baseLeft = cur.indent?.left ?? 0;
|
|
318
|
+
const nextLeft = Math.max(0, baseLeft + delta);
|
|
319
|
+
return {
|
|
320
|
+
...cur,
|
|
321
|
+
indent: {
|
|
322
|
+
left: nextLeft,
|
|
323
|
+
right: cur.indent?.right ?? null,
|
|
324
|
+
firstLine: cur.indent?.firstLine ?? null,
|
|
325
|
+
hanging: cur.indent?.hanging ?? null,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
ctx.styleTable ?? null,
|
|
330
|
+
);
|
|
331
|
+
touched = true;
|
|
332
|
+
});
|
|
333
|
+
if (!touched) return false;
|
|
334
|
+
if (dispatch) dispatch(tr);
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function increaseIndent(ctx: CommandContext = {}): Command {
|
|
340
|
+
return applyIndentDelta(TAB_TWIPS, ctx);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function decreaseIndent(ctx: CommandContext = {}): Command {
|
|
344
|
+
return applyIndentDelta(-TAB_TWIPS, ctx);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function decreaseListIndent(ctx: CommandContext = {}): Command {
|
|
348
|
+
return (state, dispatch) => {
|
|
349
|
+
let touched = false;
|
|
350
|
+
const tr = state.tr;
|
|
351
|
+
forEachParagraphInRange(state, (node, pos) => {
|
|
352
|
+
const props = node.attrs.properties as ParagraphProperties | null;
|
|
353
|
+
const numbering = props?.numbering;
|
|
354
|
+
if (!numbering) return;
|
|
355
|
+
if (numbering.ilvl === 0) return;
|
|
356
|
+
patchParagraphProperties(
|
|
357
|
+
tr,
|
|
358
|
+
pos,
|
|
359
|
+
node,
|
|
360
|
+
(cur) => ({
|
|
361
|
+
...cur,
|
|
362
|
+
numbering: { numId: numbering.numId, ilvl: numbering.ilvl - 1 },
|
|
363
|
+
}),
|
|
364
|
+
ctx.styleTable ?? null,
|
|
365
|
+
);
|
|
366
|
+
touched = true;
|
|
367
|
+
});
|
|
368
|
+
if (!touched) return false;
|
|
369
|
+
if (dispatch) dispatch(tr);
|
|
370
|
+
return true;
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Page layout commands ──────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
function findSectionBreakAfter(
|
|
377
|
+
state: EditorState,
|
|
378
|
+
fromPos: number,
|
|
379
|
+
): { node: PMNode; pos: number } | null {
|
|
380
|
+
let found: { node: PMNode; pos: number } | null = null;
|
|
381
|
+
state.doc.descendants((node, pos) => {
|
|
382
|
+
if (found) return false;
|
|
383
|
+
if (node.type.name !== "section_break") return true;
|
|
384
|
+
if (pos < fromPos) return false;
|
|
385
|
+
found = { node, pos };
|
|
386
|
+
return false;
|
|
387
|
+
});
|
|
388
|
+
if (found) return found;
|
|
389
|
+
state.doc.descendants((node, pos) => {
|
|
390
|
+
if (found) return false;
|
|
391
|
+
if (node.type.name === "section_break") {
|
|
392
|
+
found = { node, pos };
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
});
|
|
396
|
+
return found;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function patchSectionProperties(
|
|
400
|
+
tr: Transaction,
|
|
401
|
+
pos: number,
|
|
402
|
+
node: PMNode,
|
|
403
|
+
patch: (current: SectionProperties) => SectionProperties,
|
|
404
|
+
): void {
|
|
405
|
+
const current = node.attrs.properties as SectionProperties;
|
|
406
|
+
const next = patch(current);
|
|
407
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, properties: next });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function setPageSize(size: PageSize): Command {
|
|
411
|
+
return (state, dispatch) => {
|
|
412
|
+
const target = findSectionBreakAfter(state, state.selection.from);
|
|
413
|
+
if (!target) return false;
|
|
414
|
+
const tr = state.tr;
|
|
415
|
+
patchSectionProperties(tr, target.pos, target.node, (cur) => ({
|
|
416
|
+
...cur,
|
|
417
|
+
pageSize: size,
|
|
418
|
+
}));
|
|
419
|
+
if (dispatch) dispatch(tr);
|
|
420
|
+
return true;
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function setPageMargins(margins: PageMargins): Command {
|
|
425
|
+
return (state, dispatch) => {
|
|
426
|
+
const target = findSectionBreakAfter(state, state.selection.from);
|
|
427
|
+
if (!target) return false;
|
|
428
|
+
const tr = state.tr;
|
|
429
|
+
patchSectionProperties(tr, target.pos, target.node, (cur) => ({
|
|
430
|
+
...cur,
|
|
431
|
+
margins,
|
|
432
|
+
}));
|
|
433
|
+
if (dispatch) dispatch(tr);
|
|
434
|
+
return true;
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── List Enter handling ──────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Enter inside a list paragraph:
|
|
442
|
+
* - if the paragraph is empty, exit the list (clear numbering)
|
|
443
|
+
* - otherwise, split the paragraph keeping the same numbering
|
|
444
|
+
*/
|
|
445
|
+
export function listEnter(ctx: CommandContext = {}): Command {
|
|
446
|
+
return (state, dispatch) => {
|
|
447
|
+
const { selection } = state;
|
|
448
|
+
if (!selection.empty) return false;
|
|
449
|
+
const $from = selection.$from;
|
|
450
|
+
const node = $from.parent;
|
|
451
|
+
if (node.type.name !== "paragraph") return false;
|
|
452
|
+
const props = node.attrs.properties as ParagraphProperties | null;
|
|
453
|
+
const numbering = props?.numbering;
|
|
454
|
+
if (!numbering) return false;
|
|
455
|
+
|
|
456
|
+
if (node.content.size > 0) {
|
|
457
|
+
// Non-empty list item: yield to baseKeymap's Enter so the paragraph
|
|
458
|
+
// splits via the standard machinery (which copies attrs, including
|
|
459
|
+
// numbering, to the new paragraph).
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Empty list item: exit the list by clearing numbering on this paragraph.
|
|
464
|
+
const tr = state.tr;
|
|
465
|
+
patchParagraphProperties(
|
|
466
|
+
tr,
|
|
467
|
+
$from.before($from.depth),
|
|
468
|
+
node,
|
|
469
|
+
(cur) => ({ ...cur, numbering: null }),
|
|
470
|
+
ctx.styleTable ?? null,
|
|
471
|
+
);
|
|
472
|
+
if (dispatch) dispatch(tr);
|
|
473
|
+
return true;
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Generic insertion commands ───────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
export function insertHardBreak(): Command {
|
|
480
|
+
return (state, dispatch) => {
|
|
481
|
+
const node = state.schema.nodes.hard_break.create();
|
|
482
|
+
const tr = state.tr.replaceSelectionWith(node, false);
|
|
483
|
+
if (dispatch) dispatch(tr);
|
|
484
|
+
return true;
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function insertPageBreak(): Command {
|
|
489
|
+
return (state, dispatch) => {
|
|
490
|
+
const para = state.schema.nodes.paragraph.create({
|
|
491
|
+
properties: { ...EMPTY_PARAGRAPH_PROPERTIES, pageBreakBefore: true },
|
|
492
|
+
unknownProperties: [],
|
|
493
|
+
resolvedProperties: null,
|
|
494
|
+
resolvedBaseRun: null,
|
|
495
|
+
numberingLabel: null,
|
|
496
|
+
});
|
|
497
|
+
const tr = state.tr.replaceSelectionWith(para, false);
|
|
498
|
+
if (dispatch) dispatch(tr);
|
|
499
|
+
return true;
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Default tblPr and tblGrid for fresh tables — matches what Word emits for
|
|
504
|
+
// "Insert Table" with the TableGrid style (single 4px borders all around,
|
|
505
|
+
// equal column widths summing to a 6" content area at 1440 dxa/inch).
|
|
506
|
+
function buildDefaultTablePropertiesXml(): import("@lotics/ooxml/xml").XmlElement {
|
|
507
|
+
const border = (tag: string) => ({
|
|
508
|
+
[tag]: [],
|
|
509
|
+
":@": {
|
|
510
|
+
"@_w:val": "single",
|
|
511
|
+
"@_w:sz": "4",
|
|
512
|
+
"@_w:space": "0",
|
|
513
|
+
"@_w:color": "auto",
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
"w:tblPr": [
|
|
518
|
+
{ "w:tblStyle": [], ":@": { "@_w:val": "TableGrid" } },
|
|
519
|
+
{ "w:tblW": [], ":@": { "@_w:w": "0", "@_w:type": "auto" } },
|
|
520
|
+
{
|
|
521
|
+
"w:tblBorders": [
|
|
522
|
+
border("w:top"),
|
|
523
|
+
border("w:left"),
|
|
524
|
+
border("w:bottom"),
|
|
525
|
+
border("w:right"),
|
|
526
|
+
border("w:insideH"),
|
|
527
|
+
border("w:insideV"),
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildDefaultTableGridXml(
|
|
535
|
+
cols: number,
|
|
536
|
+
totalDxa = 8640, // 6"
|
|
537
|
+
): import("@lotics/ooxml/xml").XmlElement {
|
|
538
|
+
const colWidth = Math.floor(totalDxa / Math.max(cols, 1));
|
|
539
|
+
const gridCols = Array.from({ length: cols }, () => ({
|
|
540
|
+
"w:gridCol": [],
|
|
541
|
+
":@": { "@_w:w": String(colWidth) },
|
|
542
|
+
}));
|
|
543
|
+
return { "w:tblGrid": gridCols };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function insertTable(rows: number, cols: number): Command {
|
|
547
|
+
return (state, dispatch) => {
|
|
548
|
+
const { schema } = state;
|
|
549
|
+
const cellNodes = Array.from({ length: cols }, () =>
|
|
550
|
+
schema.nodes.table_cell.create({}, [schema.nodes.paragraph.create()]),
|
|
551
|
+
);
|
|
552
|
+
const rowNodes = Array.from({ length: rows }, () =>
|
|
553
|
+
schema.nodes.table_row.create({}, cellNodes),
|
|
554
|
+
);
|
|
555
|
+
const table = schema.nodes.table.create(
|
|
556
|
+
{
|
|
557
|
+
tableProperties: buildDefaultTablePropertiesXml(),
|
|
558
|
+
columnGrid: buildDefaultTableGridXml(cols),
|
|
559
|
+
},
|
|
560
|
+
rowNodes,
|
|
561
|
+
);
|
|
562
|
+
const tr = state.tr.replaceSelectionWith(table, false);
|
|
563
|
+
if (dispatch) dispatch(tr);
|
|
564
|
+
return true;
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Word's HR shape: a paragraph with <w:pBdr><w:bottom .../></w:pBdr>. The
|
|
569
|
+
// pBdr element is held in unknownProperties so partitionParagraphProperties
|
|
570
|
+
// preserves it through the docx ↔ PM round-trip without needing a typed
|
|
571
|
+
// model field.
|
|
572
|
+
const HR_PBDR_XML = {
|
|
573
|
+
"w:pBdr": [
|
|
574
|
+
{
|
|
575
|
+
"w:bottom": [],
|
|
576
|
+
":@": {
|
|
577
|
+
"@_w:val": "single",
|
|
578
|
+
"@_w:sz": "6",
|
|
579
|
+
"@_w:space": "1",
|
|
580
|
+
"@_w:color": "auto",
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
export function insertHorizontalRule(): Command {
|
|
587
|
+
return (state, dispatch) => {
|
|
588
|
+
const para = state.schema.nodes.paragraph.create({
|
|
589
|
+
properties: { ...EMPTY_PARAGRAPH_PROPERTIES },
|
|
590
|
+
unknownProperties: [HR_PBDR_XML],
|
|
591
|
+
resolvedProperties: null,
|
|
592
|
+
resolvedBaseRun: null,
|
|
593
|
+
numberingLabel: null,
|
|
594
|
+
});
|
|
595
|
+
const tr = state.tr.replaceSelectionWith(para, false);
|
|
596
|
+
if (dispatch) dispatch(tr);
|
|
597
|
+
return true;
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Insert a link into the doc.
|
|
603
|
+
*
|
|
604
|
+
* Currently only **internal anchors** (`#bookmarkName`) round-trip cleanly —
|
|
605
|
+
* external URLs require allocating a relationship in the document's rels
|
|
606
|
+
* map, which there is no plumbing for at the insertion site yet. Passing an
|
|
607
|
+
* external URL is rejected with `false` so the caller can show a useful
|
|
608
|
+
* error rather than silently producing invalid OOXML.
|
|
609
|
+
*/
|
|
610
|
+
export function insertLink(href: string, text?: string): Command {
|
|
611
|
+
return (state, dispatch) => {
|
|
612
|
+
const linkMark = state.schema.marks.link;
|
|
613
|
+
if (!linkMark) return false;
|
|
614
|
+
if (!href.startsWith("#")) {
|
|
615
|
+
// External hyperlinks need a relationship id allocator. Refuse rather
|
|
616
|
+
// than emitting <w:hyperlink w:anchor="https://..."> which is invalid.
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
const anchor = href.slice(1);
|
|
620
|
+
if (anchor.length === 0) return false;
|
|
621
|
+
const mark = linkMark.create({ relationshipId: null, anchor });
|
|
622
|
+
const display = text ?? href;
|
|
623
|
+
const node = state.schema.text(display, [mark]);
|
|
624
|
+
const tr = state.tr.replaceSelectionWith(node, false);
|
|
625
|
+
if (dispatch) dispatch(tr);
|
|
626
|
+
return true;
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Template insertion commands ──────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
function insertTextAtSelection(text: string): Command {
|
|
633
|
+
return (state, dispatch) => {
|
|
634
|
+
const tr = state.tr.replaceSelectionWith(state.schema.text(text), false);
|
|
635
|
+
if (dispatch) dispatch(tr);
|
|
636
|
+
return true;
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function insertVariable(name: string): Command {
|
|
641
|
+
if (!name) return () => false;
|
|
642
|
+
return insertTextAtSelection(`{{${name}}}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function insertLoopVariable(loopVar: string, field: string): Command {
|
|
646
|
+
if (!loopVar || !field) return () => false;
|
|
647
|
+
return insertTextAtSelection(`{{$${loopVar}.${field}}}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function wrapSelectionInLoop(loopVar: string, arrayName: string): Command {
|
|
651
|
+
if (!loopVar || !arrayName) return () => false;
|
|
652
|
+
return (state, dispatch) => {
|
|
653
|
+
const { from, to } = state.selection;
|
|
654
|
+
if (from === to) return false;
|
|
655
|
+
const tr = state.tr;
|
|
656
|
+
const startText = state.schema.text(`{{FOR ${loopVar} IN ${arrayName}}}`);
|
|
657
|
+
const endText = state.schema.text(`{{END-FOR ${arrayName}}}`);
|
|
658
|
+
tr.insert(to, endText);
|
|
659
|
+
tr.insert(from, startText);
|
|
660
|
+
if (dispatch) dispatch(tr);
|
|
661
|
+
return true;
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function wrapSelectionInConditional(condition: string): Command {
|
|
666
|
+
if (!condition) return () => false;
|
|
667
|
+
return (state, dispatch) => {
|
|
668
|
+
const { from, to } = state.selection;
|
|
669
|
+
if (from === to) return false;
|
|
670
|
+
const tr = state.tr;
|
|
671
|
+
tr.insert(to, state.schema.text(`{{END-IF ${condition}}}`));
|
|
672
|
+
tr.insert(from, state.schema.text(`{{IF ${condition}}}`));
|
|
673
|
+
if (dispatch) dispatch(tr);
|
|
674
|
+
return true;
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function setPageOrientation(orientation: Orientation): Command {
|
|
679
|
+
return (state, dispatch) => {
|
|
680
|
+
const target = findSectionBreakAfter(state, state.selection.from);
|
|
681
|
+
if (!target) return false;
|
|
682
|
+
const tr = state.tr;
|
|
683
|
+
patchSectionProperties(tr, target.pos, target.node, (cur) => {
|
|
684
|
+
if (cur.pageSize.orientation === orientation) return cur;
|
|
685
|
+
return {
|
|
686
|
+
...cur,
|
|
687
|
+
pageSize: {
|
|
688
|
+
width: cur.pageSize.height,
|
|
689
|
+
height: cur.pageSize.width,
|
|
690
|
+
orientation,
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
if (dispatch) dispatch(tr);
|
|
695
|
+
return true;
|
|
696
|
+
};
|
|
697
|
+
}
|