@beyondwork/docx-react-component 1.0.49 → 1.0.51
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 +1 -1
- package/src/api/public-types.ts +20 -1
- package/src/core/commands/index.ts +21 -0
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/runtime/document-runtime.ts +24 -1
- package/src/runtime/layout/layout-engine-version.ts +52 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +22 -1
- package/src/runtime/render/render-kernel.ts +80 -12
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +4 -2
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel-style number-format engine for chart tick labels + data labels
|
|
3
|
+
* (Stage 3A, pure math).
|
|
4
|
+
*
|
|
5
|
+
* Supports the top ~20 real-world format codes:
|
|
6
|
+
* - Digit placeholders: `0` (required digit), `#` (optional digit).
|
|
7
|
+
* - Decimal point: `.`.
|
|
8
|
+
* - Thousands separator: `,` between digit placeholders.
|
|
9
|
+
* - Percent: `%` — scales value by 100 and appends `%`.
|
|
10
|
+
* - Currency: `$` and quoted literals like `"$"`.
|
|
11
|
+
* - Scientific: `0.00E+00` style.
|
|
12
|
+
* - Date tokens: `yyyy` `yy` `mm` `m` `mmm` `mmmm` `dd` `d`.
|
|
13
|
+
* - Time tokens: `hh` `h` `mm` (contextual) `ss`.
|
|
14
|
+
* - `General`: fall back to JavaScript's default `toString()`.
|
|
15
|
+
* - `@`: text placeholder — returns the value verbatim (stringified).
|
|
16
|
+
*
|
|
17
|
+
* Out of scope (deferred post-v1):
|
|
18
|
+
* - Conditional formats (`[>100]...;...`).
|
|
19
|
+
* - Locale-specific formats (`[$-409]`).
|
|
20
|
+
* - Color tokens (`[Red]`, `[Blue]`).
|
|
21
|
+
* - Fraction formats (`# ?/?`).
|
|
22
|
+
*
|
|
23
|
+
* Unknown / malformed codes fall through to `String(value)` so the
|
|
24
|
+
* renderer never produces `NaN` or an exception at render time.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public entry point
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export function formatNumber(value: number, code: string | undefined): string {
|
|
32
|
+
if (code === undefined || code === "" || code.toLowerCase() === "general") {
|
|
33
|
+
return Number.isFinite(value) ? String(value) : "";
|
|
34
|
+
}
|
|
35
|
+
if (code === "@") return String(value);
|
|
36
|
+
|
|
37
|
+
if (!Number.isFinite(value)) return "";
|
|
38
|
+
|
|
39
|
+
// Date/time codes contain y/m/d/h/s letter tokens outside literals.
|
|
40
|
+
// Detect and route to the date formatter.
|
|
41
|
+
if (isDateFormatCode(code)) {
|
|
42
|
+
return formatDate(value, code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Scientific: "0.00E+00" / "0E+0"
|
|
46
|
+
if (/E[+-]?0/iu.test(code)) {
|
|
47
|
+
return formatScientific(value, code);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return formatDecimal(value, code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Decimal formatter
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function formatDecimal(value: number, code: string): string {
|
|
58
|
+
// Extract percent, currency prefix, thousands separator, and decimal
|
|
59
|
+
// precision from the format code. We scan for digit placeholders
|
|
60
|
+
// (0, #) and the decimal point.
|
|
61
|
+
let working = value;
|
|
62
|
+
const tokens = tokenizeLiterals(code);
|
|
63
|
+
|
|
64
|
+
// Find a sample of the digit-placeholder substring to measure precision.
|
|
65
|
+
const digitRun = tokens
|
|
66
|
+
.filter((t) => t.kind === "digits")
|
|
67
|
+
.map((t) => t.value)
|
|
68
|
+
.join("");
|
|
69
|
+
|
|
70
|
+
// Percent is a separate token from the tokenizer; scale accordingly.
|
|
71
|
+
const hasPercent = tokens.some((t) => t.kind === "percent");
|
|
72
|
+
if (hasPercent) working *= 100;
|
|
73
|
+
|
|
74
|
+
const decimalIdx = digitRun.indexOf(".");
|
|
75
|
+
const fractionDigits = decimalIdx >= 0 ? digitRun.length - decimalIdx - 1 : 0;
|
|
76
|
+
const useThousands = /,(?=[0#])/.test(digitRun);
|
|
77
|
+
const absWorking = Math.abs(working);
|
|
78
|
+
|
|
79
|
+
const [intPart, fracPart] = absWorking
|
|
80
|
+
.toFixed(Math.max(0, fractionDigits))
|
|
81
|
+
.split(".");
|
|
82
|
+
|
|
83
|
+
const intGrouped = useThousands ? groupThousands(intPart!) : intPart!;
|
|
84
|
+
const formatted =
|
|
85
|
+
fracPart !== undefined && fracPart.length > 0
|
|
86
|
+
? `${intGrouped}.${fracPart}`
|
|
87
|
+
: intGrouped;
|
|
88
|
+
const signed = working < 0 ? `-${formatted}` : formatted;
|
|
89
|
+
|
|
90
|
+
// Stitch back prefix/suffix literals around the numeric body. We
|
|
91
|
+
// replace the first run of digit placeholders with the formatted
|
|
92
|
+
// number; other literals (currency symbols, spaces, "%") stay.
|
|
93
|
+
let produced = false;
|
|
94
|
+
const parts: string[] = [];
|
|
95
|
+
for (const t of tokens) {
|
|
96
|
+
if (t.kind === "digits") {
|
|
97
|
+
if (!produced) {
|
|
98
|
+
parts.push(signed);
|
|
99
|
+
produced = true;
|
|
100
|
+
}
|
|
101
|
+
// drop subsequent digit runs — they were part of the numeric
|
|
102
|
+
// template we already substituted.
|
|
103
|
+
} else if (t.kind === "literal") {
|
|
104
|
+
parts.push(t.value);
|
|
105
|
+
} else if (t.kind === "percent") {
|
|
106
|
+
parts.push("%");
|
|
107
|
+
} else if (t.kind === "currency") {
|
|
108
|
+
parts.push(t.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!produced) parts.push(signed);
|
|
112
|
+
return parts.join("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function groupThousands(intPart: string): string {
|
|
116
|
+
return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Scientific formatter
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function formatScientific(value: number, code: string): string {
|
|
124
|
+
const match = /(0+)(\.0+)?[eE]([+-]?)(0+)/u.exec(code);
|
|
125
|
+
if (!match) return value.toExponential();
|
|
126
|
+
const mantissaInt = match[1]!.length;
|
|
127
|
+
const mantissaFrac = match[2] ? match[2].length - 1 : 0;
|
|
128
|
+
const signToken = match[3] ?? "";
|
|
129
|
+
const fracDigits = mantissaInt + mantissaFrac - 1;
|
|
130
|
+
const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
|
|
131
|
+
const mantissa = value / Math.pow(10, exp);
|
|
132
|
+
const mantissaStr = mantissa.toFixed(Math.max(0, fracDigits));
|
|
133
|
+
const sign = signToken === "+" ? (exp >= 0 ? "+" : "-") : exp < 0 ? "-" : "";
|
|
134
|
+
const expStr = String(Math.abs(exp)).padStart(match[4]!.length, "0");
|
|
135
|
+
return `${mantissaStr}E${sign}${expStr}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Date formatter
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const DATE_CODE_PATTERN = /\b(yyyy|yy|mmmm|mmm|mm|m|dd|d|hh|h|ss)\b/iu;
|
|
143
|
+
|
|
144
|
+
function isDateFormatCode(code: string): boolean {
|
|
145
|
+
// Strip quoted literals to avoid false positives on quoted text.
|
|
146
|
+
const stripped = code.replace(/"[^"]*"/g, "");
|
|
147
|
+
return DATE_CODE_PATTERN.test(stripped);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatDate(serial: number, code: string): string {
|
|
151
|
+
// Excel serial → Date. Epoch 1899-12-30.
|
|
152
|
+
const ms = (serial - 25569) * 86_400_000;
|
|
153
|
+
const date = new Date(ms);
|
|
154
|
+
const y = date.getUTCFullYear();
|
|
155
|
+
const m = date.getUTCMonth() + 1;
|
|
156
|
+
const d = date.getUTCDate();
|
|
157
|
+
const hh = date.getUTCHours();
|
|
158
|
+
const mi = date.getUTCMinutes();
|
|
159
|
+
const ss = date.getUTCSeconds();
|
|
160
|
+
|
|
161
|
+
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
162
|
+
const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
163
|
+
|
|
164
|
+
// Parse the format by splitting on literal-vs-token boundaries.
|
|
165
|
+
// Process longest tokens first (yyyy before yy, mmmm before mmm/mm, etc.).
|
|
166
|
+
let out = "";
|
|
167
|
+
let i = 0;
|
|
168
|
+
let sawTime = false; // `mm` after h/hh means minutes
|
|
169
|
+
const lowered = code.toLowerCase();
|
|
170
|
+
while (i < code.length) {
|
|
171
|
+
const ch = code[i]!;
|
|
172
|
+
if (ch === '"') {
|
|
173
|
+
const end = code.indexOf('"', i + 1);
|
|
174
|
+
if (end === -1) { out += code.slice(i + 1); break; }
|
|
175
|
+
out += code.slice(i + 1, end);
|
|
176
|
+
i = end + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Try 4/3/2/1-char tokens at this position.
|
|
180
|
+
const tok4 = lowered.slice(i, i + 4);
|
|
181
|
+
const tok3 = lowered.slice(i, i + 3);
|
|
182
|
+
const tok2 = lowered.slice(i, i + 2);
|
|
183
|
+
const tok1 = lowered.slice(i, i + 1);
|
|
184
|
+
if (tok4 === "yyyy") { out += String(y).padStart(4, "0"); i += 4; continue; }
|
|
185
|
+
if (tok4 === "mmmm") { out += MONTHS_LONG[m - 1]!; i += 4; continue; }
|
|
186
|
+
if (tok3 === "mmm") { out += MONTHS_SHORT[m - 1]!; i += 3; continue; }
|
|
187
|
+
if (tok2 === "yy") { out += String(y).slice(-2); i += 2; continue; }
|
|
188
|
+
if (tok2 === "hh") { out += pad2(hh); sawTime = true; i += 2; continue; }
|
|
189
|
+
if (tok2 === "mm") {
|
|
190
|
+
if (sawTime) {
|
|
191
|
+
out += pad2(mi);
|
|
192
|
+
sawTime = false;
|
|
193
|
+
} else {
|
|
194
|
+
out += pad2(m);
|
|
195
|
+
}
|
|
196
|
+
i += 2;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (tok2 === "ss") { out += pad2(ss); i += 2; continue; }
|
|
200
|
+
if (tok2 === "dd") { out += pad2(d); i += 2; continue; }
|
|
201
|
+
if (tok1 === "h") { out += String(hh); sawTime = true; i += 1; continue; }
|
|
202
|
+
if (tok1 === "m") { out += sawTime ? String(mi) : String(m); if (sawTime) sawTime = false; i += 1; continue; }
|
|
203
|
+
if (tok1 === "d") { out += String(d); i += 1; continue; }
|
|
204
|
+
if (tok1 === "y") { out += String(y); i += 1; continue; }
|
|
205
|
+
out += ch;
|
|
206
|
+
i += 1;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pad2(n: number): string {
|
|
212
|
+
return n < 10 ? `0${n}` : String(n);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Literal tokenization for decimal format codes
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
interface FormatToken {
|
|
220
|
+
kind: "digits" | "literal" | "percent" | "currency";
|
|
221
|
+
value: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Split a decimal format code into a stream of tokens so the formatter
|
|
226
|
+
* can re-stitch prefix/suffix literals around the formatted number.
|
|
227
|
+
*
|
|
228
|
+
* Examples:
|
|
229
|
+
* `"$#,##0.00"` → [{currency "$"}, {digits "#,##0.00"}]
|
|
230
|
+
* `"0.00%"` → [{digits "0.00"}, {percent}]
|
|
231
|
+
* `"\"Total: \"0"` → [{literal "Total: "}, {digits "0"}]
|
|
232
|
+
*/
|
|
233
|
+
function tokenizeLiterals(code: string): FormatToken[] {
|
|
234
|
+
const out: FormatToken[] = [];
|
|
235
|
+
let i = 0;
|
|
236
|
+
let digitBuf = "";
|
|
237
|
+
const flushDigits = () => {
|
|
238
|
+
if (digitBuf) {
|
|
239
|
+
out.push({ kind: "digits", value: digitBuf });
|
|
240
|
+
digitBuf = "";
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
while (i < code.length) {
|
|
244
|
+
const ch = code[i]!;
|
|
245
|
+
if (ch === '"') {
|
|
246
|
+
flushDigits();
|
|
247
|
+
const end = code.indexOf('"', i + 1);
|
|
248
|
+
const lit = end === -1 ? code.slice(i + 1) : code.slice(i + 1, end);
|
|
249
|
+
out.push({ kind: "literal", value: lit });
|
|
250
|
+
i = end === -1 ? code.length : end + 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (ch === "\\") {
|
|
254
|
+
flushDigits();
|
|
255
|
+
if (i + 1 < code.length) {
|
|
256
|
+
out.push({ kind: "literal", value: code[i + 1]! });
|
|
257
|
+
i += 2;
|
|
258
|
+
} else {
|
|
259
|
+
i += 1;
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ch === "$") {
|
|
264
|
+
flushDigits();
|
|
265
|
+
out.push({ kind: "currency", value: "$" });
|
|
266
|
+
i += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (ch === "%") {
|
|
270
|
+
flushDigits();
|
|
271
|
+
out.push({ kind: "percent", value: "%" });
|
|
272
|
+
i += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch === "0" || ch === "#" || ch === "." || ch === "," || ch === " ") {
|
|
276
|
+
digitBuf += ch;
|
|
277
|
+
i += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Unknown char — treat as literal.
|
|
281
|
+
flushDigits();
|
|
282
|
+
out.push({ kind: "literal", value: ch });
|
|
283
|
+
i += 1;
|
|
284
|
+
}
|
|
285
|
+
flushDigits();
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
@@ -11,8 +11,29 @@ import {
|
|
|
11
11
|
extractPlainTextSegments,
|
|
12
12
|
type PastePlainSegment,
|
|
13
13
|
} from "./paste-plain-text";
|
|
14
|
+
import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
|
|
14
15
|
import type { PositionMap } from "./pm-position-map";
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* I2 Tier B Slice 2 — MIME types Word + the browser use for WordprocessingML
|
|
19
|
+
* clipboard payloads. The first one is the legacy MS-Office HTML-embedded
|
|
20
|
+
* format; the second is the native Word clipboard type. Browsers expose both
|
|
21
|
+
* under `ClipboardEvent.clipboardData.getData(mime)`.
|
|
22
|
+
*/
|
|
23
|
+
const WORDML_MIMES = [
|
|
24
|
+
"application/x-docx-fragment",
|
|
25
|
+
"application/vnd.ms-word.wordprocessingml.paste",
|
|
26
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
function readWordMLPayload(clipboard: DataTransfer): string | null {
|
|
30
|
+
for (const mime of WORDML_MIMES) {
|
|
31
|
+
const value = clipboard.getData(mime);
|
|
32
|
+
if (value && value.trim().length > 0) return value;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
/**
|
|
17
38
|
* Callback subset used by paste / drop dispatch. Exported so tests can
|
|
18
39
|
* record dispatch order without constructing the full
|
|
@@ -93,6 +114,18 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
|
|
|
93
114
|
charCount: number;
|
|
94
115
|
source: "paste" | "drop";
|
|
95
116
|
}) => void;
|
|
117
|
+
/**
|
|
118
|
+
* I2 Tier B Slice 2 — optional. Fires when the paste handler detects an
|
|
119
|
+
* Office-clipboard WordprocessingML payload and parses it successfully into
|
|
120
|
+
* a canonical fragment. The host is responsible for dispatching
|
|
121
|
+
* `runtime.insertFragment(fragment)`; the bridge does not reach into the
|
|
122
|
+
* runtime directly so this plumbing stays consistent with the Tier A
|
|
123
|
+
* plain-text callback pattern.
|
|
124
|
+
*/
|
|
125
|
+
onPasteFragment?: (meta: {
|
|
126
|
+
fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
|
|
127
|
+
source: "wordml";
|
|
128
|
+
}) => void;
|
|
96
129
|
/**
|
|
97
130
|
* Optional. Fires on `compositionstart` (true) and `compositionend`
|
|
98
131
|
* (false). The surface forwards this to the predicted lane's session
|
|
@@ -195,11 +228,17 @@ export function createCommandBridgePlugins(
|
|
|
195
228
|
return true; // Block PM from processing
|
|
196
229
|
},
|
|
197
230
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
231
|
+
// I2 paste handler — Tier B (WordML) preferred, Tier A (plain) fallback.
|
|
232
|
+
//
|
|
233
|
+
// Preference order per `docs/plans/lane-1-i2-tier-b-rich-paste.md`:
|
|
234
|
+
// 1. Office-clipboard WordprocessingML payload if the host wired
|
|
235
|
+
// `onPasteFragment` AND the clipboard carries the MIME. Parsed via
|
|
236
|
+
// `parseCanonicalFragmentFromWordML`.
|
|
237
|
+
// 2. Plain text via `extractPlainTextSegments` (Tier A).
|
|
238
|
+
// 3. `onBlockedInput` for HTML-only / empty payloads.
|
|
239
|
+
//
|
|
240
|
+
// Rich-paste fallback on parse failure or missing host callback: fall
|
|
241
|
+
// through to Tier A so the user isn't left with a silent no-op.
|
|
203
242
|
handlePaste(_view, event) {
|
|
204
243
|
if (isComposing) return true;
|
|
205
244
|
const clipboard = event.clipboardData;
|
|
@@ -207,6 +246,22 @@ export function createCommandBridgePlugins(
|
|
|
207
246
|
callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
|
|
208
247
|
return true;
|
|
209
248
|
}
|
|
249
|
+
|
|
250
|
+
// Tier B: WordprocessingML
|
|
251
|
+
if (callbacks.onPasteFragment) {
|
|
252
|
+
const wordml = readWordMLPayload(clipboard);
|
|
253
|
+
if (wordml) {
|
|
254
|
+
const parsed = parseCanonicalFragmentFromWordML(wordml);
|
|
255
|
+
if (parsed.ok && parsed.fragment.blocks.length > 0) {
|
|
256
|
+
callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
// Parse failed or empty — fall through to plain-text so the paste
|
|
260
|
+
// still does something (defensive against malformed clipboard payloads).
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Tier A: plain text
|
|
210
265
|
const plain = clipboard.getData("text/plain");
|
|
211
266
|
if (!plain) {
|
|
212
267
|
callbacks.onBlockedInput?.(
|