@beyondwork/docx-react-component 1.0.11 → 1.0.13
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 +35 -21
- package/src/api/public-types.ts +103 -1
- package/src/core/commands/formatting-commands.ts +742 -0
- package/src/core/commands/image-commands.ts +84 -2
- package/src/core/commands/structural-helpers.ts +309 -0
- package/src/core/commands/table-structure-commands.ts +721 -0
- package/src/core/commands/text-commands.ts +166 -1
- package/src/core/state/editor-state.ts +318 -9
- package/src/formats/xlsx/io/parse-sheet.ts +177 -7
- package/src/formats/xlsx/io/parse-styles.ts +2 -0
- package/src/formats/xlsx/io/xlsx-session.ts +18 -12
- package/src/formats/xlsx/model/sheet.ts +81 -1
- package/src/formats/xlsx/model/workbook.ts +10 -6
- package/src/io/docx-session.ts +392 -22
- package/src/io/export/export-session.ts +55 -0
- package/src/io/export/serialize-footnotes.ts +5 -20
- package/src/io/export/serialize-headers-footers.ts +5 -31
- package/src/io/export/serialize-main-document.ts +78 -5
- package/src/io/normalize/normalize-text.ts +90 -1
- package/src/io/ooxml/parse-footnotes.ts +68 -5
- package/src/io/ooxml/parse-headers-footers.ts +67 -9
- package/src/io/ooxml/parse-main-document.ts +169 -6
- package/src/io/opc/package-reader.ts +3 -3
- package/src/io/source-package-provenance.ts +241 -0
- package/src/model/canonical-document.ts +450 -2
- package/src/model/cds-1.0.0.ts +5 -2
- package/src/model/snapshot.ts +190 -19
- package/src/preservation/package-preservation.ts +0 -7
- package/src/runtime/document-runtime.ts +7 -1
- package/src/runtime/read-only-diagnostics-runtime.ts +1 -1
- package/src/runtime/surface-projection.ts +200 -17
- package/src/runtime/table-commands.ts +79 -0
- package/src/runtime/table-schema.ts +9 -0
- package/src/ui/WordReviewEditor.tsx +708 -16
- package/src/ui-tailwind/editor-surface/pm-schema.ts +121 -5
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +73 -7
- package/src/ui-tailwind/editor-surface/search-plugin.ts +76 -16
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +162 -14
- package/src/validation/compatibility-engine.ts +208 -0
|
@@ -20,6 +20,23 @@ import { toggleMark } from "prosemirror-commands";
|
|
|
20
20
|
import type { Command } from "prosemirror-state";
|
|
21
21
|
import type { MarkType, Schema } from "prosemirror-model";
|
|
22
22
|
|
|
23
|
+
import type {
|
|
24
|
+
FormattingAlignment,
|
|
25
|
+
FormattingStateSnapshot,
|
|
26
|
+
PersistedEditorSnapshot,
|
|
27
|
+
RuntimeRenderSnapshot,
|
|
28
|
+
SurfaceBlockSnapshot,
|
|
29
|
+
SurfaceInlineSegment,
|
|
30
|
+
} from "../../api/public-types";
|
|
31
|
+
import type {
|
|
32
|
+
BlockNode,
|
|
33
|
+
DocumentRootNode,
|
|
34
|
+
InlineNode,
|
|
35
|
+
ParagraphNode,
|
|
36
|
+
TextMark,
|
|
37
|
+
TextNode,
|
|
38
|
+
} from "../../model/canonical-document.ts";
|
|
39
|
+
|
|
23
40
|
// ---------------------------------------------------------------------------
|
|
24
41
|
// Toggle commands (boolean marks – no attrs)
|
|
25
42
|
// ---------------------------------------------------------------------------
|
|
@@ -159,3 +176,728 @@ export function isMarkActive(schema: Schema, markName: keyof typeof schema.marks
|
|
|
159
176
|
});
|
|
160
177
|
return active;
|
|
161
178
|
}
|
|
179
|
+
|
|
180
|
+
type CanonicalDocumentEnvelope = PersistedEditorSnapshot["canonicalDocument"];
|
|
181
|
+
type ParagraphSurfaceBlock = Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
|
|
182
|
+
type ToggleFormattingMark =
|
|
183
|
+
| "bold"
|
|
184
|
+
| "italic"
|
|
185
|
+
| "underline"
|
|
186
|
+
| "strikethrough"
|
|
187
|
+
| "superscript"
|
|
188
|
+
| "subscript";
|
|
189
|
+
type FormattingOperation =
|
|
190
|
+
| { type: "toggle"; mark: ToggleFormattingMark }
|
|
191
|
+
| { type: "set-font-family"; fontFamily: string | null }
|
|
192
|
+
| { type: "set-font-size"; size: number | null }
|
|
193
|
+
| { type: "set-text-color"; color: string | null }
|
|
194
|
+
| { type: "set-highlight-color"; color: string | null }
|
|
195
|
+
| { type: "set-alignment"; alignment: FormattingAlignment }
|
|
196
|
+
| { type: "indent" }
|
|
197
|
+
| { type: "outdent" };
|
|
198
|
+
|
|
199
|
+
export interface FormattingMutationResult {
|
|
200
|
+
document: CanonicalDocumentEnvelope;
|
|
201
|
+
selection: RuntimeRenderSnapshot["selection"];
|
|
202
|
+
changed: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const DEFAULT_FORMATTING_STATE: FormattingStateSnapshot = {
|
|
206
|
+
bold: false,
|
|
207
|
+
italic: false,
|
|
208
|
+
underline: false,
|
|
209
|
+
strikethrough: false,
|
|
210
|
+
superscript: false,
|
|
211
|
+
subscript: false,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const INDENT_STEP_TWIPS = 720;
|
|
215
|
+
|
|
216
|
+
export function getFormattingStateFromRenderSnapshot(
|
|
217
|
+
snapshot: RuntimeRenderSnapshot,
|
|
218
|
+
): FormattingStateSnapshot {
|
|
219
|
+
const surface = snapshot.surface;
|
|
220
|
+
if (!surface) {
|
|
221
|
+
return { ...DEFAULT_FORMATTING_STATE };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const paragraphs = collectParagraphSurfaces(surface.blocks).filter((paragraph) =>
|
|
225
|
+
selectionTouchesRange(
|
|
226
|
+
snapshot.selection.anchor,
|
|
227
|
+
snapshot.selection.head,
|
|
228
|
+
paragraph.from,
|
|
229
|
+
paragraph.to,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
const textSegments = collectFormattingTextSegments(paragraphs, snapshot.selection);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
bold: textSegments.length > 0
|
|
236
|
+
? textSegments.every((segment) => segmentHasMark(segment, "bold"))
|
|
237
|
+
: false,
|
|
238
|
+
italic: textSegments.length > 0
|
|
239
|
+
? textSegments.every((segment) => segmentHasMark(segment, "italic"))
|
|
240
|
+
: false,
|
|
241
|
+
underline: textSegments.length > 0
|
|
242
|
+
? textSegments.every((segment) => segmentHasMark(segment, "underline"))
|
|
243
|
+
: false,
|
|
244
|
+
strikethrough: textSegments.length > 0
|
|
245
|
+
? textSegments.every((segment) => segmentHasMark(segment, "strikethrough"))
|
|
246
|
+
: false,
|
|
247
|
+
superscript: textSegments.length > 0
|
|
248
|
+
? textSegments.every((segment) => segmentHasMark(segment, "superscript"))
|
|
249
|
+
: false,
|
|
250
|
+
subscript: textSegments.length > 0
|
|
251
|
+
? textSegments.every((segment) => segmentHasMark(segment, "subscript"))
|
|
252
|
+
: false,
|
|
253
|
+
fontFamily: getConsistentValue(textSegments, (segment) => segment.markAttrs?.fontFamily),
|
|
254
|
+
fontSize: getConsistentValue(textSegments, (segment) => {
|
|
255
|
+
const size = segment.markAttrs?.fontSize;
|
|
256
|
+
return typeof size === "number" ? size / 2 : undefined;
|
|
257
|
+
}),
|
|
258
|
+
textColor: getConsistentValue(textSegments, (segment) =>
|
|
259
|
+
toPublicColor(segment.markAttrs?.textColor),
|
|
260
|
+
),
|
|
261
|
+
highlightColor: getConsistentValue(
|
|
262
|
+
textSegments,
|
|
263
|
+
(segment) => toPublicColor(segment.markAttrs?.backgroundColor) ?? null,
|
|
264
|
+
{
|
|
265
|
+
emptyFallback: null,
|
|
266
|
+
},
|
|
267
|
+
),
|
|
268
|
+
alignment: getConsistentValue(paragraphs, (paragraph) =>
|
|
269
|
+
toPublicAlignment(paragraph.alignment),
|
|
270
|
+
),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function applyFormattingOperationToDocument(
|
|
275
|
+
document: CanonicalDocumentEnvelope,
|
|
276
|
+
snapshot: RuntimeRenderSnapshot,
|
|
277
|
+
operation: FormattingOperation,
|
|
278
|
+
): FormattingMutationResult {
|
|
279
|
+
const surface = snapshot.surface;
|
|
280
|
+
if (!surface) {
|
|
281
|
+
return {
|
|
282
|
+
document,
|
|
283
|
+
selection: snapshot.selection,
|
|
284
|
+
changed: false,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const nextDocument = structuredClone(document);
|
|
289
|
+
const root = nextDocument.content as DocumentRootNode;
|
|
290
|
+
let changed = false;
|
|
291
|
+
const selectionFrom = Math.min(snapshot.selection.anchor, snapshot.selection.head);
|
|
292
|
+
const selectionTo = Math.max(snapshot.selection.anchor, snapshot.selection.head);
|
|
293
|
+
|
|
294
|
+
if (operation.type === "set-alignment" || operation.type === "indent" || operation.type === "outdent") {
|
|
295
|
+
visitParagraphBindings(root.children, surface.blocks, (paragraph, paragraphSurface) => {
|
|
296
|
+
if (
|
|
297
|
+
!selectionTouchesRange(
|
|
298
|
+
snapshot.selection.anchor,
|
|
299
|
+
snapshot.selection.head,
|
|
300
|
+
paragraphSurface.from,
|
|
301
|
+
paragraphSurface.to,
|
|
302
|
+
)
|
|
303
|
+
) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const paragraphChanged =
|
|
308
|
+
operation.type === "set-alignment"
|
|
309
|
+
? applyAlignment(paragraph, operation.alignment)
|
|
310
|
+
: applyIndentation(paragraph, operation.type === "indent" ? 1 : -1);
|
|
311
|
+
changed = changed || paragraphChanged;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
document: changed ? nextDocument : document,
|
|
316
|
+
selection: snapshot.selection,
|
|
317
|
+
changed,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (selectionFrom === selectionTo) {
|
|
322
|
+
return {
|
|
323
|
+
document,
|
|
324
|
+
selection: snapshot.selection,
|
|
325
|
+
changed: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const nextMarks = resolveMarkUpdater(snapshot, operation);
|
|
330
|
+
|
|
331
|
+
visitParagraphBindings(root.children, surface.blocks, (paragraph, paragraphSurface) => {
|
|
332
|
+
if (
|
|
333
|
+
!rangesOverlap(
|
|
334
|
+
selectionFrom,
|
|
335
|
+
selectionTo,
|
|
336
|
+
paragraphSurface.from,
|
|
337
|
+
paragraphSurface.to,
|
|
338
|
+
)
|
|
339
|
+
) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const transformed = transformInlineNodes(
|
|
344
|
+
paragraph.children,
|
|
345
|
+
paragraphSurface.from,
|
|
346
|
+
selectionFrom,
|
|
347
|
+
selectionTo,
|
|
348
|
+
nextMarks,
|
|
349
|
+
);
|
|
350
|
+
if (!transformed.changed) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
paragraph.children = transformed.nodes;
|
|
355
|
+
changed = true;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
document: changed ? nextDocument : document,
|
|
360
|
+
selection: snapshot.selection,
|
|
361
|
+
changed,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function resolveMarkUpdater(
|
|
366
|
+
snapshot: RuntimeRenderSnapshot,
|
|
367
|
+
operation: Exclude<
|
|
368
|
+
FormattingOperation,
|
|
369
|
+
{ type: "set-alignment" } | { type: "indent" } | { type: "outdent" }
|
|
370
|
+
>,
|
|
371
|
+
): (marks?: TextMark[]) => TextMark[] | undefined {
|
|
372
|
+
const formatting = getFormattingStateFromRenderSnapshot(snapshot);
|
|
373
|
+
|
|
374
|
+
switch (operation.type) {
|
|
375
|
+
case "toggle":
|
|
376
|
+
return (marks) => toggleMarks(marks, operation.mark, !formatting[operation.mark]);
|
|
377
|
+
case "set-font-family":
|
|
378
|
+
return (marks) => setMarkValue(marks, "fontFamily", sanitizeFontFamily(operation.fontFamily));
|
|
379
|
+
case "set-font-size":
|
|
380
|
+
return (marks) => setMarkValue(marks, "fontSize", sanitizeFontSize(operation.size));
|
|
381
|
+
case "set-text-color":
|
|
382
|
+
return (marks) => setMarkValue(marks, "textColor", sanitizeColor(operation.color));
|
|
383
|
+
case "set-highlight-color":
|
|
384
|
+
return (marks) => setMarkValue(marks, "backgroundColor", sanitizeColor(operation.color));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function applyAlignment(
|
|
389
|
+
paragraph: ParagraphNode,
|
|
390
|
+
alignment: FormattingAlignment,
|
|
391
|
+
): boolean {
|
|
392
|
+
const nextAlignment = alignment === "justify" ? "both" : alignment;
|
|
393
|
+
if (paragraph.alignment === nextAlignment) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
paragraph.alignment = nextAlignment;
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function applyIndentation(paragraph: ParagraphNode, delta: -1 | 1): boolean {
|
|
402
|
+
if (paragraph.numbering) {
|
|
403
|
+
const nextLevel = clamp(paragraph.numbering.level + delta, 0, 8);
|
|
404
|
+
if (nextLevel === paragraph.numbering.level) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
paragraph.numbering = {
|
|
409
|
+
...paragraph.numbering,
|
|
410
|
+
level: nextLevel,
|
|
411
|
+
};
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const currentLeft = paragraph.indentation?.left ?? 0;
|
|
416
|
+
const nextLeft = Math.max(0, currentLeft + delta * INDENT_STEP_TWIPS);
|
|
417
|
+
if (nextLeft === currentLeft) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const nextIndentation = {
|
|
422
|
+
...(paragraph.indentation ?? {}),
|
|
423
|
+
};
|
|
424
|
+
if (nextLeft > 0) {
|
|
425
|
+
nextIndentation.left = nextLeft;
|
|
426
|
+
paragraph.indentation = nextIndentation;
|
|
427
|
+
} else if (paragraph.indentation) {
|
|
428
|
+
delete nextIndentation.left;
|
|
429
|
+
paragraph.indentation =
|
|
430
|
+
Object.keys(nextIndentation).length > 0 ? nextIndentation : undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function collectParagraphSurfaces(
|
|
437
|
+
blocks: SurfaceBlockSnapshot[],
|
|
438
|
+
output: ParagraphSurfaceBlock[] = [],
|
|
439
|
+
): ParagraphSurfaceBlock[] {
|
|
440
|
+
for (const block of blocks) {
|
|
441
|
+
if (block.kind === "paragraph") {
|
|
442
|
+
output.push(block);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (block.kind === "table") {
|
|
447
|
+
for (const row of block.rows) {
|
|
448
|
+
for (const cell of row.cells) {
|
|
449
|
+
collectParagraphSurfaces(cell.content, output);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (block.kind === "sdt_block") {
|
|
456
|
+
collectParagraphSurfaces(block.children, output);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return output;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function collectFormattingTextSegments(
|
|
464
|
+
paragraphs: ParagraphSurfaceBlock[],
|
|
465
|
+
selection: RuntimeRenderSnapshot["selection"],
|
|
466
|
+
): Array<Extract<SurfaceInlineSegment, { kind: "text" }>> {
|
|
467
|
+
const caret = selection.anchor;
|
|
468
|
+
const isCollapsed = selection.isCollapsed;
|
|
469
|
+
const selectionFrom = Math.min(selection.anchor, selection.head);
|
|
470
|
+
const selectionTo = Math.max(selection.anchor, selection.head);
|
|
471
|
+
const segments: Array<Extract<SurfaceInlineSegment, { kind: "text" }>> = [];
|
|
472
|
+
|
|
473
|
+
for (const paragraph of paragraphs) {
|
|
474
|
+
for (const segment of paragraph.segments) {
|
|
475
|
+
if (segment.kind !== "text") {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (
|
|
480
|
+
isCollapsed
|
|
481
|
+
? segmentContainsCaret(segment, caret)
|
|
482
|
+
: rangesOverlap(selectionFrom, selectionTo, segment.from, segment.to)
|
|
483
|
+
) {
|
|
484
|
+
segments.push(segment);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!isCollapsed || segments.length > 0) {
|
|
490
|
+
return segments;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const paragraph of paragraphs) {
|
|
494
|
+
const fallback = paragraph.segments.find(
|
|
495
|
+
(segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
|
|
496
|
+
segment.kind === "text" && segment.to === caret,
|
|
497
|
+
);
|
|
498
|
+
if (fallback) {
|
|
499
|
+
return [fallback];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function segmentContainsCaret(
|
|
507
|
+
segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
|
|
508
|
+
caret: number,
|
|
509
|
+
): boolean {
|
|
510
|
+
return (
|
|
511
|
+
segment.from <= caret &&
|
|
512
|
+
(caret < segment.to || (segment.to === caret && segment.to > segment.from))
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function segmentHasMark(
|
|
517
|
+
segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
|
|
518
|
+
mark: ToggleFormattingMark,
|
|
519
|
+
): boolean {
|
|
520
|
+
const marks = segment.marks ?? [];
|
|
521
|
+
if (mark === "superscript") {
|
|
522
|
+
return marks.includes("superscript");
|
|
523
|
+
}
|
|
524
|
+
if (mark === "subscript") {
|
|
525
|
+
return marks.includes("subscript");
|
|
526
|
+
}
|
|
527
|
+
return marks.includes(mark);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function getConsistentValue<TItem, TValue>(
|
|
531
|
+
items: readonly TItem[],
|
|
532
|
+
getter: (item: TItem) => TValue,
|
|
533
|
+
options: {
|
|
534
|
+
emptyFallback?: TValue;
|
|
535
|
+
} = {},
|
|
536
|
+
): TValue | undefined {
|
|
537
|
+
if (items.length === 0) {
|
|
538
|
+
return options.emptyFallback;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let value: TValue | undefined = undefined;
|
|
542
|
+
let initialized = false;
|
|
543
|
+
|
|
544
|
+
for (const item of items) {
|
|
545
|
+
const next = getter(item);
|
|
546
|
+
if (!initialized) {
|
|
547
|
+
value = next;
|
|
548
|
+
initialized = true;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (next !== value) {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function visitParagraphBindings(
|
|
561
|
+
blocks: BlockNode[],
|
|
562
|
+
surfaceBlocks: SurfaceBlockSnapshot[],
|
|
563
|
+
visitor: (paragraph: ParagraphNode, surface: ParagraphSurfaceBlock) => void,
|
|
564
|
+
): void {
|
|
565
|
+
for (let index = 0; index < Math.min(blocks.length, surfaceBlocks.length); index += 1) {
|
|
566
|
+
const block = blocks[index];
|
|
567
|
+
const surface = surfaceBlocks[index];
|
|
568
|
+
if (!block || !surface) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (block.type === "paragraph" && surface.kind === "paragraph") {
|
|
573
|
+
visitor(block, surface);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (block.type === "table" && surface.kind === "table") {
|
|
578
|
+
for (let rowIndex = 0; rowIndex < Math.min(block.rows.length, surface.rows.length); rowIndex += 1) {
|
|
579
|
+
const row = block.rows[rowIndex];
|
|
580
|
+
const surfaceRow = surface.rows[rowIndex];
|
|
581
|
+
if (!row || !surfaceRow) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (
|
|
586
|
+
let cellIndex = 0;
|
|
587
|
+
cellIndex < Math.min(row.cells.length, surfaceRow.cells.length);
|
|
588
|
+
cellIndex += 1
|
|
589
|
+
) {
|
|
590
|
+
const cell = row.cells[cellIndex];
|
|
591
|
+
const surfaceCell = surfaceRow.cells[cellIndex];
|
|
592
|
+
if (!cell || !surfaceCell) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
visitParagraphBindings(cell.children, surfaceCell.content, visitor);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (block.type === "sdt" && surface.kind === "sdt_block") {
|
|
603
|
+
visitParagraphBindings(block.children, surface.children, visitor);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function transformInlineNodes(
|
|
609
|
+
nodes: InlineNode[],
|
|
610
|
+
start: number,
|
|
611
|
+
selectionFrom: number,
|
|
612
|
+
selectionTo: number,
|
|
613
|
+
updateMarks: (marks?: TextMark[]) => TextMark[] | undefined,
|
|
614
|
+
): { nodes: InlineNode[]; changed: boolean; nextPosition: number } {
|
|
615
|
+
let changed = false;
|
|
616
|
+
let position = start;
|
|
617
|
+
const nextNodes: InlineNode[] = [];
|
|
618
|
+
|
|
619
|
+
for (const node of nodes) {
|
|
620
|
+
if (node.type === "text") {
|
|
621
|
+
const transformed = transformTextNode(
|
|
622
|
+
node,
|
|
623
|
+
position,
|
|
624
|
+
selectionFrom,
|
|
625
|
+
selectionTo,
|
|
626
|
+
updateMarks,
|
|
627
|
+
);
|
|
628
|
+
nextNodes.push(...transformed.nodes);
|
|
629
|
+
position = transformed.nextPosition;
|
|
630
|
+
changed = changed || transformed.changed;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (node.type === "hyperlink") {
|
|
635
|
+
const transformed = transformInlineNodes(
|
|
636
|
+
node.children as InlineNode[],
|
|
637
|
+
position,
|
|
638
|
+
selectionFrom,
|
|
639
|
+
selectionTo,
|
|
640
|
+
updateMarks,
|
|
641
|
+
);
|
|
642
|
+
nextNodes.push(
|
|
643
|
+
transformed.changed
|
|
644
|
+
? {
|
|
645
|
+
...node,
|
|
646
|
+
children: transformed.nodes as typeof node.children,
|
|
647
|
+
}
|
|
648
|
+
: node,
|
|
649
|
+
);
|
|
650
|
+
position = transformed.nextPosition;
|
|
651
|
+
changed = changed || transformed.changed;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
nextNodes.push(node);
|
|
656
|
+
position += inlineNodeLength(node);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
nodes: nextNodes,
|
|
661
|
+
changed,
|
|
662
|
+
nextPosition: position,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function transformTextNode(
|
|
667
|
+
node: TextNode,
|
|
668
|
+
start: number,
|
|
669
|
+
selectionFrom: number,
|
|
670
|
+
selectionTo: number,
|
|
671
|
+
updateMarks: (marks?: TextMark[]) => TextMark[] | undefined,
|
|
672
|
+
): { nodes: TextNode[]; changed: boolean; nextPosition: number } {
|
|
673
|
+
const characters = Array.from(node.text);
|
|
674
|
+
const end = start + characters.length;
|
|
675
|
+
const overlapFrom = Math.max(selectionFrom, start);
|
|
676
|
+
const overlapTo = Math.min(selectionTo, end);
|
|
677
|
+
|
|
678
|
+
if (overlapFrom >= overlapTo) {
|
|
679
|
+
return {
|
|
680
|
+
nodes: [node],
|
|
681
|
+
changed: false,
|
|
682
|
+
nextPosition: end,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const localFrom = overlapFrom - start;
|
|
687
|
+
const localTo = overlapTo - start;
|
|
688
|
+
const nextNodes: TextNode[] = [];
|
|
689
|
+
|
|
690
|
+
if (localFrom > 0) {
|
|
691
|
+
nextNodes.push(createTextNode(characters.slice(0, localFrom).join(""), node.marks));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const transformedMarks = updateMarks(node.marks);
|
|
695
|
+
nextNodes.push(
|
|
696
|
+
createTextNode(characters.slice(localFrom, localTo).join(""), transformedMarks),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
if (localTo < characters.length) {
|
|
700
|
+
nextNodes.push(createTextNode(characters.slice(localTo).join(""), node.marks));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
nodes: nextNodes.filter((candidate) => candidate.text.length > 0),
|
|
705
|
+
changed: !marksEqual(node.marks, transformedMarks) || localFrom > 0 || localTo < characters.length,
|
|
706
|
+
nextPosition: end,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function createTextNode(text: string, marks?: TextMark[]): TextNode {
|
|
711
|
+
return {
|
|
712
|
+
type: "text",
|
|
713
|
+
text,
|
|
714
|
+
...(marks && marks.length > 0 ? { marks: cloneMarks(marks) } : {}),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function toggleMarks(
|
|
719
|
+
marks: TextMark[] | undefined,
|
|
720
|
+
mark: ToggleFormattingMark,
|
|
721
|
+
enabled: boolean,
|
|
722
|
+
): TextMark[] | undefined {
|
|
723
|
+
const nextMarks = cloneMarks(marks);
|
|
724
|
+
|
|
725
|
+
if (mark === "superscript" || mark === "subscript") {
|
|
726
|
+
const filtered: TextMark[] = nextMarks.filter((candidate) => candidate.type !== "position");
|
|
727
|
+
if (enabled) {
|
|
728
|
+
filtered.push({
|
|
729
|
+
type: "position",
|
|
730
|
+
val: mark === "superscript" ? 1 : -1,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const filtered = nextMarks.filter((candidate) => candidate.type !== mark);
|
|
737
|
+
if (enabled) {
|
|
738
|
+
filtered.push({ type: mark });
|
|
739
|
+
}
|
|
740
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function setMarkValue(
|
|
744
|
+
marks: TextMark[] | undefined,
|
|
745
|
+
markType: "fontFamily" | "fontSize" | "textColor" | "backgroundColor",
|
|
746
|
+
value: string | number | null,
|
|
747
|
+
): TextMark[] | undefined {
|
|
748
|
+
const nextMarks = cloneMarks(marks).filter((candidate) => {
|
|
749
|
+
if (markType === "backgroundColor") {
|
|
750
|
+
return candidate.type !== "backgroundColor";
|
|
751
|
+
}
|
|
752
|
+
return candidate.type !== markType;
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (value !== null) {
|
|
756
|
+
switch (markType) {
|
|
757
|
+
case "fontFamily":
|
|
758
|
+
nextMarks.push({ type: "fontFamily", val: value as string });
|
|
759
|
+
break;
|
|
760
|
+
case "fontSize":
|
|
761
|
+
nextMarks.push({ type: "fontSize", val: value as number });
|
|
762
|
+
break;
|
|
763
|
+
case "textColor":
|
|
764
|
+
nextMarks.push({ type: "textColor", color: value as string });
|
|
765
|
+
break;
|
|
766
|
+
case "backgroundColor":
|
|
767
|
+
nextMarks.push({ type: "backgroundColor", color: value as string });
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return nextMarks.length > 0 ? nextMarks : undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function cloneMarks(marks?: TextMark[]): TextMark[] {
|
|
776
|
+
return marks ? marks.map((mark) => ({ ...mark })) : [];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function marksEqual(left?: TextMark[], right?: TextMark[]): boolean {
|
|
780
|
+
if (!left?.length && !right?.length) {
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
if (!left || !right || left.length !== right.length) {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return left.every((mark, index) => JSON.stringify(mark) === JSON.stringify(right[index]));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function inlineNodeLength(node: InlineNode): number {
|
|
791
|
+
switch (node.type) {
|
|
792
|
+
case "text":
|
|
793
|
+
return Array.from(node.text).length;
|
|
794
|
+
case "hyperlink":
|
|
795
|
+
return node.children.reduce< number >(
|
|
796
|
+
(total, child) => total + inlineNodeLength(child as InlineNode),
|
|
797
|
+
0,
|
|
798
|
+
);
|
|
799
|
+
case "hard_break":
|
|
800
|
+
case "column_break":
|
|
801
|
+
case "tab":
|
|
802
|
+
case "symbol":
|
|
803
|
+
case "image":
|
|
804
|
+
case "opaque_inline":
|
|
805
|
+
case "footnote_ref":
|
|
806
|
+
case "chart_preview":
|
|
807
|
+
case "smartart_preview":
|
|
808
|
+
case "shape":
|
|
809
|
+
case "wordart":
|
|
810
|
+
case "vml_shape":
|
|
811
|
+
return 1;
|
|
812
|
+
case "field":
|
|
813
|
+
return node.children.reduce<number>(
|
|
814
|
+
(total, child) => total + inlineNodeLength(child as InlineNode),
|
|
815
|
+
0,
|
|
816
|
+
);
|
|
817
|
+
case "bookmark_start":
|
|
818
|
+
case "bookmark_end":
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function toPublicAlignment(
|
|
824
|
+
alignment: ParagraphNode["alignment"] | undefined,
|
|
825
|
+
): FormattingAlignment | undefined {
|
|
826
|
+
switch (alignment) {
|
|
827
|
+
case "both":
|
|
828
|
+
case "distribute":
|
|
829
|
+
return "justify";
|
|
830
|
+
case "left":
|
|
831
|
+
case "center":
|
|
832
|
+
case "right":
|
|
833
|
+
return alignment;
|
|
834
|
+
default:
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function toPublicColor(color: string | undefined): string | undefined {
|
|
840
|
+
if (!color) {
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return /^[0-9A-F]{3,8}$/iu.test(color)
|
|
845
|
+
? `#${color.toUpperCase()}`
|
|
846
|
+
: color;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function sanitizeFontFamily(fontFamily: string | null): string | null {
|
|
850
|
+
const normalized = fontFamily?.trim();
|
|
851
|
+
return normalized ? normalized : null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function sanitizeFontSize(size: number | null): number | null {
|
|
855
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size <= 0) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return Math.round(size * 2);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function sanitizeColor(color: string | null): string | null {
|
|
863
|
+
const normalized = color?.trim();
|
|
864
|
+
if (!normalized) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const raw = normalized.startsWith("#") ? normalized.slice(1) : normalized;
|
|
869
|
+
return /^[0-9A-F]{3,8}$/iu.test(raw) ? raw.toUpperCase() : null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function selectionTouchesRange(
|
|
873
|
+
anchor: number,
|
|
874
|
+
head: number,
|
|
875
|
+
rangeFrom: number,
|
|
876
|
+
rangeTo: number,
|
|
877
|
+
): boolean {
|
|
878
|
+
const selectionFrom = Math.min(anchor, head);
|
|
879
|
+
const selectionTo = Math.max(anchor, head);
|
|
880
|
+
if (selectionFrom === selectionTo) {
|
|
881
|
+
return selectionFrom >= rangeFrom && selectionFrom <= rangeTo;
|
|
882
|
+
}
|
|
883
|
+
return rangesOverlap(selectionFrom, selectionTo, rangeFrom, rangeTo);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function rangesOverlap(
|
|
887
|
+
leftFrom: number,
|
|
888
|
+
leftTo: number,
|
|
889
|
+
rightFrom: number,
|
|
890
|
+
rightTo: number,
|
|
891
|
+
): boolean {
|
|
892
|
+
if (leftFrom === leftTo) {
|
|
893
|
+
return leftFrom >= rightFrom && leftFrom <= rightTo;
|
|
894
|
+
}
|
|
895
|
+
if (rightFrom === rightTo) {
|
|
896
|
+
return rightFrom >= leftFrom && rightFrom <= leftTo;
|
|
897
|
+
}
|
|
898
|
+
return leftFrom < rightTo && leftTo > rightFrom;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function clamp(value: number, min: number, max: number): number {
|
|
902
|
+
return Math.min(max, Math.max(min, value));
|
|
903
|
+
}
|