@beyondwork/docx-react-component 1.0.18 → 1.0.20
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/README.md +8 -2
- package/package.json +24 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +710 -4
- package/src/api/session-state.ts +60 -0
- package/src/core/commands/formatting-commands.ts +2 -1
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +19 -3
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +357 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +4 -1
- package/src/index.ts +51 -0
- package/src/io/docx-session.ts +623 -56
- package/src/io/export/serialize-comments.ts +104 -34
- package/src/io/export/serialize-footnotes.ts +198 -1
- package/src/io/export/serialize-headers-footers.ts +203 -10
- package/src/io/export/serialize-main-document.ts +285 -8
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/export/split-review-boundaries.ts +181 -19
- package/src/io/normalize/normalize-text.ts +144 -32
- package/src/io/ooxml/highlight-colors.ts +39 -0
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-comments.ts +85 -19
- package/src/io/ooxml/parse-fields.ts +396 -0
- package/src/io/ooxml/parse-footnotes.ts +452 -22
- package/src/io/ooxml/parse-headers-footers.ts +657 -29
- package/src/io/ooxml/parse-inline-media.ts +30 -0
- package/src/io/ooxml/parse-main-document.ts +807 -20
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-revisions.ts +317 -38
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/legal/bookmarks.ts +44 -0
- package/src/legal/cross-references.ts +59 -1
- package/src/model/canonical-document.ts +250 -4
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +87 -2
- package/src/review/store/revision-store.ts +6 -0
- package/src/review/store/revision-types.ts +1 -0
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +603 -0
- package/src/runtime/document-runtime.ts +1754 -78
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
- package/src/runtime/session-capabilities.ts +35 -3
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +324 -36
- package/src/runtime/table-schema.ts +89 -7
- package/src/runtime/view-state.ts +477 -0
- package/src/runtime/workflow-markup.ts +349 -0
- package/src/ui/WordReviewEditor.tsx +2469 -1344
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/editor-command-bag.ts +120 -0
- package/src/ui/editor-runtime-boundary.ts +1422 -0
- package/src/ui/editor-shell-view.tsx +134 -0
- package/src/ui/editor-surface-controller.tsx +51 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/revision-decoration-model.ts +4 -4
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui/runtime-snapshot-selectors.ts +197 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/page-chrome-model.ts +27 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
- package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
- package/src/ui-tailwind/theme/editor-theme.css +127 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
- package/src/validation/compatibility-engine.ts +119 -24
- package/src/validation/compatibility-report.ts +1 -0
- package/src/validation/diagnostics.ts +1 -0
- package/src/validation/docx-comment-proof.ts +707 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditorSessionState,
|
|
3
|
+
PersistedEditorSnapshot,
|
|
4
|
+
} from "./public-types.ts";
|
|
5
|
+
|
|
6
|
+
export const EDITOR_SESSION_STATE_VERSION = "editor-session-state/1" as const;
|
|
7
|
+
|
|
8
|
+
function cloneStructuredValue<T>(value: T): T {
|
|
9
|
+
return structuredClone(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createEditorSessionState(
|
|
13
|
+
input: Omit<EditorSessionState, "sessionVersion">,
|
|
14
|
+
): EditorSessionState {
|
|
15
|
+
return cloneStructuredValue({
|
|
16
|
+
sessionVersion: EDITOR_SESSION_STATE_VERSION,
|
|
17
|
+
...input,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function editorSessionStateFromPersistedSnapshot(
|
|
22
|
+
snapshot: PersistedEditorSnapshot,
|
|
23
|
+
): EditorSessionState {
|
|
24
|
+
return createEditorSessionState({
|
|
25
|
+
schemaVersion: snapshot.schemaVersion,
|
|
26
|
+
documentId: snapshot.documentId,
|
|
27
|
+
docId: snapshot.docId,
|
|
28
|
+
createdAt: snapshot.createdAt,
|
|
29
|
+
updatedAt: snapshot.updatedAt,
|
|
30
|
+
editorBuild: snapshot.editorBuild,
|
|
31
|
+
canonicalDocument: snapshot.canonicalDocument,
|
|
32
|
+
compatibility: snapshot.compatibility,
|
|
33
|
+
warningLog: snapshot.warningLog,
|
|
34
|
+
protectionSnapshot: snapshot.protectionSnapshot,
|
|
35
|
+
sourcePackage: snapshot.sourcePackage,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function persistedSnapshotFromEditorSessionState(
|
|
40
|
+
sessionState: EditorSessionState,
|
|
41
|
+
options: {
|
|
42
|
+
savedAt: string;
|
|
43
|
+
},
|
|
44
|
+
): PersistedEditorSnapshot {
|
|
45
|
+
return cloneStructuredValue({
|
|
46
|
+
snapshotVersion: "persisted-editor-snapshot/2",
|
|
47
|
+
schemaVersion: sessionState.schemaVersion,
|
|
48
|
+
documentId: sessionState.documentId,
|
|
49
|
+
docId: sessionState.docId,
|
|
50
|
+
createdAt: sessionState.createdAt,
|
|
51
|
+
updatedAt: sessionState.updatedAt,
|
|
52
|
+
savedAt: options.savedAt,
|
|
53
|
+
editorBuild: sessionState.editorBuild,
|
|
54
|
+
canonicalDocument: sessionState.canonicalDocument,
|
|
55
|
+
compatibility: sessionState.compatibility,
|
|
56
|
+
warningLog: sessionState.warningLog,
|
|
57
|
+
protectionSnapshot: sessionState.protectionSnapshot,
|
|
58
|
+
sourcePackage: sessionState.sourcePackage,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -271,6 +271,7 @@ export function getFormattingStateFromRenderSnapshot(
|
|
|
271
271
|
alignment: getConsistentValue(paragraphs, (paragraph) =>
|
|
272
272
|
toPublicAlignment(paragraph.alignment),
|
|
273
273
|
),
|
|
274
|
+
paragraphStyleId: getConsistentValue(paragraphs, (paragraph) => paragraph.styleId),
|
|
274
275
|
breadcrumb: getSelectionBreadcrumb(surface.blocks, snapshot.selection),
|
|
275
276
|
};
|
|
276
277
|
}
|
|
@@ -972,7 +973,7 @@ function setMarkValue(
|
|
|
972
973
|
): TextMark[] | undefined {
|
|
973
974
|
const nextMarks = cloneMarks(marks).filter((candidate) => {
|
|
974
975
|
if (markType === "backgroundColor") {
|
|
975
|
-
return candidate.type !== "backgroundColor";
|
|
976
|
+
return candidate.type !== "backgroundColor" && candidate.type !== "highlight";
|
|
976
977
|
}
|
|
977
978
|
return candidate.type !== markType;
|
|
978
979
|
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
export interface ImageCommandContext {
|
|
18
18
|
timestamp: string;
|
|
19
19
|
altText?: string;
|
|
20
|
+
display?: "inline" | "floating";
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface InsertImageResult {
|
|
@@ -158,6 +159,7 @@ function insertImageIntoParagraph(
|
|
|
158
159
|
filename: `${mediaId.replace("media:", "")}.${ext}`,
|
|
159
160
|
packagePartName,
|
|
160
161
|
...(context.altText ? { altText: context.altText } : {}),
|
|
162
|
+
...(context.display ? { display: context.display } : {}),
|
|
161
163
|
};
|
|
162
164
|
|
|
163
165
|
const existingItems =
|
|
@@ -205,6 +207,151 @@ function insertImageIntoParagraph(
|
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Bounded geometry commands — export-safe, command-backed
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** EMU-based image dimensions. 1 inch = 914400 EMU. */
|
|
215
|
+
export interface ImageDimensions {
|
|
216
|
+
widthEmu: number;
|
|
217
|
+
heightEmu: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Bounded resize constraints. */
|
|
221
|
+
const MIN_DIMENSION_EMU = 91440; // ~0.1 inch
|
|
222
|
+
const MAX_DIMENSION_EMU = 27432000; // ~30 inches — Word practical max
|
|
223
|
+
|
|
224
|
+
function clampDimension(value: number): number {
|
|
225
|
+
return Math.max(MIN_DIMENSION_EMU, Math.min(MAX_DIMENSION_EMU, Math.round(value)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ResizeImageResult {
|
|
229
|
+
document: CanonicalDocumentEnvelope;
|
|
230
|
+
dimensions: ImageDimensions;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resize an image in the media catalog by media ID.
|
|
235
|
+
*
|
|
236
|
+
* Bounded: dimensions are clamped to [0.1in, 30in] and stored on the
|
|
237
|
+
* MediaItem so they survive export round-trip. The ImageNode in the story
|
|
238
|
+
* is not mutated — placement geometry lives on the media item and the
|
|
239
|
+
* preserved `placementXml` is updated if present.
|
|
240
|
+
*/
|
|
241
|
+
export function resizeImage(
|
|
242
|
+
document: CanonicalDocumentEnvelope,
|
|
243
|
+
mediaId: string,
|
|
244
|
+
dimensions: ImageDimensions,
|
|
245
|
+
timestamp: string = new Date().toISOString(),
|
|
246
|
+
): ResizeImageResult {
|
|
247
|
+
const media = document.media as { items: Record<string, MediaItem> } | undefined;
|
|
248
|
+
if (!media?.items[mediaId]) {
|
|
249
|
+
throw new TextTransactionError(
|
|
250
|
+
"invalid_selection",
|
|
251
|
+
`Media item ${mediaId} does not exist in the catalog.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const clamped: ImageDimensions = {
|
|
256
|
+
widthEmu: clampDimension(dimensions.widthEmu),
|
|
257
|
+
heightEmu: clampDimension(dimensions.heightEmu),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const updatedItem: MediaItem = {
|
|
261
|
+
...media.items[mediaId],
|
|
262
|
+
widthEmu: clamped.widthEmu,
|
|
263
|
+
heightEmu: clamped.heightEmu,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
document: {
|
|
268
|
+
...document,
|
|
269
|
+
updatedAt: timestamp,
|
|
270
|
+
media: {
|
|
271
|
+
items: {
|
|
272
|
+
...media.items,
|
|
273
|
+
[mediaId]: updatedItem,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
dimensions: clamped,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface RepositionImageResult {
|
|
282
|
+
document: CanonicalDocumentEnvelope;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Reposition a floating image by updating its floating axis offsets.
|
|
287
|
+
*
|
|
288
|
+
* Only applies to images with `display: "floating"`. The offset values are
|
|
289
|
+
* EMU-based and stored on the canonical ImageNode's `floating` property so
|
|
290
|
+
* they survive export. Inline images cannot be repositioned — callers must
|
|
291
|
+
* first change the display mode through a separate command.
|
|
292
|
+
*/
|
|
293
|
+
export function repositionFloatingImage(
|
|
294
|
+
document: CanonicalDocumentEnvelope,
|
|
295
|
+
mediaId: string,
|
|
296
|
+
offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
|
|
297
|
+
timestamp: string = new Date().toISOString(),
|
|
298
|
+
): RepositionImageResult {
|
|
299
|
+
const blocks = document.content.children;
|
|
300
|
+
let found = false;
|
|
301
|
+
const nextBlocks = blocks.map((block) => {
|
|
302
|
+
if (block.type !== "paragraph") return block;
|
|
303
|
+
const nextChildren = block.children.map((child) => {
|
|
304
|
+
if (child.type !== "image" || child.mediaId !== mediaId) return child;
|
|
305
|
+
if (child.display !== "floating") {
|
|
306
|
+
throw new TextTransactionError(
|
|
307
|
+
"unsupported_content",
|
|
308
|
+
`Image ${mediaId} is not floating — reposition requires display: "floating".`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
found = true;
|
|
312
|
+
const prevFloating = child.floating ?? {};
|
|
313
|
+
return {
|
|
314
|
+
...child,
|
|
315
|
+
floating: {
|
|
316
|
+
...prevFloating,
|
|
317
|
+
...(offsets.horizontalOffsetEmu !== undefined
|
|
318
|
+
? {
|
|
319
|
+
horizontalPosition: {
|
|
320
|
+
...prevFloating.horizontalPosition,
|
|
321
|
+
offset: offsets.horizontalOffsetEmu,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
: {}),
|
|
325
|
+
...(offsets.verticalOffsetEmu !== undefined
|
|
326
|
+
? {
|
|
327
|
+
verticalPosition: {
|
|
328
|
+
...prevFloating.verticalPosition,
|
|
329
|
+
offset: offsets.verticalOffsetEmu,
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
: {}),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
return { ...block, children: nextChildren };
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (!found) {
|
|
340
|
+
throw new TextTransactionError(
|
|
341
|
+
"invalid_selection",
|
|
342
|
+
`No floating image with mediaId ${mediaId} found in document content.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
document: {
|
|
348
|
+
...document,
|
|
349
|
+
updatedAt: timestamp,
|
|
350
|
+
content: { ...document.content, children: nextBlocks },
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
208
355
|
function mimeTypeToExtension(mimeType: string): string {
|
|
209
356
|
switch (mimeType.toLowerCase()) {
|
|
210
357
|
case "image/png":
|
|
@@ -60,6 +60,7 @@ export type EditorCommand =
|
|
|
60
60
|
document: CanonicalDocumentEnvelope;
|
|
61
61
|
mapping?: TransactionMapping;
|
|
62
62
|
selection?: SelectionSnapshot;
|
|
63
|
+
protectionSelection?: SelectionSnapshot;
|
|
63
64
|
origin?: CommandOrigin;
|
|
64
65
|
}
|
|
65
66
|
| {
|
|
@@ -110,6 +111,7 @@ export type EditorCommand =
|
|
|
110
111
|
| {
|
|
111
112
|
type: "comment.add";
|
|
112
113
|
comment: CommentThreadRecord;
|
|
114
|
+
selection?: SelectionSnapshot;
|
|
113
115
|
origin?: CommandOrigin;
|
|
114
116
|
}
|
|
115
117
|
| {
|
|
@@ -541,7 +543,11 @@ export function executeEditorCommand(
|
|
|
541
543
|
}
|
|
542
544
|
const editedEntries = [...threadToEdit.entries];
|
|
543
545
|
editedEntries[0] = { ...editedEntries[0], body: command.body };
|
|
544
|
-
const editedThread = {
|
|
546
|
+
const editedThread = {
|
|
547
|
+
...threadToEdit,
|
|
548
|
+
body: command.body,
|
|
549
|
+
entries: editedEntries,
|
|
550
|
+
};
|
|
545
551
|
const editedComments = {
|
|
546
552
|
...state.document.review.comments,
|
|
547
553
|
[command.commentId]: editedThread,
|
|
@@ -611,7 +617,12 @@ export function remapSelection(
|
|
|
611
617
|
}
|
|
612
618
|
|
|
613
619
|
if (activeRange.kind === "node") {
|
|
614
|
-
return
|
|
620
|
+
return {
|
|
621
|
+
anchor: activeRange.at,
|
|
622
|
+
head: activeRange.at,
|
|
623
|
+
isCollapsed: true,
|
|
624
|
+
activeRange,
|
|
625
|
+
};
|
|
615
626
|
}
|
|
616
627
|
|
|
617
628
|
return {
|
|
@@ -676,7 +687,12 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
|
|
|
676
687
|
}
|
|
677
688
|
|
|
678
689
|
if (activeRange.kind === "node") {
|
|
679
|
-
return
|
|
690
|
+
return {
|
|
691
|
+
anchor: activeRange.at,
|
|
692
|
+
head: activeRange.at,
|
|
693
|
+
isCollapsed: true,
|
|
694
|
+
activeRange,
|
|
695
|
+
};
|
|
680
696
|
}
|
|
681
697
|
|
|
682
698
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AbstractNumberingDefinition,
|
|
3
|
+
BlockNode,
|
|
3
4
|
CanonicalDocument as CanonicalDocumentEnvelope,
|
|
4
5
|
NumberingCatalog,
|
|
5
6
|
NumberingInstance,
|
|
@@ -16,9 +17,6 @@ export interface ListCommandResult {
|
|
|
16
17
|
createdNumberingInstanceId?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
type OpaqueBlockLike = { type: "opaque_block"; [key: string]: unknown };
|
|
20
|
-
type BlockLike = ParagraphNode | OpaqueBlockLike;
|
|
21
|
-
|
|
22
20
|
export function toggleNumberedList(
|
|
23
21
|
document: CanonicalDocumentEnvelope,
|
|
24
22
|
paragraphIndexes: readonly number[],
|
|
@@ -51,6 +49,98 @@ export function outdentListItems(
|
|
|
51
49
|
return adjustListLevels(document, paragraphIndexes, -1, context);
|
|
52
50
|
}
|
|
53
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Word-like Enter behavior on a list paragraph:
|
|
54
|
+
* - If the paragraph has content, split normally (caller handles the split).
|
|
55
|
+
* Returns `action: "split"` so the caller can proceed with a standard paragraph split.
|
|
56
|
+
* - If the paragraph is empty AND at level > 0, outdent one level → `action: "outdented"`.
|
|
57
|
+
* - If the paragraph is empty AND at level 0, remove numbering → `action: "removed"`.
|
|
58
|
+
*/
|
|
59
|
+
export function splitListParagraph(
|
|
60
|
+
document: CanonicalDocumentEnvelope,
|
|
61
|
+
paragraphIndex: number,
|
|
62
|
+
paragraphIsEmpty: boolean,
|
|
63
|
+
context: ListCommandContext,
|
|
64
|
+
): ListCommandResult & { action: "split" | "outdented" | "removed" } {
|
|
65
|
+
if (!paragraphIsEmpty) {
|
|
66
|
+
return {
|
|
67
|
+
document,
|
|
68
|
+
affectedParagraphIndexes: [],
|
|
69
|
+
action: "split",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const working = cloneEnvelope(document, context.timestamp);
|
|
74
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
75
|
+
const target = paragraphs[paragraphIndex];
|
|
76
|
+
|
|
77
|
+
if (!target?.numbering) {
|
|
78
|
+
return {
|
|
79
|
+
document: working,
|
|
80
|
+
affectedParagraphIndexes: [],
|
|
81
|
+
action: "split",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (target.numbering.level > 0) {
|
|
86
|
+
target.numbering = {
|
|
87
|
+
numberingInstanceId: target.numbering.numberingInstanceId,
|
|
88
|
+
level: clampLevel(target.numbering.level - 1),
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
document: working,
|
|
92
|
+
affectedParagraphIndexes: [paragraphIndex],
|
|
93
|
+
action: "outdented",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
delete target.numbering;
|
|
98
|
+
return {
|
|
99
|
+
document: working,
|
|
100
|
+
affectedParagraphIndexes: [paragraphIndex],
|
|
101
|
+
action: "removed",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Word-like Backspace at the start of a list paragraph:
|
|
107
|
+
* - If at level > 0, outdent one level.
|
|
108
|
+
* - If at level 0, remove numbering entirely.
|
|
109
|
+
* Returns the modified document and whether numbering was changed.
|
|
110
|
+
*/
|
|
111
|
+
export function backspaceAtListStart(
|
|
112
|
+
document: CanonicalDocumentEnvelope,
|
|
113
|
+
paragraphIndex: number,
|
|
114
|
+
context: ListCommandContext,
|
|
115
|
+
): ListCommandResult & { handled: boolean } {
|
|
116
|
+
const working = cloneEnvelope(document, context.timestamp);
|
|
117
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
118
|
+
const target = paragraphs[paragraphIndex];
|
|
119
|
+
|
|
120
|
+
if (!target?.numbering) {
|
|
121
|
+
return {
|
|
122
|
+
document: working,
|
|
123
|
+
affectedParagraphIndexes: [],
|
|
124
|
+
handled: false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (target.numbering.level > 0) {
|
|
129
|
+
target.numbering = {
|
|
130
|
+
numberingInstanceId: target.numbering.numberingInstanceId,
|
|
131
|
+
level: clampLevel(target.numbering.level - 1),
|
|
132
|
+
};
|
|
133
|
+
} else {
|
|
134
|
+
delete target.numbering;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
document: working,
|
|
139
|
+
affectedParagraphIndexes: [paragraphIndex],
|
|
140
|
+
handled: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
54
144
|
export function restartNumbering(
|
|
55
145
|
document: CanonicalDocumentEnvelope,
|
|
56
146
|
paragraphIndex: number,
|
|
@@ -58,10 +148,10 @@ export function restartNumbering(
|
|
|
58
148
|
startAt = 1,
|
|
59
149
|
): ListCommandResult {
|
|
60
150
|
const working = cloneEnvelope(document, context.timestamp);
|
|
61
|
-
const
|
|
62
|
-
const target =
|
|
151
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
152
|
+
const target = paragraphs[paragraphIndex];
|
|
63
153
|
|
|
64
|
-
if (!
|
|
154
|
+
if (!target?.numbering) {
|
|
65
155
|
return {
|
|
66
156
|
document: working,
|
|
67
157
|
affectedParagraphIndexes: [],
|
|
@@ -88,18 +178,18 @@ export function restartNumbering(
|
|
|
88
178
|
};
|
|
89
179
|
|
|
90
180
|
const affectedParagraphIndexes: number[] = [];
|
|
91
|
-
for (let index = paragraphIndex; index <
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
181
|
+
for (let index = paragraphIndex; index < paragraphs.length; index += 1) {
|
|
182
|
+
const paragraph = paragraphs[index];
|
|
183
|
+
if (!paragraph?.numbering) {
|
|
94
184
|
break;
|
|
95
185
|
}
|
|
96
|
-
if (
|
|
186
|
+
if (paragraph.numbering.numberingInstanceId !== existingInstance.numberingInstanceId) {
|
|
97
187
|
break;
|
|
98
188
|
}
|
|
99
189
|
|
|
100
|
-
|
|
190
|
+
paragraph.numbering = {
|
|
101
191
|
numberingInstanceId,
|
|
102
|
-
level:
|
|
192
|
+
level: paragraph.numbering.level,
|
|
103
193
|
};
|
|
104
194
|
affectedParagraphIndexes.push(index);
|
|
105
195
|
}
|
|
@@ -113,6 +203,70 @@ export function restartNumbering(
|
|
|
113
203
|
};
|
|
114
204
|
}
|
|
115
205
|
|
|
206
|
+
export function continueNumbering(
|
|
207
|
+
document: CanonicalDocumentEnvelope,
|
|
208
|
+
paragraphIndex: number,
|
|
209
|
+
context: ListCommandContext,
|
|
210
|
+
): ListCommandResult {
|
|
211
|
+
const working = cloneEnvelope(document, context.timestamp);
|
|
212
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
213
|
+
const target = paragraphs[paragraphIndex];
|
|
214
|
+
|
|
215
|
+
if (!target?.numbering) {
|
|
216
|
+
return {
|
|
217
|
+
document: working,
|
|
218
|
+
affectedParagraphIndexes: [],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const catalog = ensureNumberingCatalog(working.numbering);
|
|
223
|
+
const currentInstanceId = target.numbering.numberingInstanceId;
|
|
224
|
+
const currentKind = getListKind(catalog, currentInstanceId);
|
|
225
|
+
if (!currentKind) {
|
|
226
|
+
return {
|
|
227
|
+
document: working,
|
|
228
|
+
affectedParagraphIndexes: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const compatibleInstanceId = findPreviousCompatibleInstance(
|
|
233
|
+
paragraphs,
|
|
234
|
+
catalog,
|
|
235
|
+
paragraphIndex,
|
|
236
|
+
currentKind,
|
|
237
|
+
);
|
|
238
|
+
if (!compatibleInstanceId || compatibleInstanceId === currentInstanceId) {
|
|
239
|
+
return {
|
|
240
|
+
document: working,
|
|
241
|
+
affectedParagraphIndexes: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const affectedParagraphIndexes: number[] = [];
|
|
246
|
+
for (let index = paragraphIndex; index < paragraphs.length; index += 1) {
|
|
247
|
+
const paragraph = paragraphs[index];
|
|
248
|
+
if (!paragraph?.numbering) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (paragraph.numbering.numberingInstanceId !== currentInstanceId) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
paragraph.numbering = {
|
|
255
|
+
numberingInstanceId: compatibleInstanceId,
|
|
256
|
+
level: paragraph.numbering.level,
|
|
257
|
+
};
|
|
258
|
+
affectedParagraphIndexes.push(index);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
working.numbering = catalog;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
document: working,
|
|
265
|
+
affectedParagraphIndexes,
|
|
266
|
+
createdNumberingInstanceId: compatibleInstanceId,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
116
270
|
function toggleListKind(
|
|
117
271
|
document: CanonicalDocumentEnvelope,
|
|
118
272
|
paragraphIndexes: readonly number[],
|
|
@@ -120,8 +274,8 @@ function toggleListKind(
|
|
|
120
274
|
context: ListCommandContext,
|
|
121
275
|
): ListCommandResult {
|
|
122
276
|
const working = cloneEnvelope(document, context.timestamp);
|
|
123
|
-
const
|
|
124
|
-
const normalizedIndexes = normalizeParagraphIndexes(
|
|
277
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
278
|
+
const normalizedIndexes = normalizeParagraphIndexes(paragraphs, paragraphIndexes);
|
|
125
279
|
|
|
126
280
|
if (normalizedIndexes.length === 0) {
|
|
127
281
|
return {
|
|
@@ -132,7 +286,7 @@ function toggleListKind(
|
|
|
132
286
|
|
|
133
287
|
const catalog = ensureNumberingCatalog(working.numbering);
|
|
134
288
|
const allAlreadyKind = normalizedIndexes.every((index) => {
|
|
135
|
-
const paragraph =
|
|
289
|
+
const paragraph = paragraphs[index] as ParagraphNode;
|
|
136
290
|
return paragraph.numbering
|
|
137
291
|
? getListKind(catalog, paragraph.numbering.numberingInstanceId) === kind
|
|
138
292
|
: false;
|
|
@@ -140,7 +294,7 @@ function toggleListKind(
|
|
|
140
294
|
|
|
141
295
|
if (allAlreadyKind) {
|
|
142
296
|
for (const index of normalizedIndexes) {
|
|
143
|
-
delete (
|
|
297
|
+
delete (paragraphs[index] as ParagraphNode).numbering;
|
|
144
298
|
}
|
|
145
299
|
working.numbering = catalog;
|
|
146
300
|
return {
|
|
@@ -150,11 +304,11 @@ function toggleListKind(
|
|
|
150
304
|
}
|
|
151
305
|
|
|
152
306
|
const numberingInstanceId =
|
|
153
|
-
findAdjacentCompatibleInstance(
|
|
307
|
+
findAdjacentCompatibleInstance(paragraphs, catalog, normalizedIndexes[0]!, kind) ??
|
|
154
308
|
ensureDefaultInstance(catalog, kind);
|
|
155
309
|
|
|
156
310
|
for (const index of normalizedIndexes) {
|
|
157
|
-
const paragraph =
|
|
311
|
+
const paragraph = paragraphs[index] as ParagraphNode;
|
|
158
312
|
paragraph.numbering = {
|
|
159
313
|
numberingInstanceId,
|
|
160
314
|
level: clampLevel(paragraph.numbering?.level ?? 0),
|
|
@@ -177,13 +331,13 @@ function adjustListLevels(
|
|
|
177
331
|
context: ListCommandContext,
|
|
178
332
|
): ListCommandResult {
|
|
179
333
|
const working = cloneEnvelope(document, context.timestamp);
|
|
180
|
-
const
|
|
181
|
-
const affectedParagraphIndexes = normalizeParagraphIndexes(
|
|
182
|
-
(index) =>
|
|
334
|
+
const paragraphs = captureEditableParagraphs(working);
|
|
335
|
+
const affectedParagraphIndexes = normalizeParagraphIndexes(paragraphs, paragraphIndexes).filter(
|
|
336
|
+
(index) => Boolean(paragraphs[index]?.numbering),
|
|
183
337
|
);
|
|
184
338
|
|
|
185
339
|
for (const index of affectedParagraphIndexes) {
|
|
186
|
-
const paragraph =
|
|
340
|
+
const paragraph = paragraphs[index] as ParagraphNode;
|
|
187
341
|
if (!paragraph.numbering) {
|
|
188
342
|
continue;
|
|
189
343
|
}
|
|
@@ -226,13 +380,13 @@ function ensureDefaultInstance(
|
|
|
226
380
|
}
|
|
227
381
|
|
|
228
382
|
function findAdjacentCompatibleInstance(
|
|
229
|
-
|
|
383
|
+
paragraphs: readonly ParagraphNode[],
|
|
230
384
|
catalog: NumberingCatalog,
|
|
231
385
|
fromIndex: number,
|
|
232
386
|
kind: "numbered" | "bulleted",
|
|
233
387
|
): string | undefined {
|
|
234
|
-
const previous =
|
|
235
|
-
if (
|
|
388
|
+
const previous = paragraphs[fromIndex - 1];
|
|
389
|
+
if (previous?.numbering) {
|
|
236
390
|
const previousKind = getListKind(catalog, previous.numbering.numberingInstanceId);
|
|
237
391
|
if (previousKind === kind) {
|
|
238
392
|
return previous.numbering.numberingInstanceId;
|
|
@@ -242,6 +396,24 @@ function findAdjacentCompatibleInstance(
|
|
|
242
396
|
return undefined;
|
|
243
397
|
}
|
|
244
398
|
|
|
399
|
+
function findPreviousCompatibleInstance(
|
|
400
|
+
paragraphs: readonly ParagraphNode[],
|
|
401
|
+
catalog: NumberingCatalog,
|
|
402
|
+
fromIndex: number,
|
|
403
|
+
kind: "numbered" | "bulleted",
|
|
404
|
+
): string | undefined {
|
|
405
|
+
for (let index = fromIndex - 1; index >= 0; index -= 1) {
|
|
406
|
+
const paragraph = paragraphs[index];
|
|
407
|
+
if (!paragraph?.numbering) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (getListKind(catalog, paragraph.numbering.numberingInstanceId) === kind) {
|
|
411
|
+
return paragraph.numbering.numberingInstanceId;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
245
417
|
function getListKind(
|
|
246
418
|
catalog: NumberingCatalog,
|
|
247
419
|
numberingInstanceId: string,
|
|
@@ -333,9 +505,11 @@ function cloneEnvelope(
|
|
|
333
505
|
};
|
|
334
506
|
}
|
|
335
507
|
|
|
336
|
-
function
|
|
508
|
+
function captureEditableParagraphs(document: CanonicalDocumentEnvelope): ParagraphNode[] {
|
|
337
509
|
if (isDocumentRoot(document.content)) {
|
|
338
|
-
|
|
510
|
+
const paragraphs: ParagraphNode[] = [];
|
|
511
|
+
collectEditableParagraphs(document.content.children as BlockNode[], paragraphs);
|
|
512
|
+
return paragraphs;
|
|
339
513
|
}
|
|
340
514
|
|
|
341
515
|
const fallback = {
|
|
@@ -343,16 +517,42 @@ function captureBlocks(document: CanonicalDocumentEnvelope): BlockLike[] {
|
|
|
343
517
|
children: [{ type: "paragraph" as const, children: [] }],
|
|
344
518
|
};
|
|
345
519
|
document.content = fallback;
|
|
346
|
-
return fallback.children as
|
|
520
|
+
return fallback.children as ParagraphNode[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function collectEditableParagraphs(
|
|
524
|
+
blocks: readonly BlockNode[],
|
|
525
|
+
output: ParagraphNode[],
|
|
526
|
+
): void {
|
|
527
|
+
for (const block of blocks) {
|
|
528
|
+
switch (block.type) {
|
|
529
|
+
case "paragraph":
|
|
530
|
+
output.push(block);
|
|
531
|
+
break;
|
|
532
|
+
case "table":
|
|
533
|
+
for (const row of block.rows) {
|
|
534
|
+
for (const cell of row.cells) {
|
|
535
|
+
collectEditableParagraphs(cell.children, output);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
break;
|
|
539
|
+
case "sdt":
|
|
540
|
+
collectEditableParagraphs(block.children, output);
|
|
541
|
+
break;
|
|
542
|
+
case "custom_xml":
|
|
543
|
+
break;
|
|
544
|
+
default:
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
347
548
|
}
|
|
348
549
|
|
|
349
550
|
function normalizeParagraphIndexes(
|
|
350
|
-
|
|
551
|
+
paragraphs: readonly ParagraphNode[],
|
|
351
552
|
paragraphIndexes: readonly number[],
|
|
352
553
|
): number[] {
|
|
353
554
|
return [...new Set(paragraphIndexes)]
|
|
354
|
-
.filter((index) => Number.isInteger(index) && index >= 0 && index <
|
|
355
|
-
.filter((index) => isParagraphNode(blocks[index]))
|
|
555
|
+
.filter((index) => Number.isInteger(index) && index >= 0 && index < paragraphs.length)
|
|
356
556
|
.sort((left, right) => left - right);
|
|
357
557
|
}
|
|
358
558
|
|
|
@@ -363,8 +563,3 @@ function clampLevel(level: number): number {
|
|
|
363
563
|
function isDocumentRoot(value: unknown): value is { type: "doc"; children: unknown[] } {
|
|
364
564
|
return Boolean(value) && typeof value === "object" && (value as { type?: string }).type === "doc";
|
|
365
565
|
}
|
|
366
|
-
|
|
367
|
-
function isParagraphNode(value: unknown): value is ParagraphNode {
|
|
368
|
-
return Boolean(value) && typeof value === "object" && (value as { type?: string }).type === "paragraph";
|
|
369
|
-
}
|
|
370
|
-
|