@beyondwork/docx-react-component 1.0.12 → 1.0.14

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.
@@ -1,8 +1,23 @@
1
- import type { CanonicalDocumentEnvelope, SelectionSnapshot } from "../state/editor-state.ts";
1
+ import type { InsertTableOptions } from "../../api/public-types";
2
+ import type { ParagraphNode } from "../../model/canonical-document.ts";
3
+ import {
4
+ createSelectionSnapshot,
5
+ type CanonicalDocumentEnvelope,
6
+ type SelectionSnapshot,
7
+ } from "../state/editor-state.ts";
2
8
  import {
3
9
  applyTextTransaction,
4
10
  type TextTransactionResult,
5
11
  } from "../state/text-transaction.ts";
12
+ import {
13
+ createInsertedTableBlock,
14
+ createNoopStructuralMutation,
15
+ findTableCellParagraphSelection,
16
+ replaceParagraphScope,
17
+ resolveInsertedTableStyleId,
18
+ resolveParagraphScope,
19
+ type StructuralMutationResult,
20
+ } from "./structural-helpers.ts";
6
21
 
7
22
  export interface TextCommandContext {
8
23
  timestamp: string;
@@ -117,3 +132,153 @@ export function splitParagraph(
117
132
  context,
118
133
  );
119
134
  }
135
+
136
+ export function insertPageBreak(
137
+ document: CanonicalDocumentEnvelope,
138
+ selection: SelectionSnapshot,
139
+ context: TextCommandContext,
140
+ ): StructuralMutationResult {
141
+ const scope = resolveParagraphScope(document, selection);
142
+ if (!scope) {
143
+ return createNoopStructuralMutation(document, selection);
144
+ }
145
+
146
+ const localDocument: CanonicalDocumentEnvelope = {
147
+ ...document,
148
+ content: {
149
+ type: "doc",
150
+ children: [scope.paragraph],
151
+ },
152
+ };
153
+ const localSelection = createSelectionSnapshot(
154
+ selection.anchor - scope.paragraphStart,
155
+ selection.head - scope.paragraphStart,
156
+ );
157
+ const splitResult = splitParagraph(localDocument, localSelection, context);
158
+ const splitRoot = splitResult.document.content;
159
+ if (!splitRoot || splitRoot.type !== "doc") {
160
+ return createNoopStructuralMutation(document, selection);
161
+ }
162
+
163
+ const replacementParagraphs = splitRoot.children
164
+ .filter((block): block is ParagraphNode => block.type === "paragraph")
165
+ .map((block, index) =>
166
+ index === 1
167
+ ? {
168
+ ...block,
169
+ pageBreakBefore: true,
170
+ }
171
+ : block,
172
+ );
173
+ if (replacementParagraphs.length < 2) {
174
+ return createNoopStructuralMutation(document, selection);
175
+ }
176
+
177
+ const nextDocument = replaceParagraphScope(document, scope, replacementParagraphs);
178
+ return {
179
+ changed: true,
180
+ document: {
181
+ ...nextDocument,
182
+ updatedAt: context.timestamp,
183
+ },
184
+ selection: createSelectionSnapshot(
185
+ splitResult.selection.anchor + scope.paragraphStart,
186
+ splitResult.selection.head + scope.paragraphStart,
187
+ ),
188
+ mapping: {
189
+ ...splitResult.mapping,
190
+ steps: splitResult.mapping.steps.map((step) => ({
191
+ ...step,
192
+ from: step.from + scope.paragraphStart,
193
+ to: step.to + scope.paragraphStart,
194
+ })),
195
+ },
196
+ };
197
+ }
198
+
199
+ export function insertTable(
200
+ document: CanonicalDocumentEnvelope,
201
+ selection: SelectionSnapshot,
202
+ options: InsertTableOptions,
203
+ context: TextCommandContext,
204
+ ): StructuralMutationResult {
205
+ const scope = resolveParagraphScope(document, selection);
206
+ if (!scope || scope.kind !== "top-level") {
207
+ return createNoopStructuralMutation(document, selection);
208
+ }
209
+
210
+ const safeRows = Math.max(1, Math.floor(options.rows));
211
+ const safeColumns = Math.max(1, Math.floor(options.columns));
212
+ if (!Number.isFinite(safeRows) || !Number.isFinite(safeColumns)) {
213
+ return createNoopStructuralMutation(document, selection);
214
+ }
215
+
216
+ const localDocument: CanonicalDocumentEnvelope = {
217
+ ...document,
218
+ content: {
219
+ type: "doc",
220
+ children: [scope.paragraph],
221
+ },
222
+ };
223
+ const localSelection = createSelectionSnapshot(
224
+ selection.anchor - scope.paragraphStart,
225
+ selection.head - scope.paragraphStart,
226
+ );
227
+ const splitResult = splitParagraph(localDocument, localSelection, context);
228
+ const splitRoot = splitResult.document.content;
229
+ if (!splitRoot || splitRoot.type !== "doc") {
230
+ return createNoopStructuralMutation(document, selection);
231
+ }
232
+
233
+ const replacementParagraphs = splitRoot.children.filter(
234
+ (block): block is ParagraphNode => block.type === "paragraph",
235
+ );
236
+ if (replacementParagraphs.length < 2) {
237
+ return createNoopStructuralMutation(document, selection);
238
+ }
239
+
240
+ const nextRoot = document.content;
241
+ if (!nextRoot || nextRoot.type !== "doc") {
242
+ return createNoopStructuralMutation(document, selection);
243
+ }
244
+
245
+ const insertedTable = createInsertedTableBlock(
246
+ safeRows,
247
+ safeColumns,
248
+ resolveInsertedTableStyleId(document),
249
+ );
250
+ const nextDocument: CanonicalDocumentEnvelope = {
251
+ ...document,
252
+ updatedAt: context.timestamp,
253
+ content: {
254
+ ...nextRoot,
255
+ children: [
256
+ ...nextRoot.children.slice(0, scope.blockIndex),
257
+ replacementParagraphs[0],
258
+ insertedTable,
259
+ replacementParagraphs[1],
260
+ ...nextRoot.children.slice(scope.blockIndex + 1),
261
+ ],
262
+ },
263
+ };
264
+
265
+ return {
266
+ changed: true,
267
+ document: nextDocument,
268
+ selection:
269
+ findTableCellParagraphSelection(
270
+ nextDocument,
271
+ scope.blockIndex + 1,
272
+ 0,
273
+ 0,
274
+ ) ?? selection,
275
+ mapping: {
276
+ ...splitResult.mapping,
277
+ steps: splitResult.mapping.steps.map((step) => ({
278
+ ...step,
279
+ from: step.from + scope.paragraphStart,
280
+ to: step.to + scope.paragraphStart,
281
+ })),
282
+ },
283
+ };
284
+ }
@@ -76,6 +76,16 @@ export function rangeStaysWithinSingleParagraph(
76
76
  return true;
77
77
  }
