@rowsncolumns/utils 9.4.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC"}
package/dist/esm/index.js CHANGED
@@ -28,3 +28,5 @@ export * from "./formula-locale";
28
28
  export * from "./table-names";
29
29
  export * from "./table-totals";
30
30
  export * from "./sparkline-groups";
31
+ export * from "./rich-text-html";
32
+ export * from "./rich-text-links";
@@ -0,0 +1,82 @@
1
+ import type { TextFormat, TextFormatRun } from "@rowsncolumns/common-types";
2
+ /**
3
+ * Clipboard HTML → the engine's native rich-text shape.
4
+ *
5
+ * Google Sheets and Excel both put a cell's mixed formatting into `text/html`
6
+ * — Google as `<span style="…">` / `<a href>` runs inside a `<td>` (wrapped in
7
+ * `<google-sheets-html-origin>`), Excel as `<b>` / `<font color>` /
8
+ * `<font class="fontN">` (class rules in `<head><style>`) / `<span style>` /
9
+ * `<a href>` inside a `<td>`. This module is the ONE converter every paste
10
+ * target uses (the grid's cell paste in `spreadsheet-state/api/parse-html`,
11
+ * the formula bar and the in-cell editor in `ui/editor-pm`), so a rich cell
12
+ * lands identically wherever it is pasted:
13
+ *
14
+ * - `text` — the full text, whitespace normalized like `extractTextContent`
15
+ * (source-newlines collapse to a space, `<br>` / block boundaries become
16
+ * `\n`, outer whitespace trimmed).
17
+ * - `runs` — `TextFormatRun`s RELATIVE to `baseFormat` (only the properties a
18
+ * segment changes), adjacent equal segments merged, empty formats dropped —
19
+ * the same normal form `extractTextFormatRuns` produces from the editor.
20
+ * `undefined` when the text is uniformly formatted (then `baseFormat`
21
+ * already says everything).
22
+ * - `links` — every `<a href>` in document order, as `[start, end)` spans.
23
+ * A link is also carried on its run as `format.link` (Google Sheets keeps
24
+ * several links per cell), and the engines promote the first run link to the
25
+ * cell-level `hyperlink` on commit (Excel keeps one link per cell).
26
+ * - `hyperlink` — the cell-level link: an anchor wrapping the whole text
27
+ * (Excel), else the first link.
28
+ * - `baseFormat` — the container's own formatting (the `<td>` class / inline
29
+ * style), i.e. the CELL-level text format of the source cell.
30
+ *
31
+ * Styles are read from the inline `style`, the tag semantics and
32
+ * `getComputedStyle` (the element must be mounted — see
33
+ * {@link mountClipboardHtml}) so Excel's class-based `<font class="fontN">`
34
+ * runs resolve. `<a>` elements read inline styles only: a browser's UA sheet
35
+ * would otherwise paint every pasted link `-webkit-link` blue.
36
+ */
37
+ export type RichTextLink = {
38
+ startIndex: number;
39
+ endIndex: number;
40
+ uri: string;
41
+ };
42
+ export type ParsedRichText = {
43
+ text: string;
44
+ runs: TextFormatRun[] | undefined;
45
+ links: RichTextLink[];
46
+ hyperlink: string | undefined;
47
+ baseFormat: TextFormat;
48
+ /** Mixed formatting or at least one link — the reasons to prefer runs over flat text. */
49
+ isRich: boolean;
50
+ };
51
+ /**
52
+ * Parse an HTML clipboard payload into a hidden, mounted sandbox so
53
+ * `getComputedStyle` resolves the payload's own `<style>` rules (Excel's
54
+ * `.fontN` / `.xlNN` classes). Callers must `cleanup()` when done.
55
+ */
56
+ export declare const mountClipboardHtml: (clipboardValue: string) => {
57
+ root: HTMLDivElement;
58
+ cleanup: () => void;
59
+ };
60
+ export declare const cssColorToHex: (value: string | null | undefined) => string | undefined;
61
+ /** CSS font-size → points. `pt` verbatim, `px` at 96dpi, `em`/`rem` on a 16px base. */
62
+ export declare const cssFontSizeToPt: (value: string | null | undefined) => number | undefined;
63
+ /**
64
+ * Convert a mounted element (a clipboard `<td>`, or the sandbox wrapper for
65
+ * table-less HTML) into the engine's rich-text shape. See the module doc.
66
+ */
67
+ export declare const htmlElementToRichText: (root: HTMLElement) => ParsedRichText;
68
+ /**
69
+ * The element a single-cell clipboard payload's text lives in: the lone
70
+ * `<td>`/`<th>` of a one-cell table (Excel / Google Sheets), or the mounted
71
+ * wrapper for table-less HTML. `undefined` for a multi-cell table — that is a
72
+ * grid paste, not a rich-text one.
73
+ */
74
+ export declare const findSingleClipboardCell: (root: HTMLElement) => HTMLElement | undefined;
75
+ /**
76
+ * One-shot helper for the editor targets: mount the clipboard HTML, convert
77
+ * the single cell it carries, clean up. `undefined` when the payload is a
78
+ * multi-cell table or carries no formatting/links worth keeping (the caller
79
+ * then falls back to its plain-text handling).
80
+ */
81
+ export declare const clipboardHtmlToRichText: (html: string) => ParsedRichText | undefined;
82
+ //# sourceMappingURL=rich-text-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-text-html.d.ts","sourceRoot":"","sources":["../../rich-text-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAK5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,aAAa,EAAE,GAAG,SAAS,CAAC;IAClC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,UAAU,EAAE,UAAU,CAAC;IACvB,yFAAyF;IACzF,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAgGF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,gBAAgB,MAAM;;;CAmBxD,CAAC;AAEF,eAAO,MAAM,aAAa,GACxB,OAAO,MAAM,GAAG,IAAI,GAAG,SAAS,KAC/B,MAAM,GAAG,SAkBX,CAAC;AAEF,uFAAuF;AACvF,eAAO,MAAM,eAAe,GAC1B,OAAO,MAAM,GAAG,IAAI,GAAG,SAAS,KAC/B,MAAM,GAAG,SAoBX,CAAC;AA+SF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,MAAM,WAAW,KAAG,cA+DzD,CAAC;AAgBF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,WAAW,KAChB,WAAW,GAAG,SAShB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,MAAM,KACX,cAAc,GAAG,SAkBnB,CAAC"}
@@ -0,0 +1,520 @@
1
+ import { colorStringToHex } from "./color";
2
+ import { canUseDOM } from "./dom";
3
+ const BLOCK_TAGS = new Set([
4
+ "p",
5
+ "div",
6
+ "li",
7
+ "ul",
8
+ "ol",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "pre",
16
+ "tr",
17
+ "blockquote",
18
+ ]);
19
+ const SKIPPED_TAGS = new Set([
20
+ "style",
21
+ "script",
22
+ "meta",
23
+ "head",
24
+ "title",
25
+ "link",
26
+ "col",
27
+ "colgroup",
28
+ "template",
29
+ "noscript",
30
+ ]);
31
+ // `<font size="N">` → points (HTML 4 size keywords).
32
+ const FONT_SIZE_KEYWORDS = {
33
+ 1: 8,
34
+ 2: 10,
35
+ 3: 12,
36
+ 4: 14,
37
+ 5: 18,
38
+ 6: 24,
39
+ 7: 36,
40
+ };
41
+ // CSS named colors Excel / Word actually emit (`color:red`, `<font color=red>`).
42
+ // `getComputedStyle` in a browser already resolves names to `rgb()`, this
43
+ // table covers inline keywords and jsdom (which reports the declared value).
44
+ const NAMED_COLORS = {
45
+ black: "#000000",
46
+ white: "#FFFFFF",
47
+ red: "#FF0000",
48
+ green: "#008000",
49
+ lime: "#00FF00",
50
+ blue: "#0000FF",
51
+ yellow: "#FFFF00",
52
+ orange: "#FFA500",
53
+ purple: "#800080",
54
+ gray: "#808080",
55
+ grey: "#808080",
56
+ silver: "#C0C0C0",
57
+ maroon: "#800000",
58
+ navy: "#000080",
59
+ teal: "#008080",
60
+ olive: "#808000",
61
+ aqua: "#00FFFF",
62
+ cyan: "#00FFFF",
63
+ fuchsia: "#FF00FF",
64
+ magenta: "#FF00FF",
65
+ brown: "#A52A2A",
66
+ pink: "#FFC0CB",
67
+ gold: "#FFD700",
68
+ indigo: "#4B0082",
69
+ violet: "#EE82EE",
70
+ darkred: "#8B0000",
71
+ darkgreen: "#006400",
72
+ darkblue: "#00008B",
73
+ };
74
+ // Values that mean "no explicit color" — Excel's `windowtext`, CSS keywords.
75
+ const NON_COLORS = new Set([
76
+ "windowtext",
77
+ "inherit",
78
+ "initial",
79
+ "unset",
80
+ "currentcolor",
81
+ "transparent",
82
+ "auto",
83
+ "",
84
+ ]);
85
+ /**
86
+ * Parse an HTML clipboard payload into a hidden, mounted sandbox so
87
+ * `getComputedStyle` resolves the payload's own `<style>` rules (Excel's
88
+ * `.fontN` / `.xlNN` classes). Callers must `cleanup()` when done.
89
+ */
90
+ export const mountClipboardHtml = (clipboardValue) => {
91
+ const parser = new DOMParser();
92
+ const doc = parser.parseFromString(clipboardValue, "text/html");
93
+ const sandbox = document.createElement("div");
94
+ sandbox.style.cssText =
95
+ "position:fixed;left:-10000px;top:-10000px;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none;font-size:10pt;";
96
+ sandbox.innerHTML = doc.body?.innerHTML ?? "";
97
+ const styles = doc.head?.querySelectorAll("style") ?? [];
98
+ styles.forEach((style) => sandbox.appendChild(style.cloneNode(true)));
99
+ document.body.appendChild(sandbox);
100
+ return {
101
+ root: sandbox,
102
+ cleanup: () => sandbox.remove(),
103
+ };
104
+ };
105
+ export const cssColorToHex = (value) => {
106
+ if (!value) {
107
+ return undefined;
108
+ }
109
+ const trimmed = value.trim();
110
+ const lower = trimmed.toLowerCase();
111
+ if (NON_COLORS.has(lower)) {
112
+ return undefined;
113
+ }
114
+ // `rgba(0, 0, 0, 0)` is the transparent default many engines report.
115
+ if (/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(lower)) {
116
+ return undefined;
117
+ }
118
+ const hex = colorStringToHex(trimmed);
119
+ if (hex) {
120
+ return hex.toUpperCase();
121
+ }
122
+ return NAMED_COLORS[lower];
123
+ };
124
+ /** CSS font-size → points. `pt` verbatim, `px` at 96dpi, `em`/`rem` on a 16px base. */
125
+ export const cssFontSizeToPt = (value) => {
126
+ if (!value) {
127
+ return undefined;
128
+ }
129
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(pt|px|rem|em)?$/i);
130
+ if (!match) {
131
+ return undefined;
132
+ }
133
+ const numeric = parseFloat(match[1]);
134
+ if (!Number.isFinite(numeric) || numeric <= 0) {
135
+ return undefined;
136
+ }
137
+ const unit = (match[2] ?? "px").toLowerCase();
138
+ const pt = unit === "pt"
139
+ ? numeric
140
+ : unit === "px"
141
+ ? numeric * 0.75
142
+ : numeric * 16 * 0.75;
143
+ return Number(pt.toFixed(1));
144
+ };
145
+ const firstFontFamily = (value) => {
146
+ if (!value) {
147
+ return undefined;
148
+ }
149
+ const family = value
150
+ .split(",")[0]
151
+ .trim()
152
+ .replace(/^["']|["']$/g, "")
153
+ .trim();
154
+ if (!family || /^(inherit|initial|unset)$/i.test(family)) {
155
+ return undefined;
156
+ }
157
+ return family;
158
+ };
159
+ const safeComputedStyle = (element) => {
160
+ if (!canUseDOM || typeof getComputedStyle !== "function") {
161
+ return null;
162
+ }
163
+ try {
164
+ return getComputedStyle(element);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ };
170
+ const readWeight = (value) => {
171
+ if (!value) {
172
+ return undefined;
173
+ }
174
+ const lower = value.trim().toLowerCase();
175
+ if (lower === "bold" || lower === "bolder") {
176
+ return true;
177
+ }
178
+ if (lower === "normal" || lower === "lighter") {
179
+ return false;
180
+ }
181
+ const numeric = Number(lower);
182
+ if (Number.isFinite(numeric)) {
183
+ return numeric >= 600;
184
+ }
185
+ return undefined;
186
+ };
187
+ const normalizeWhitespace = (text) => text.replace(/[\n\r]+/g, " ").replace(/\s{2,}/g, " ");
188
+ /**
189
+ * The formatting an element applies on top of `inherited`: tag semantics
190
+ * (`<b>`, `<font color>`, `<a href>`, …) + inline `style` + (except for
191
+ * anchors) the cascaded computed style.
192
+ */
193
+ const readElementFormat = (element, inherited) => {
194
+ const format = { ...inherited };
195
+ const tag = element.tagName.toLowerCase();
196
+ let attrColor;
197
+ let attrFace;
198
+ let attrSize;
199
+ switch (tag) {
200
+ case "b":
201
+ case "strong":
202
+ format.bold = true;
203
+ break;
204
+ case "i":
205
+ case "em":
206
+ format.italic = true;
207
+ break;
208
+ case "u":
209
+ format.underline = true;
210
+ break;
211
+ case "s":
212
+ case "strike":
213
+ case "del":
214
+ format.strikethrough = true;
215
+ break;
216
+ case "sup":
217
+ format.baselineOffset = "superscript";
218
+ break;
219
+ case "sub":
220
+ format.baselineOffset = "subscript";
221
+ break;
222
+ case "font": {
223
+ // Presentational attributes. They rank between the inline style and
224
+ // the cascaded computed style below: a browser folds them into the
225
+ // computed value, jsdom reports the inherited one instead.
226
+ attrColor = cssColorToHex(element.getAttribute("color"));
227
+ attrFace = firstFontFamily(element.getAttribute("face"));
228
+ const size = parseInt(element.getAttribute("size") ?? "", 10);
229
+ if (!Number.isNaN(size) && FONT_SIZE_KEYWORDS[size]) {
230
+ attrSize = FONT_SIZE_KEYWORDS[size];
231
+ }
232
+ break;
233
+ }
234
+ case "a": {
235
+ const href = element.getAttribute("href");
236
+ if (href && href.trim()) {
237
+ format.link = { uri: href.trim() };
238
+ }
239
+ break;
240
+ }
241
+ }
242
+ const inline = element.style;
243
+ const computed = tag === "a" ? null : safeComputedStyle(element);
244
+ const pick = (inlineValue, computedValue) => (inlineValue && inlineValue.trim() ? inlineValue : computedValue);
245
+ const weight = readWeight(pick(inline.fontWeight, computed?.fontWeight));
246
+ if (weight !== undefined) {
247
+ format.bold = weight;
248
+ }
249
+ const fontStyle = pick(inline.fontStyle, computed?.fontStyle);
250
+ if (fontStyle) {
251
+ const lower = fontStyle.trim().toLowerCase();
252
+ if (lower === "italic" || lower === "oblique") {
253
+ format.italic = true;
254
+ }
255
+ else if (lower === "normal") {
256
+ format.italic = false;
257
+ }
258
+ }
259
+ // `text-decoration` is not inherited in CSS but propagates visually, so a
260
+ // parent's underline stays on (inherited state) unless a child says `none`.
261
+ const decoration = pick(inline.textDecoration || inline.textDecorationLine, computed?.textDecorationLine || computed?.textDecoration);
262
+ if (decoration) {
263
+ const lower = decoration.toLowerCase();
264
+ if (lower.includes("underline")) {
265
+ format.underline = true;
266
+ }
267
+ if (lower.includes("line-through")) {
268
+ format.strikethrough = true;
269
+ }
270
+ if (/^\s*none\s*$/.test(lower)) {
271
+ format.underline = false;
272
+ format.strikethrough = false;
273
+ }
274
+ }
275
+ const verticalAlign = pick(inline.verticalAlign, computed?.verticalAlign)?.toLowerCase();
276
+ if (verticalAlign === "super") {
277
+ format.baselineOffset = "superscript";
278
+ }
279
+ else if (verticalAlign === "sub") {
280
+ format.baselineOffset = "subscript";
281
+ }
282
+ else if (verticalAlign === "baseline" && tag !== "sup" && tag !== "sub") {
283
+ format.baselineOffset = null;
284
+ }
285
+ const color = cssColorToHex(inline.color) ?? attrColor ?? cssColorToHex(computed?.color);
286
+ if (color) {
287
+ format.color = color;
288
+ }
289
+ const fontSize = cssFontSizeToPt(inline.fontSize) ??
290
+ attrSize ??
291
+ cssFontSizeToPt(computed?.fontSize);
292
+ if (fontSize !== undefined) {
293
+ format.fontSize = fontSize;
294
+ }
295
+ const fontFamily = firstFontFamily(inline.fontFamily) ??
296
+ attrFace ??
297
+ firstFontFamily(computed?.fontFamily);
298
+ if (fontFamily) {
299
+ format.fontFamily = fontFamily;
300
+ }
301
+ return format;
302
+ };
303
+ const isBlockElement = (element) => {
304
+ const tag = element.tagName.toLowerCase();
305
+ if (BLOCK_TAGS.has(tag)) {
306
+ return true;
307
+ }
308
+ const display = element.style.display?.toLowerCase();
309
+ return display === "block" || display === "list-item";
310
+ };
311
+ const collectSegments = (node, inherited, segments) => {
312
+ if (node.nodeType === Node.TEXT_NODE) {
313
+ const text = normalizeWhitespace(node.textContent ?? "");
314
+ if (text) {
315
+ segments.push({ text, format: inherited });
316
+ }
317
+ return;
318
+ }
319
+ if (node.nodeType !== Node.ELEMENT_NODE) {
320
+ return;
321
+ }
322
+ const element = node;
323
+ const tag = element.tagName.toLowerCase();
324
+ if (SKIPPED_TAGS.has(tag)) {
325
+ return;
326
+ }
327
+ if (tag === "br") {
328
+ segments.push({ text: "\n", format: inherited });
329
+ return;
330
+ }
331
+ const block = isBlockElement(element);
332
+ if (block && segments.length && !endsWithNewline(segments)) {
333
+ segments.push({ text: "\n", format: inherited });
334
+ }
335
+ const format = readElementFormat(element, inherited);
336
+ for (const child of Array.from(element.childNodes)) {
337
+ collectSegments(child, format, segments);
338
+ }
339
+ if (block && segments.length && !endsWithNewline(segments)) {
340
+ segments.push({ text: "\n", format: inherited });
341
+ }
342
+ };
343
+ const endsWithNewline = (segments) => {
344
+ const last = segments[segments.length - 1];
345
+ return !!last && last.text.endsWith("\n");
346
+ };
347
+ const TEXT_KEYS = [
348
+ "bold",
349
+ "italic",
350
+ "underline",
351
+ "strikethrough",
352
+ "baselineOffset",
353
+ "color",
354
+ "fontSize",
355
+ "fontFamily",
356
+ ];
357
+ /** The properties of `format` that differ from `base` (the run's own contribution). */
358
+ const diffFormat = (format, base) => {
359
+ const diff = {};
360
+ for (const key of TEXT_KEYS) {
361
+ const value = format[key];
362
+ const baseValue = base[key];
363
+ const normalizedValue = value ?? undefined;
364
+ const normalizedBase = baseValue ?? undefined;
365
+ if (normalizedValue === normalizedBase) {
366
+ continue;
367
+ }
368
+ if (normalizedValue === undefined || normalizedValue === false) {
369
+ // A run switching OFF a base property (bold cell, normal run).
370
+ if (normalizedBase) {
371
+ diff[key] =
372
+ key === "baselineOffset" ? null : false;
373
+ }
374
+ continue;
375
+ }
376
+ // Black on an unstyled base is the default text color, not a run color.
377
+ if (key === "color" &&
378
+ normalizedBase === undefined &&
379
+ typeof normalizedValue === "string" &&
380
+ normalizedValue.toUpperCase() === "#000000") {
381
+ continue;
382
+ }
383
+ diff[key] = normalizedValue;
384
+ }
385
+ return diff;
386
+ };
387
+ const sameFormat = (a, b) => {
388
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
389
+ for (const key of keys) {
390
+ const av = a[key];
391
+ const bv = b[key];
392
+ if (key === "link") {
393
+ if (av?.uri !== bv?.uri) {
394
+ return false;
395
+ }
396
+ continue;
397
+ }
398
+ if (av !== bv) {
399
+ return false;
400
+ }
401
+ }
402
+ return true;
403
+ };
404
+ const isEmptyFormat = (format) => Object.keys(format).length === 0;
405
+ /**
406
+ * Convert a mounted element (a clipboard `<td>`, or the sandbox wrapper for
407
+ * table-less HTML) into the engine's rich-text shape. See the module doc.
408
+ */
409
+ export const htmlElementToRichText = (root) => {
410
+ const baseFormat = normalizeBase(readElementFormat(root, {}));
411
+ const segments = [];
412
+ for (const child of Array.from(root.childNodes)) {
413
+ collectSegments(child, baseFormat, segments);
414
+ }
415
+ // Trim outer whitespace (leading/trailing spaces and line breaks) so the
416
+ // text matches `extractTextContent`, shifting the segments with it.
417
+ const joined = segments.map((s) => s.text).join("");
418
+ const leading = joined.length - joined.trimStart().length;
419
+ const text = joined.trim();
420
+ const runs = [];
421
+ const links = [];
422
+ let cursor = -leading;
423
+ for (const segment of segments) {
424
+ const start = Math.max(0, cursor);
425
+ const end = Math.min(text.length, cursor + segment.text.length);
426
+ cursor += segment.text.length;
427
+ if (end <= start) {
428
+ continue;
429
+ }
430
+ const format = diffFormat(segment.format, baseFormat);
431
+ const uri = segment.format.link?.uri;
432
+ if (uri) {
433
+ format.link = { uri };
434
+ const previous = links[links.length - 1];
435
+ if (previous && previous.uri === uri && previous.endIndex === start) {
436
+ previous.endIndex = end;
437
+ }
438
+ else {
439
+ links.push({ startIndex: start, endIndex: end, uri });
440
+ }
441
+ }
442
+ if (isEmptyFormat(format)) {
443
+ continue;
444
+ }
445
+ const previous = runs[runs.length - 1];
446
+ if (previous &&
447
+ previous.nodeType === "text" &&
448
+ previous.endIndex === start &&
449
+ sameFormat(previous.format, format)) {
450
+ previous.endIndex = end;
451
+ continue;
452
+ }
453
+ runs.push({ startIndex: start, endIndex: end, format, nodeType: "text" });
454
+ }
455
+ const wholeTextLink = links.find((link) => link.startIndex === 0 && link.endIndex === text.length);
456
+ const hyperlink = wholeTextLink?.uri ?? links[0]?.uri;
457
+ return {
458
+ text,
459
+ runs: runs.length ? runs : undefined,
460
+ links,
461
+ hyperlink,
462
+ baseFormat,
463
+ isRich: runs.length > 0 || links.length > 0,
464
+ };
465
+ };
466
+ // The base drops the segment-only `link` key and `false`/`null` values — a
467
+ // container can't be "not bold" more than the default already is.
468
+ const normalizeBase = (format) => {
469
+ const base = {};
470
+ for (const key of TEXT_KEYS) {
471
+ const value = format[key];
472
+ if (value === undefined || value === null || value === false) {
473
+ continue;
474
+ }
475
+ base[key] = value;
476
+ }
477
+ return base;
478
+ };
479
+ /**
480
+ * The element a single-cell clipboard payload's text lives in: the lone
481
+ * `<td>`/`<th>` of a one-cell table (Excel / Google Sheets), or the mounted
482
+ * wrapper for table-less HTML. `undefined` for a multi-cell table — that is a
483
+ * grid paste, not a rich-text one.
484
+ */
485
+ export const findSingleClipboardCell = (root) => {
486
+ const cells = root.querySelectorAll("td,th");
487
+ if (cells.length > 1) {
488
+ return undefined;
489
+ }
490
+ if (cells.length === 1) {
491
+ return cells[0];
492
+ }
493
+ return root;
494
+ };
495
+ /**
496
+ * One-shot helper for the editor targets: mount the clipboard HTML, convert
497
+ * the single cell it carries, clean up. `undefined` when the payload is a
498
+ * multi-cell table or carries no formatting/links worth keeping (the caller
499
+ * then falls back to its plain-text handling).
500
+ */
501
+ export const clipboardHtmlToRichText = (html) => {
502
+ if (!canUseDOM || !html?.trim()) {
503
+ return undefined;
504
+ }
505
+ const { root, cleanup } = mountClipboardHtml(html);
506
+ try {
507
+ const cell = findSingleClipboardCell(root);
508
+ if (!cell) {
509
+ return undefined;
510
+ }
511
+ const parsed = htmlElementToRichText(cell);
512
+ if (!parsed.text || !parsed.isRich) {
513
+ return undefined;
514
+ }
515
+ return parsed;
516
+ }
517
+ finally {
518
+ cleanup();
519
+ }
520
+ };
@@ -0,0 +1,23 @@
1
+ import type { TextFormatRun } from "@rowsncolumns/common-types";
2
+ /**
3
+ * Reconcile a committed string's run-level links (`TextFormatRun.format.link`)
4
+ * with the cell-level `hyperlink`. Excel keeps ONE link per cell, painted and
5
+ * followed at the cell level; Google Sheets keeps several inside the text.
6
+ * Both engines apply this on commit (`SheetCell.setStringValue`, rnc-store
7
+ * `apply_set_value_rich_text`) so a rich paste lands the same way whether it
8
+ * came through the grid, the formula bar or the in-cell editor:
9
+ *
10
+ * - a cell without a hyperlink takes the FIRST run link as its hyperlink;
11
+ * - a run link equal to the cell hyperlink that spans the WHOLE text is
12
+ * redundant (that is exactly what the cell hyperlink says) and is stripped
13
+ * — the run itself goes when nothing else is left on it;
14
+ * - every other run link stays as data (the multi-link cell keeps them all).
15
+ */
16
+ export type ResolvedRunLinks = {
17
+ runs: TextFormatRun[] | undefined | null;
18
+ hyperlink: string | undefined;
19
+ /** True when `runs` or `hyperlink` differ from the input. */
20
+ changed: boolean;
21
+ };
22
+ export declare const promoteRunLinkToHyperlink: (text: string, runs: TextFormatRun[] | undefined | null, hyperlink: string | undefined | null) => ResolvedRunLinks;
23
+ //# sourceMappingURL=rich-text-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-text-links.d.ts","sourceRoot":"","sources":["../../rich-text-links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,aAAa,EAAE,GAAG,SAAS,GAAG,IAAI,CAAC;IACzC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,eAAO,MAAM,yBAAyB,GACpC,MAAM,MAAM,EACZ,MAAM,aAAa,EAAE,GAAG,SAAS,GAAG,IAAI,EACxC,WAAW,MAAM,GAAG,SAAS,GAAG,IAAI,KACnC,gBAkCF,CAAC"}
@@ -0,0 +1,31 @@
1
+ export const promoteRunLinkToHyperlink = (text, runs, hyperlink) => {
2
+ if (!runs || runs.length === 0) {
3
+ return { runs, hyperlink: hyperlink ?? undefined, changed: false };
4
+ }
5
+ const firstLink = runs.find((run) => run.nodeType !== "mention" && run.format?.link?.uri);
6
+ if (!firstLink || firstLink.nodeType === "mention") {
7
+ return { runs, hyperlink: hyperlink ?? undefined, changed: false };
8
+ }
9
+ const resolvedHyperlink = hyperlink || firstLink.format.link.uri;
10
+ let changed = resolvedHyperlink !== (hyperlink ?? undefined);
11
+ const stripped = [];
12
+ for (const run of runs) {
13
+ if (run.nodeType === "mention" ||
14
+ run.format?.link?.uri !== resolvedHyperlink ||
15
+ run.startIndex !== 0 ||
16
+ run.endIndex < text.length) {
17
+ stripped.push(run);
18
+ continue;
19
+ }
20
+ changed = true;
21
+ const { link: _link, ...rest } = run.format;
22
+ if (Object.keys(rest).length > 0) {
23
+ stripped.push({ ...run, format: rest });
24
+ }
25
+ }
26
+ return {
27
+ runs: changed ? (stripped.length ? stripped : undefined) : runs,
28
+ hyperlink: resolvedHyperlink,
29
+ changed,
30
+ };
31
+ };
@@ -1 +1 @@
1
- {"root":["../../array.ts","../../cell-data-helpers.ts","../../color.ts","../../colors.ts","../../conditional-format.ts","../../content-hash.ts","../../data-validation.ts","../../date.ts","../../dom.ts","../../excel-number-format.ts","../../formatter.ts","../../formula-locale.ts","../../formula-refs.ts","../../formula.ts","../../index.ts","../../keyboard.ts","../../locale.ts","../../log.ts","../../memoize.ts","../../numbers.ts","../../selection.ts","../../shared-string-canonical.ts","../../sheet-span.ts","../../sort.ts","../../sparkline-groups.ts","../../string.ts","../../table-names.ts","../../table-styles.ts","../../table-totals.ts","../../typeguards.ts","../../uuid.ts","../../vitest.config.ts","../../formula-locale/formula-locale-data.generated.ts"],"version":"5.8.3"}
1
+ {"root":["../../array.ts","../../cell-data-helpers.ts","../../color.ts","../../colors.ts","../../conditional-format.ts","../../content-hash.ts","../../data-validation.ts","../../date.ts","../../dom.ts","../../excel-number-format.ts","../../formatter.ts","../../formula-locale.ts","../../formula-refs.ts","../../formula.ts","../../index.ts","../../keyboard.ts","../../locale.ts","../../log.ts","../../memoize.ts","../../numbers.ts","../../rich-text-html.ts","../../rich-text-links.ts","../../selection.ts","../../shared-string-canonical.ts","../../sheet-span.ts","../../sort.ts","../../sparkline-groups.ts","../../string.ts","../../table-names.ts","../../table-styles.ts","../../table-totals.ts","../../typeguards.ts","../../uuid.ts","../../vitest.config.ts","../../formula-locale/formula-locale-data.generated.ts"],"version":"5.8.3"}