78
78
 
79
+ const surfaceBlocks = readSurfaceBlocks(content);
80
+ if (surfaceBlocks) {
81
+ return surfaceBlocks.some(
82
+ (block) =>
83
+ block.kind === "paragraph" &&
84
+ normalized.from >= block.from &&
85
+ normalized.to <= block.to,
86
+ );
87
+ }
88
+
79
89
  const story = parseTextStory(content);
80
90
  const upperBound = Math.min(normalized.to, story.units.length);
81
91
 
@@ -92,3 +102,82 @@ export function rangeStaysWithinSingleParagraph(
92
102
 
93
103
  return true;
94
104
  }
105
+
106
+ export function canCreateDocxCommentAnchor(
107
+ content: unknown,
108
+ anchor: ReviewAnchor,
109
+ ): boolean {
110
+ if (anchor.kind !== "range") {
111
+ return false;
112
+ }
113
+
114
+ const normalized = normalizeRange(anchor.range);
115
+ if (normalized.from === normalized.to) {
116
+ return false;
117
+ }
118
+
119
+ return rangeStaysWithinSingleParagraph(content, normalized);
120
+ }
121
+
122
+ function readSurfaceBlocks(
123
+ content: unknown,
124
+ ): Array<{ kind: string; from: number; to: number }> | undefined {
125
+ if (!content || typeof content !== "object" || !("blocks" in content)) {
126
+ return undefined;
127
+ }
128
+
129
+ const blocks = (content as { blocks?: unknown }).blocks;
130
+ if (!Array.isArray(blocks)) {
131
+ return undefined;
132
+ }
133
+
134
+ const normalized = flattenSurfaceBlocks(blocks);
135
+
136
+ return normalized.length > 0 ? normalized : undefined;
137
+ }
138
+
139
+ function flattenSurfaceBlocks(
140
+ blocks: unknown[],
141
+ ): Array<{ kind: string; from: number; to: number }> {
142
+ const flattened: Array<{ kind: string; from: number; to: number }> = [];
143
+
144
+ for (const block of blocks) {
145
+ if (
146
+ !block ||
147
+ typeof block !== "object" ||
148
+ typeof (block as { kind?: unknown }).kind !== "string" ||
149
+ typeof (block as { from?: unknown }).from !== "number" ||
150
+ typeof (block as { to?: unknown }).to !== "number"
151
+ ) {
152
+ continue;
153
+ }
154
+
155
+ flattened.push({
156
+ kind: (block as { kind: string }).kind,
157
+ from: (block as { from: number }).from,
158
+ to: (block as { to: number }).to,
159
+ });
160
+
161
+ if (
162
+ (block as { kind: string }).kind === "table" &&
163
+ Array.isArray((block as { rows?: unknown }).rows)
164
+ ) {
165
+ for (const row of (block as { rows: Array<{ cells?: unknown[] }> }).rows) {
166
+ for (const cell of row.cells ?? []) {
167
+ if (cell && typeof cell === "object" && Array.isArray((cell as { content?: unknown[] }).content)) {
168
+ flattened.push(...flattenSurfaceBlocks((cell as { content: unknown[] }).content));
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ if (
175
+ (block as { kind: string }).kind === "sdt_block" &&
176
+ Array.isArray((block as { children?: unknown[] }).children)
177
+ ) {
178
+ flattened.push(...flattenSurfaceBlocks((block as { children: unknown[] }).children));
179
+ }
180
+ }
181
+
182
+ return flattened;
183
+ }
@@ -1,4 +1,4 @@
1
- import { decodeXmlEntities, parseSharedStringsXml as _unused } from "./parse-shared-strings.ts";
1
+ import { decodeXmlEntities } from "./parse-shared-strings.ts";
2
2
  import { parseXmlAttributes } from "./parse-styles.ts";
3
3
 
4
4
  // Re-export for external use
@@ -12,11 +12,23 @@ export { decodeXmlEntities };
12
12
  * b (boolean), e (error), str (formula string result), and blank.
13
13
  */
14
14
  export type XlsxParsedCellValue =
15
+ | { type: "blank" }
16
+ | { type: "text"; value: string; fromSharedString: boolean }
17
+ | { type: "number"; value: number }
18
+ | { type: "boolean"; value: boolean }
19
+ | {
20
+ type: "formula";
21
+ formula: string;
22
+ referenceTokens: string[];
23
+ cachedValue: XlsxParsedFormulaCachedValue | null;
24
+ }
25
+ | { type: "error"; errorCode: string };
26
+
27
+ export type XlsxParsedFormulaCachedValue =
15
28
  | { type: "blank" }
16
29
  | { type: "text"; value: string }
17
30
  | { type: "number"; value: number }
18
31
  | { type: "boolean"; value: boolean }
19
- | { type: "formula"; formula: string; cachedValue: string | null }
20
32
  | { type: "error"; errorCode: string };
21
33
 
22
34
  export interface XlsxParsedCell {
@@ -37,9 +49,17 @@ export interface XlsxParsedMerge {
37
49
  endCol: number;
38
50
  }
39
51
 
52
+ export interface XlsxParsedDimension {
53
+ startRow: number;
54
+ startCol: number;
55
+ endRow: number;
56
+ endCol: number;
57
+ }
58
+
40
59
  export interface SheetParseResult {
41
60
  cells: XlsxParsedCell[];
42
61
  merges: XlsxParsedMerge[];
62
+ dimension: XlsxParsedDimension | null;
43
63
  }
44
64
 
45
65
  /**
@@ -54,6 +74,7 @@ export function parseSheetXml(
54
74
  ): SheetParseResult {
55
75
  const cells: XlsxParsedCell[] = [];
56
76
  const merges: XlsxParsedMerge[] = [];
77
+ const dimension = parseSheetDimension(xml);
57
78
 
58
79
  const sheetDataMatch = /<sheetData>([\s\S]*?)<\/sheetData>/i.exec(xml);
59
80
  if (sheetDataMatch) {
@@ -65,7 +86,7 @@ export function parseSheetXml(
65
86
  parseMergeCells(mergeCellsMatch[1] ?? "", merges);
66
87
  }
67
88
 
68
- return { cells, merges };
89
+ return { cells, merges, dimension };
69
90
  }
70
91
 
71
92
  function parseSheetData(
@@ -145,10 +166,12 @@ function resolveCellValue(
145
166
  ): XlsxParsedCellValue {
146
167
  // Formula cells may have any result type; store formula + cached value.
147
168
  if (formulaText !== null) {
169
+ const decodedFormula = decodeXmlEntities(formulaText);
148
170
  return {
149
171
  type: "formula",
150
- formula: decodeXmlEntities(formulaText),
151
- cachedValue: rawValue !== null ? decodeXmlEntities(rawValue) : null,
172
+ formula: decodedFormula,
173
+ referenceTokens: extractFormulaReferenceTokens(decodedFormula),
174
+ cachedValue: resolveFormulaCachedValue(typeCode, rawValue, inlineText, sharedStrings),
152
175
  };
153
176
  }
154
177
 
@@ -160,11 +183,11 @@ function resolveCellValue(
160
183
  !Number.isNaN(index) && index >= 0 && index < sharedStrings.length
161
184
  ? (sharedStrings[index] ?? "")
162
185
  : "";
163
- return { type: "text", value: text };
186
+ return { type: "text", value: text, fromSharedString: true };
164
187
  }
165
188
 
166
189
  case "inlineStr": {
167
- return { type: "text", value: inlineText ?? "" };
190
+ return { type: "text", value: inlineText ?? "", fromSharedString: false };
168
191
  }
169
192
 
170
193
  case "str": {
@@ -173,6 +196,7 @@ function resolveCellValue(
173
196
  return {
174
197
  type: "text",
175
198
  value: rawValue !== null ? decodeXmlEntities(rawValue) : "",
199
+ fromSharedString: false,
176
200
  };
177
201
  }
178
202
 
@@ -202,6 +226,61 @@ function resolveCellValue(
202
226
  }
203
227
  }
204
228
 
229
+ function resolveFormulaCachedValue(
230
+ typeCode: string,
231
+ rawValue: string | null,
232
+ inlineText: string | null,
233
+ sharedStrings: readonly string[],
234
+ ): XlsxParsedFormulaCachedValue | null {
235
+ switch (typeCode) {
236
+ case "s": {
237
+ const index = rawValue !== null ? parseInt(rawValue, 10) : NaN;
238
+ return {
239
+ type: "text",
240
+ value:
241
+ !Number.isNaN(index) && index >= 0 && index < sharedStrings.length
242
+ ? (sharedStrings[index] ?? "")
243
+ : "",
244
+ };
245
+ }
246
+
247
+ case "inlineStr":
248
+ return { type: "text", value: inlineText ?? "" };
249
+
250
+ case "str":
251
+ return rawValue === null
252
+ ? { type: "blank" }
253
+ : { type: "text", value: decodeXmlEntities(rawValue) };
254
+
255
+ case "b":
256
+ return rawValue === null
257
+ ? { type: "blank" }
258
+ : { type: "boolean", value: rawValue === "1" };
259
+
260
+ case "e":
261
+ return rawValue === null
262
+ ? { type: "blank" }
263
+ : { type: "error", errorCode: decodeXmlEntities(rawValue) };
264
+
265
+ default: {
266
+ if (rawValue === null || rawValue === "") {
267
+ return { type: "blank" };
268
+ }
269
+
270
+ const numericValue = Number(rawValue);
271
+ if (!Number.isNaN(numericValue)) {
272
+ return { type: "number", value: numericValue };
273
+ }
274
+
275
+ if (rawValue === "TRUE" || rawValue === "FALSE") {
276
+ return { type: "boolean", value: rawValue === "TRUE" };
277
+ }
278
+
279
+ return { type: "text", value: decodeXmlEntities(rawValue) };
280
+ }
281
+ }
282
+ }
283
+
205
284
  function extractTextContent(xml: string, tagName: string): string | null {
206
285
  const pattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>([^<]*)</${tagName}>`, "i");
207
286
  const match = pattern.exec(xml);
@@ -260,6 +339,97 @@ function parseMergeRef(ref: string): XlsxParsedMerge | null {
260
339
  };
261
340
  }
262
341
 
342
+ function parseSheetDimension(xml: string): XlsxParsedDimension | null {
343
+ const dimensionMatch = /<dimension\b([^>]*?)(?:\/>|>)/i.exec(xml);
344
+ if (!dimensionMatch) {
345
+ return null;
346
+ }
347
+
348
+ const attrs = parseXmlAttributes(dimensionMatch[1] ?? "");
349
+ const ref = attrs["ref"];
350
+ if (!ref) {
351
+ return null;
352
+ }
353
+
354
+ return parseDimensionRef(ref);
355
+ }
356
+
357
+ const SHEET_PREFIX_PATTERN =
358
+ "(?:'(?:[^']|'')+'|[A-Za-z_][A-Za-z0-9_.]*)!";
359
+ const CELL_REFERENCE_PATTERN = "\\$?[A-Z]{1,3}\\$?[1-9][0-9]*";
360
+ const RANGE_REFERENCE_PATTERN =
361
+ `${CELL_REFERENCE_PATTERN}(?::${CELL_REFERENCE_PATTERN})?`;
362
+ const FORMULA_REFERENCE_PATTERN = new RegExp(
363
+ String.raw`(?<![A-Za-z0-9_.$])(?:${SHEET_PREFIX_PATTERN})?${RANGE_REFERENCE_PATTERN}(?![A-Za-z0-9_])`,
364
+ "g",
365
+ );
366
+
367
+ function extractFormulaReferenceTokens(formula: string): string[] {
368
+ const maskedFormula = maskQuotedFormulaStrings(formula);
369
+ const tokens: string[] = [];
370
+ const seen = new Set<string>();
371
+
372
+ for (const match of maskedFormula.matchAll(FORMULA_REFERENCE_PATTERN)) {
373
+ const index = match.index ?? -1;
374
+ if (index < 0) {
375
+ continue;
376
+ }
377
+ const token = formula.slice(index, index + match[0].length);
378
+ if (seen.has(token)) {
379
+ continue;
380
+ }
381
+ seen.add(token);
382
+ tokens.push(token);
383
+ }
384
+
385
+ return tokens;
386
+ }
387
+
388
+ function maskQuotedFormulaStrings(formula: string): string {
389
+ let output = "";
390
+ let inString = false;
391
+
392
+ for (let index = 0; index < formula.length; index += 1) {
393
+ const char = formula[index];
394
+ const next = formula[index + 1];
395
+
396
+ if (char === "\"") {
397
+ output += char;
398
+ if (inString && next === "\"") {
399
+ output += " ";
400
+ index += 1;
401
+ continue;
402
+ }
403
+ inString = !inString;
404
+ continue;
405
+ }
406
+
407
+ output += inString ? " " : char;
408
+ }
409
+
410
+ return output;
411
+ }
412
+
413
+ function parseDimensionRef(ref: string): XlsxParsedDimension | null {
414
+ const [startRef, endRef = startRef] = ref.split(":");
415
+ if (!startRef || !endRef) {
416
+ return null;
417
+ }
418
+
419
+ const start = parseCellRef(startRef);
420
+ const end = parseCellRef(endRef);
421
+ if (start === null || end === null) {
422
+ return null;
423
+ }
424
+
425
+ return {
426
+ startRow: start.row,
427
+ startCol: start.col,
428
+ endRow: end.row,
429
+ endCol: end.col,
430
+ };
431
+ }
432
+
263
433
  function parseCellRef(ref: string): { row: number; col: number } | null {
264
434
  const match = /^([A-Z]+)([0-9]+)$/i.exec(ref.trim());
265
435
  if (!match) {
@@ -9,6 +9,7 @@ export interface XlsxStyleEntry {
9
9
  fontId: number;
10
10
  fillId: number;
11
11
  borderId: number;
12
+ rawAttributes: Record<string, string>;
12
13
  }
13
14
 
14
15
  export function parseStylesXml(xml: string): XlsxStyleEntry[] {
@@ -29,6 +30,7 @@ export function parseStylesXml(xml: string): XlsxStyleEntry[] {
29
30
  fontId: parseIntAttr(attrs["fontId"], 0),
30
31
  fillId: parseIntAttr(attrs["fillId"], 0),
31
32
  borderId: parseIntAttr(attrs["borderId"], 0),
33
+ rawAttributes: attrs,
32
34
  });
33
35
  }
34
36
 
@@ -33,7 +33,7 @@ import { parseWorkbookXml } from "./parse-workbook.ts";
33
33
  import { parseSharedStringsXml } from "./parse-shared-strings.ts";
34
34
  import { parseStylesXml, parseXmlAttributes } from "./parse-styles.ts";
35
35
  import { parseSheetXml } from "./parse-sheet.ts";
36
- import type { XlsxParsedCellValue } from "./parse-sheet.ts";
36
+ import type { XlsxParsedCellValue, XlsxParsedFormulaCachedValue } from "./parse-sheet.ts";
37
37
 
38
38
  // ---------------------------------------------------------------------------
39
39
  // Relationship type constants (SpreadsheetML / OPC)
@@ -177,7 +177,7 @@ function buildStyleRegistry(stylesXml: string): WorkbookStyleRegistry {
177
177
  fillId: entry.fillId,
178
178
  borderId: entry.borderId,
179
179
  numFmtId: entry.numFmtId,
180
- rawAttributes: {},
180
+ rawAttributes: entry.rawAttributes,
181
181
  }),
182
182
  );
183
183
 
@@ -239,7 +239,7 @@ function convertParsedCellValue(
239
239
  return styleRef ? makeBlankCell(styleRef) : null;
240
240
 
241
241
  case "text":
242
- return makeTextCell(parsed.value, false, styleRef);
242
+ return makeTextCell(parsed.value, parsed.fromSharedString, styleRef);
243
243
 
244
244
  case "number":
245
245
  return makeNumberCell(parsed.value, styleRef);
@@ -255,6 +255,7 @@ function convertParsedCellValue(
255
255
  parsed.formula,
256
256
  convertCachedFormulaValue(parsed.cachedValue),
257
257
  styleRef,
258
+ parsed.referenceTokens,
258
259
  );
259
260
  }
260
261
  }
@@ -282,19 +283,24 @@ function normalizeErrorCode(raw: string): CellErrorCode {
282
283
  * parse layer does not currently surface for formula cells.
283
284
  */
284
285
  function convertCachedFormulaValue(
285
- raw: string | null,
286
+ cachedValue: XlsxParsedFormulaCachedValue | null,
286
287
  ): CachedFormulaValue | undefined {
287
- if (raw === null) {
288
+ if (cachedValue === null) {
288
289
  return undefined;
289
290
  }
290
- const num = Number(raw);
291
- if (!Number.isNaN(num)) {
292
- return { type: "number", value: num };
293
- }
294
- if (raw === "TRUE" || raw === "FALSE") {
295
- return { type: "boolean", value: raw === "TRUE" };
291
+
292
+ switch (cachedValue.type) {
293
+ case "blank":
294
+ return { type: "blank" };
295
+ case "number":
296
+ return { type: "number", value: cachedValue.value };
297
+ case "text":
298
+ return { type: "text", value: cachedValue.value };
299
+ case "boolean":
300
+ return { type: "boolean", value: cachedValue.value };
301
+ case "error":
302
+ return { type: "error", errorCode: normalizeErrorCode(cachedValue.errorCode) };
296
303
  }
297
- return { type: "text", value: raw };
298
304
  }
299
305
 
300
306
  // ---------------------------------------------------------------------------