@embedpdf/web 3.0.0-next.11 → 3.0.0-next.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/dist/index.cjs CHANGED
@@ -1120,6 +1120,796 @@ function createStageSurface(el, stage, options = {}) {
1120
1120
  };
1121
1121
  }
1122
1122
  //#endregion
1123
+ //#region src/byte-store.ts
1124
+ /** Open (creating on first use) one IndexedDB object store keyed by id. */
1125
+ function indexedDbByteStore(dbName, opts = {}) {
1126
+ const storeName = opts.storeName ?? "bytes";
1127
+ const open = () => new Promise((resolve, reject) => {
1128
+ const request = indexedDB.open(dbName, 1);
1129
+ request.onupgradeneeded = () => {
1130
+ if (!request.result.objectStoreNames.contains(storeName)) request.result.createObjectStore(storeName);
1131
+ };
1132
+ request.onsuccess = () => resolve(request.result);
1133
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexedDB.open failed"));
1134
+ });
1135
+ const run = async (mode, body) => {
1136
+ const db = await open();
1137
+ try {
1138
+ return await new Promise((resolve, reject) => {
1139
+ const request = body(db.transaction(storeName, mode).objectStore(storeName));
1140
+ request.onsuccess = () => resolve(request.result);
1141
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexedDB request failed"));
1142
+ });
1143
+ } finally {
1144
+ db.close();
1145
+ }
1146
+ };
1147
+ return {
1148
+ list: async () => {
1149
+ const keys = await run("readonly", (store) => store.getAllKeys());
1150
+ const values = await run("readonly", (store) => store.getAll());
1151
+ return keys.map((key, i) => ({
1152
+ id: String(key),
1153
+ bytes: new Uint8Array(values[i])
1154
+ }));
1155
+ },
1156
+ put: (id, bytes) => run("readwrite", (store) => store.put(new Uint8Array(bytes), id)).then(() => void 0),
1157
+ delete: (id) => run("readwrite", (store) => store.delete(id)).then(() => void 0)
1158
+ };
1159
+ }
1160
+ //#endregion
1161
+ //#region src/web-font.ts
1162
+ /**
1163
+ * Mount a registered font for the DOM: the engine lays text out with the
1164
+ * font's bytes, the browser needs the same face as a `@font-face` so the
1165
+ * live editor and the vector renderer show the glyphs the appearance stream
1166
+ * will bake. The family name is the font's KEY — the CSS family the
1167
+ * annotation plugin emits for a registered font (`cssFontFamily`) — so
1168
+ * mounting under the key is what makes the two agree.
1169
+ *
1170
+ * Returns the unmount. Idempotent per (document, key): a second mount of a
1171
+ * key already in the document's font set is a no-op that still returns an
1172
+ * unmount of its own (the face leaves when the LAST holder unmounts).
1173
+ */
1174
+ async function mountWebFont(key, data, options = {}) {
1175
+ const doc = options.document ?? document;
1176
+ const fonts = doc.fonts;
1177
+ if (!fonts || typeof FontFace === "undefined") return () => {};
1178
+ const holders = holdersOf(doc);
1179
+ const existing = holders.get(key);
1180
+ if (existing) {
1181
+ existing.count++;
1182
+ return () => release(holders, key);
1183
+ }
1184
+ const buffer = data instanceof Uint8Array ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : data;
1185
+ const face = new FontFace(key, buffer, {
1186
+ weight: options.weight !== void 0 ? String(options.weight) : void 0,
1187
+ style: options.style
1188
+ });
1189
+ holders.set(key, {
1190
+ face,
1191
+ doc,
1192
+ count: 1
1193
+ });
1194
+ try {
1195
+ await face.load();
1196
+ fonts.add(face);
1197
+ } catch {
1198
+ holders.delete(key);
1199
+ return () => {};
1200
+ }
1201
+ notifyFontChange(doc);
1202
+ return () => release(holders, key);
1203
+ }
1204
+ const HOLDERS = /* @__PURE__ */ new WeakMap();
1205
+ const LISTENERS = /* @__PURE__ */ new WeakMap();
1206
+ /** Internal: refresh editor styles for CSS loads and explicitly mounted faces.
1207
+ * Adding an already loaded FontFace does not trigger `loadingdone`. */
1208
+ function observeWebFonts(doc, onChange) {
1209
+ let listeners = LISTENERS.get(doc);
1210
+ if (!listeners) {
1211
+ listeners = /* @__PURE__ */ new Set();
1212
+ LISTENERS.set(doc, listeners);
1213
+ }
1214
+ listeners.add(onChange);
1215
+ doc.fonts?.addEventListener("loadingdone", onChange);
1216
+ doc.fonts?.addEventListener("loadingerror", onChange);
1217
+ return () => {
1218
+ listeners.delete(onChange);
1219
+ doc.fonts?.removeEventListener("loadingdone", onChange);
1220
+ doc.fonts?.removeEventListener("loadingerror", onChange);
1221
+ };
1222
+ }
1223
+ function notifyFontChange(doc) {
1224
+ for (const listener of LISTENERS.get(doc) ?? []) listener();
1225
+ }
1226
+ function holdersOf(doc) {
1227
+ let map = HOLDERS.get(doc);
1228
+ if (!map) {
1229
+ map = /* @__PURE__ */ new Map();
1230
+ HOLDERS.set(doc, map);
1231
+ }
1232
+ return map;
1233
+ }
1234
+ function release(holders, key) {
1235
+ const holder = holders.get(key);
1236
+ if (!holder) return;
1237
+ holder.count--;
1238
+ if (holder.count > 0) return;
1239
+ holders.delete(key);
1240
+ try {
1241
+ holder.doc.fonts.delete(holder.face);
1242
+ } catch {}
1243
+ notifyFontChange(holder.doc);
1244
+ }
1245
+ //#endregion
1246
+ //#region src/web-font-metrics.ts
1247
+ /** Acrobat's leading: a constant share of the size, never the font's lineGap. */
1248
+ const ACROBAT_LEADING = .2;
1249
+ /** The engine's metrics for the standard families (Acrobat's substitutes'
1250
+ * `hhea`), keyed the engine's way: no case, spaces, hyphens, quotes. */
1251
+ const STANDARD_METRICS = {
1252
+ helvetica: {
1253
+ ascent: .83,
1254
+ descent: .17
1255
+ },
1256
+ arial: {
1257
+ ascent: .83,
1258
+ descent: .17
1259
+ },
1260
+ arialmt: {
1261
+ ascent: .83,
1262
+ descent: .17
1263
+ },
1264
+ times: {
1265
+ ascent: .784,
1266
+ descent: .216
1267
+ },
1268
+ timesroman: {
1269
+ ascent: .784,
1270
+ descent: .216
1271
+ },
1272
+ timesnewroman: {
1273
+ ascent: .784,
1274
+ descent: .216
1275
+ },
1276
+ courier: {
1277
+ ascent: .627,
1278
+ descent: .373
1279
+ },
1280
+ couriernew: {
1281
+ ascent: .627,
1282
+ descent: .373
1283
+ }
1284
+ };
1285
+ /** What the browser resolves when nothing can be measured: a one-em content
1286
+ * area, the ascent of the engine's Helvetica. */
1287
+ const FALLBACK_METRICS = {
1288
+ ascent: .83,
1289
+ descent: .17
1290
+ };
1291
+ function familyKey(family) {
1292
+ return family.toLowerCase().replace(/[\s\-_'"]/g, "");
1293
+ }
1294
+ /** The first family of a CSS family list, unquoted. */
1295
+ function firstFamily$1(cssFamily) {
1296
+ return (cssFamily.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
1297
+ }
1298
+ const CONTEXTS = /* @__PURE__ */ new WeakMap();
1299
+ /**
1300
+ * The browser's own ascent and descent for a CSS family list, per em,
1301
+ * measured through a canvas. `null` when the platform cannot measure
1302
+ * (no canvas, no `fontBoundingBoxAscent`). Reads the currently resolved face
1303
+ * on every call, including while the intended font is loading.
1304
+ */
1305
+ function webFontMetrics(cssFamily, doc = typeof document === "undefined" ? void 0 : document, face = {}) {
1306
+ if (!doc) return null;
1307
+ let ctx = CONTEXTS.get(doc);
1308
+ if (!ctx) {
1309
+ const context = doc.createElement("canvas").getContext("2d");
1310
+ if (!context) return null;
1311
+ ctx = context;
1312
+ CONTEXTS.set(doc, ctx);
1313
+ }
1314
+ ctx.font = `${face.style ?? "normal"} ${face.weight ?? 400} 100px ${cssFamily}`;
1315
+ const m = ctx.measureText("Hg");
1316
+ if (typeof m.fontBoundingBoxAscent !== "number" || typeof m.fontBoundingBoxDescent !== "number") return null;
1317
+ return {
1318
+ ascent: m.fontBoundingBoxAscent / 100,
1319
+ descent: m.fontBoundingBoxDescent / 100
1320
+ };
1321
+ }
1322
+ /**
1323
+ * The standard families' engine metrics, or a browser approximation for
1324
+ * other families. `measure` is injectable for tests and resolved face styles.
1325
+ */
1326
+ function lineModelFor(cssFamily, measure = webFontMetrics) {
1327
+ const metrics = STANDARD_METRICS[familyKey(firstFamily$1(cssFamily))] ?? measure(cssFamily) ?? FALLBACK_METRICS;
1328
+ return {
1329
+ ascent: metrics.ascent,
1330
+ descent: metrics.descent,
1331
+ lineHeight: Math.round((metrics.ascent + metrics.descent + ACROBAT_LEADING) * 1e3) / 1e3
1332
+ };
1333
+ }
1334
+ /**
1335
+ * Screen px to raise a box's first line by so its baseline matches the
1336
+ * engine's: CSS puts half the leading above the glyphs and draws them with
1337
+ * the browser's face, whose ascent differs from the engine's for the
1338
+ * standard families. `fontSize` in screen px.
1339
+ */
1340
+ function firstLineShiftFor(cssFamily, fontSize, measure = webFontMetrics) {
1341
+ const model = lineModelFor(cssFamily, measure);
1342
+ const web = measure(cssFamily) ?? {
1343
+ ascent: model.ascent,
1344
+ descent: model.descent
1345
+ };
1346
+ const cssBaseline = (model.lineHeight - (web.ascent + web.descent)) / 2 + web.ascent;
1347
+ return Math.round((cssBaseline - model.ascent) * fontSize * 100) / 100;
1348
+ }
1349
+ //#endregion
1350
+ //#region src/rich-text-editor.ts
1351
+ /**
1352
+ * Rich text editor binding — the ONE contentEditable mechanics module for a
1353
+ * FreeText annotation, shared by every framework adapter (React, Angular,
1354
+ * Vue, Svelte…) so editing can never drift between them:
1355
+ *
1356
+ * • render a rich document (runs of style deltas over a body) into the
1357
+ * element as paragraphs of inline-styled spans
1358
+ * • serialise the element back into runs on `input` — inline styles only,
1359
+ * never computed styles, so the editor cannot invent properties
1360
+ * • map the DOM selection to flat offsets over the plain projection (and
1361
+ * back), and keep the caret through a model-driven re-render
1362
+ * • hold re-renders during IME composition, paste as plain text, forward
1363
+ * Cmd/Ctrl+B/I/U as commands
1364
+ *
1365
+ * The browser owns typing; this module only translates. The POLICY — what a
1366
+ * command does to the document, when to commit, what a family maps to —
1367
+ * belongs to the host (the annotation plugin), reached through the
1368
+ * structural {@link RichTextEditorHost}: @embedpdf/web imports no plugin,
1369
+ * per the layering law. The return shape is a Svelte action; the other
1370
+ * frameworks call the same two functions from their effects.
1371
+ *
1372
+ * Offsets: positions in the plain projection — paragraphs joined by `\r`,
1373
+ * runs concatenated, a hard break inside a run is a `\r` too. The same
1374
+ * arithmetic the core's run algebra uses.
1375
+ *
1376
+ * Known limits (by design, recorded in the plan): the browser's native undo
1377
+ * stack does not span a host-driven restyle; DOM line wrapping approximates
1378
+ * the engine's layout, the appearance stream is the truth.
1379
+ */
1380
+ const ELEMENT_NODE = 1;
1381
+ const TEXT_NODE = 3;
1382
+ function isBlock(node) {
1383
+ if (node.nodeType !== ELEMENT_NODE) return false;
1384
+ const name = node.nodeName.toUpperCase();
1385
+ return name === "DIV" || name === "P";
1386
+ }
1387
+ function isBreak(node) {
1388
+ return node.nodeType === ELEMENT_NODE && node.nodeName.toUpperCase() === "BR";
1389
+ }
1390
+ function lastChild(node) {
1391
+ return node.childNodes[node.childNodes.length - 1];
1392
+ }
1393
+ /**
1394
+ * A `<br>` that nothing follows inside its block (or the root) is the
1395
+ * browser's caret placeholder, not content — even wrapped in a span, as
1396
+ * Chrome does for a new styled line. Every other break is a hard line break.
1397
+ */
1398
+ function isTrailingBreak(node, root) {
1399
+ for (let n = node;;) {
1400
+ const parent = n.parentNode;
1401
+ if (!parent) return false;
1402
+ if (lastChild(parent) !== n) return false;
1403
+ if (parent === root || isBlock(parent)) return true;
1404
+ n = parent;
1405
+ }
1406
+ }
1407
+ function contains(root, node) {
1408
+ for (let n = node; n; n = n.parentNode) if (n === root) return true;
1409
+ return false;
1410
+ }
1411
+ const HEX = /^#([0-9a-f]{6})$/i;
1412
+ const RGB = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i;
1413
+ function cssColorToHex(css) {
1414
+ const trimmed = css.trim();
1415
+ if (HEX.test(trimmed)) return trimmed.toUpperCase();
1416
+ const m = RGB.exec(trimmed);
1417
+ if (!m) return void 0;
1418
+ const hex = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, "0");
1419
+ return `#${hex(m[1])}${hex(m[2])}${hex(m[3])}`.toUpperCase();
1420
+ }
1421
+ function firstFamily(css) {
1422
+ const first = css.split(",")[0]?.trim().replace(/^["']|["']$/g, "");
1423
+ return first ? first : void 0;
1424
+ }
1425
+ function round2(n) {
1426
+ return Math.round(n * 100) / 100;
1427
+ }
1428
+ /** The inline style declarations a span carries, as a style delta. Only what
1429
+ * the span itself sets: an unset property means "inherit", never a value. */
1430
+ function styleDeltaOf(style, scale) {
1431
+ const delta = {};
1432
+ if (!style) return delta;
1433
+ const weight = style.fontWeight;
1434
+ if (weight) {
1435
+ const parsed = weight === "bold" ? 700 : weight === "normal" ? 400 : Number(weight);
1436
+ if (Number.isFinite(parsed) && parsed > 0) delta.weight = parsed;
1437
+ }
1438
+ if (style.fontStyle) delta.italic = style.fontStyle === "italic" || style.fontStyle === "oblique";
1439
+ const decoration = style.textDecorationLine || style.textDecoration;
1440
+ if (decoration) delta.decoration = decoration.split(/\s+/).filter((p) => p === "underline" || p === "line-through");
1441
+ if (style.color) {
1442
+ const hex = cssColorToHex(style.color);
1443
+ if (hex) delta.color = hex;
1444
+ }
1445
+ const script = style.verticalAlign;
1446
+ if (script === "sub" || script === "super") delta.script = script;
1447
+ if (style.fontSize && !delta.script) {
1448
+ const px = parseFloat(style.fontSize);
1449
+ if (Number.isFinite(px) && scale > 0) delta.size = round2(px / scale);
1450
+ }
1451
+ if (style.fontFamily) {
1452
+ const family = firstFamily(style.fontFamily);
1453
+ if (family) delta.family = family;
1454
+ }
1455
+ if (style.letterSpacing) {
1456
+ const px = parseFloat(style.letterSpacing);
1457
+ if (Number.isFinite(px) && scale > 0) delta.letterSpacing = round2(px / scale);
1458
+ }
1459
+ return delta;
1460
+ }
1461
+ function applyStyleDelta(style, delta, scale, cssFontFamily) {
1462
+ if (delta.weight !== void 0) style.fontWeight = String(delta.weight);
1463
+ if (delta.italic !== void 0) style.fontStyle = delta.italic ? "italic" : "normal";
1464
+ if (delta.decoration !== void 0) {
1465
+ const lines = delta.decoration.map((d) => d === "word" ? "underline" : d);
1466
+ style.textDecoration = lines.length ? [...new Set(lines)].join(" ") : "none";
1467
+ }
1468
+ if (delta.color !== void 0) style.color = delta.color;
1469
+ if (delta.script !== void 0 && delta.script !== "normal") {
1470
+ style.verticalAlign = delta.script;
1471
+ style.fontSize = "0.66em";
1472
+ } else if (delta.size !== void 0) style.fontSize = `${delta.size * scale}px`;
1473
+ if (delta.family !== void 0) style.fontFamily = cssFontFamily(delta.family);
1474
+ if (delta.letterSpacing !== void 0) style.letterSpacing = `${delta.letterSpacing * scale}px`;
1475
+ }
1476
+ function mergeDelta(base, over) {
1477
+ return {
1478
+ ...base,
1479
+ ...over
1480
+ };
1481
+ }
1482
+ function sameDelta(a, b) {
1483
+ const da = a ?? {};
1484
+ const db = b ?? {};
1485
+ const keys = /* @__PURE__ */ new Set([...Object.keys(da), ...Object.keys(db)]);
1486
+ for (const key of keys) {
1487
+ const va = da[key];
1488
+ const vb = db[key];
1489
+ if (Array.isArray(va) || Array.isArray(vb)) {
1490
+ if ([...va ?? []].sort().join(" ") !== [...vb ?? []].sort().join(" ")) return false;
1491
+ } else if (va !== vb) return false;
1492
+ }
1493
+ return true;
1494
+ }
1495
+ /** Render a document into the element: one block per paragraph, one span per
1496
+ * styled run, `<br>` for a hard break, a placeholder `<br>` at the end of a
1497
+ * block that is empty or ends on a break so the caret has a home. */
1498
+ function renderRichText(root, doc, options) {
1499
+ const factory = root.ownerDocument;
1500
+ if (!factory) return;
1501
+ const blocks = [];
1502
+ const paragraphs = doc.paragraphs.length ? doc.paragraphs : [{ runs: [{ text: "" }] }];
1503
+ for (const paragraph of paragraphs) {
1504
+ const block = factory.createElement("div");
1505
+ if (paragraph.align) block.style.textAlign = paragraph.align;
1506
+ if (paragraph.dir) block.style.direction = paragraph.dir;
1507
+ let endsWithBreak = false;
1508
+ for (const run of paragraph.runs) {
1509
+ let container = block;
1510
+ if (run.style && Object.keys(run.style).length) {
1511
+ container = factory.createElement("span");
1512
+ applyStyleDelta(container.style, run.style, options.scale, options.cssFontFamily);
1513
+ block.appendChild(container);
1514
+ }
1515
+ const pieces = run.text.split(/\r\n|\r|\n/);
1516
+ for (let i = 0; i < pieces.length; i++) {
1517
+ if (i > 0) {
1518
+ container.appendChild(factory.createElement("br"));
1519
+ endsWithBreak = true;
1520
+ }
1521
+ const piece = pieces[i];
1522
+ if (!piece) continue;
1523
+ container.appendChild(factory.createTextNode(piece));
1524
+ endsWithBreak = false;
1525
+ }
1526
+ }
1527
+ if (endsWithBreak || !lastChild(block)) block.appendChild(factory.createElement("br"));
1528
+ blocks.push(block);
1529
+ }
1530
+ if (root.replaceChildren) root.replaceChildren(...blocks);
1531
+ else {
1532
+ while (root.firstChild && root.removeChild) root.removeChild(root.firstChild);
1533
+ for (const block of blocks) root.appendChild(block);
1534
+ }
1535
+ }
1536
+ /** Keep the baseline adjustment on the first block after Enter clones it. */
1537
+ function applyFirstLineShift(root, shift) {
1538
+ for (let i = 0; i < root.childNodes.length; i++) {
1539
+ const node = root.childNodes[i];
1540
+ if (!node.style) continue;
1541
+ node.style.marginTop = i === 0 && shift ? `${-shift}px` : "";
1542
+ }
1543
+ }
1544
+ function pushText(walk, text, style) {
1545
+ if (!walk.current) {
1546
+ walk.current = { runs: [] };
1547
+ walk.paragraphs.push(walk.current);
1548
+ }
1549
+ const runs = walk.current.runs;
1550
+ const delta = Object.keys(style).length ? style : void 0;
1551
+ const last = runs[runs.length - 1];
1552
+ if (last && sameDelta(last.style, delta)) last.text += text;
1553
+ else runs.push(delta ? {
1554
+ text,
1555
+ style: delta
1556
+ } : { text });
1557
+ }
1558
+ function walkNode(node, inherited, walk) {
1559
+ if (node.nodeType === TEXT_NODE) {
1560
+ const text = (node.nodeValue ?? "").replace(/\r\n|\n/g, "\r");
1561
+ if (text) pushText(walk, text, inherited);
1562
+ return;
1563
+ }
1564
+ if (node.nodeType !== ELEMENT_NODE) return;
1565
+ if (isBreak(node)) {
1566
+ if (!isTrailingBreak(node, walk.root)) pushText(walk, "\r", inherited);
1567
+ return;
1568
+ }
1569
+ const name = node.nodeName.toUpperCase();
1570
+ let style = inherited;
1571
+ if (name === "B" || name === "STRONG") style = mergeDelta(style, { weight: 700 });
1572
+ if (name === "I" || name === "EM") style = mergeDelta(style, { italic: true });
1573
+ if (name === "U") style = mergeDelta(style, { decoration: ["underline"] });
1574
+ if (name === "SUB") style = mergeDelta(style, { script: "sub" });
1575
+ if (name === "SUP") style = mergeDelta(style, { script: "super" });
1576
+ style = mergeDelta(style, styleDeltaOf(node.style, walk.scale));
1577
+ if (isBlock(node)) {
1578
+ const paragraph = { runs: [] };
1579
+ const align = node.style?.textAlign;
1580
+ if (align === "left" || align === "center" || align === "right" || align === "justify") paragraph.align = align;
1581
+ if (node.style?.direction === "rtl") paragraph.dir = "rtl";
1582
+ walk.paragraphs.push(paragraph);
1583
+ walk.current = paragraph;
1584
+ for (let i = 0; i < node.childNodes.length; i++) walkNode(node.childNodes[i], style, walk);
1585
+ walk.current = null;
1586
+ return;
1587
+ }
1588
+ for (let i = 0; i < node.childNodes.length; i++) walkNode(node.childNodes[i], style, walk);
1589
+ }
1590
+ /** The element's content as a document. Inline styles (and the semantic
1591
+ * b/i/u/sub/sup tags a browser may insert) only. */
1592
+ function serializeRichText(root, scale) {
1593
+ const walk = {
1594
+ root,
1595
+ scale,
1596
+ paragraphs: [],
1597
+ current: null
1598
+ };
1599
+ for (let i = 0; i < root.childNodes.length; i++) walkNode(root.childNodes[i], {}, walk);
1600
+ for (const paragraph of walk.paragraphs) if (paragraph.runs.length === 0) paragraph.runs.push({ text: "" });
1601
+ if (walk.paragraphs.length === 0) walk.paragraphs.push({ runs: [{ text: "" }] });
1602
+ return { paragraphs: walk.paragraphs };
1603
+ }
1604
+ /** The element in document order, each node with its flat offset — counted
1605
+ * like the projection: text length, 1 per hard break, 1 between blocks. */
1606
+ function collectOffsets(root) {
1607
+ const events = [];
1608
+ let offset = 0;
1609
+ let blocks = 0;
1610
+ const visit = (node) => {
1611
+ if (node.nodeType === TEXT_NODE) {
1612
+ const length = (node.nodeValue ?? "").length;
1613
+ events.push({
1614
+ kind: "text",
1615
+ node,
1616
+ at: offset,
1617
+ length
1618
+ });
1619
+ offset += length;
1620
+ return;
1621
+ }
1622
+ if (node.nodeType !== ELEMENT_NODE) return;
1623
+ if (isBreak(node)) {
1624
+ if (isTrailingBreak(node, root)) return;
1625
+ events.push({
1626
+ kind: "break",
1627
+ node,
1628
+ at: offset,
1629
+ length: 1
1630
+ });
1631
+ offset += 1;
1632
+ return;
1633
+ }
1634
+ if (isBlock(node)) {
1635
+ if (blocks > 0) offset += 1;
1636
+ blocks++;
1637
+ }
1638
+ events.push({
1639
+ kind: "enter",
1640
+ node,
1641
+ at: offset,
1642
+ length: 0
1643
+ });
1644
+ for (let i = 0; i < node.childNodes.length; i++) visit(node.childNodes[i]);
1645
+ events.push({
1646
+ kind: "exit",
1647
+ node,
1648
+ at: offset,
1649
+ length: 0
1650
+ });
1651
+ };
1652
+ for (let i = 0; i < root.childNodes.length; i++) visit(root.childNodes[i]);
1653
+ return events;
1654
+ }
1655
+ /** The flat offset of a DOM position (a node and an offset within it). */
1656
+ function offsetOfPosition(root, position) {
1657
+ if (!contains(root, position.node)) return 0;
1658
+ const events = collectOffsets(root);
1659
+ if (position.node.nodeType === TEXT_NODE) {
1660
+ const event = events.find((e) => e.kind === "text" && e.node === position.node);
1661
+ return event ? event.at + Math.min(position.offset, event.length) : 0;
1662
+ }
1663
+ if (isBreak(position.node)) {
1664
+ const event = events.find((e) => e.kind === "break" && e.node === position.node);
1665
+ if (event) return event.at + (position.offset > 0 ? 1 : 0);
1666
+ const exit = events.find((e) => e.kind === "exit" && e.node === position.node.parentNode);
1667
+ return exit ? exit.at : 0;
1668
+ }
1669
+ const child = position.node.childNodes[position.offset];
1670
+ if (child) {
1671
+ const event = events.find((e) => e.node === child || e.kind !== "exit" && contains(child, e.node));
1672
+ if (event) return event.at;
1673
+ }
1674
+ if (position.node === root) {
1675
+ const last = events[events.length - 1];
1676
+ return last ? last.at + last.length : 0;
1677
+ }
1678
+ const exit = events.find((e) => e.kind === "exit" && e.node === position.node);
1679
+ return exit ? exit.at : 0;
1680
+ }
1681
+ function indexIn(parent, node) {
1682
+ for (let i = 0; i < parent.childNodes.length; i++) if (parent.childNodes[i] === node) return i;
1683
+ return 0;
1684
+ }
1685
+ /** The DOM position of a flat offset: inside a text node when one covers it,
1686
+ * else the empty block / the slot after a hard break the offset names. */
1687
+ function positionOfOffset(root, offset) {
1688
+ const events = collectOffsets(root);
1689
+ const text = events.find((e) => e.kind === "text" && offset >= e.at && offset <= e.at + e.length);
1690
+ if (text) return {
1691
+ node: text.node,
1692
+ offset: offset - text.at
1693
+ };
1694
+ const block = events.find((e) => e.kind === "enter" && isBlock(e.node) && e.at === offset);
1695
+ if (block) return {
1696
+ node: block.node,
1697
+ offset: 0
1698
+ };
1699
+ const after = events.find((e) => e.kind === "break" && e.at + 1 === offset);
1700
+ if (after && after.node.parentNode) return {
1701
+ node: after.node.parentNode,
1702
+ offset: indexIn(after.node.parentNode, after.node) + 1
1703
+ };
1704
+ for (let i = events.length - 1; i >= 0; i--) {
1705
+ const e = events[i];
1706
+ if (e.kind === "text") return {
1707
+ node: e.node,
1708
+ offset: e.length
1709
+ };
1710
+ }
1711
+ const first = root.childNodes[0];
1712
+ return first ? {
1713
+ node: first,
1714
+ offset: 0
1715
+ } : null;
1716
+ }
1717
+ /** The selection's range as the shadow tree sees it: the standard
1718
+ * `getComposedRanges` (options form, then the earlier positional form). */
1719
+ function composedRange(sel, shadowRoot) {
1720
+ const s = sel;
1721
+ if (typeof s.getComposedRanges !== "function") return null;
1722
+ for (const args of [[{ shadowRoots: [shadowRoot] }], [shadowRoot]]) try {
1723
+ const ranges = s.getComposedRanges(...args);
1724
+ if (ranges.length) return ranges[0];
1725
+ } catch {}
1726
+ return null;
1727
+ }
1728
+ function documentsEqual(a, b) {
1729
+ if (a.paragraphs.length !== b.paragraphs.length) return false;
1730
+ for (let i = 0; i < a.paragraphs.length; i++) {
1731
+ const pa = a.paragraphs[i];
1732
+ const pb = b.paragraphs[i];
1733
+ if ((pa.align ?? "left") !== (pb.align ?? "left") || (pa.dir ?? "ltr") !== (pb.dir ?? "ltr")) return false;
1734
+ if (pa.runs.length !== pb.runs.length) return false;
1735
+ for (let j = 0; j < pa.runs.length; j++) {
1736
+ const ra = pa.runs[j];
1737
+ const rb = pb.runs[j];
1738
+ if (ra.text !== rb.text || !sameDelta(ra.style, rb.style)) return false;
1739
+ }
1740
+ }
1741
+ return true;
1742
+ }
1743
+ /** Bind one contentEditable element. Returns the Svelte-action-shaped handle. */
1744
+ function attachRichTextEditor(el, host, initial) {
1745
+ const root = el;
1746
+ const doc = el.ownerDocument;
1747
+ const rootNode = el.getRootNode();
1748
+ const activeElement = () => rootNode.activeElement;
1749
+ const liveRange = () => {
1750
+ const sel = doc.getSelection();
1751
+ if (!sel) return null;
1752
+ if (rootNode !== doc) {
1753
+ const composed = composedRange(sel, rootNode);
1754
+ if (composed) return composed;
1755
+ const shadowSel = rootNode.getSelection?.();
1756
+ if (shadowSel && shadowSel.rangeCount > 0) return shadowSel.getRangeAt(0);
1757
+ }
1758
+ return sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
1759
+ };
1760
+ let props = initial;
1761
+ let rendered = null;
1762
+ let composing = false;
1763
+ const currentSelection = () => {
1764
+ const range = liveRange();
1765
+ if (!range) return null;
1766
+ const anchor = range.startContainer;
1767
+ const focus = range.endContainer;
1768
+ if (!contains(root, anchor) || !contains(root, focus)) return null;
1769
+ const start = offsetOfPosition(root, {
1770
+ node: anchor,
1771
+ offset: range.startOffset
1772
+ });
1773
+ const end = offsetOfPosition(root, {
1774
+ node: focus,
1775
+ offset: range.endOffset
1776
+ });
1777
+ return {
1778
+ start: Math.min(start, end),
1779
+ end: Math.max(start, end)
1780
+ };
1781
+ };
1782
+ const select = (range) => {
1783
+ const sel = doc.getSelection();
1784
+ if (!sel) return;
1785
+ const start = positionOfOffset(root, range.start);
1786
+ const end = positionOfOffset(root, range.end);
1787
+ if (!start || !end) return;
1788
+ if (typeof sel.setBaseAndExtent === "function") {
1789
+ sel.setBaseAndExtent(start.node, start.offset, end.node, end.offset);
1790
+ return;
1791
+ }
1792
+ const domRange = doc.createRange();
1793
+ domRange.setStart(start.node, start.offset);
1794
+ domRange.setEnd(end.node, end.offset);
1795
+ sel.removeAllRanges();
1796
+ sel.addRange(domRange);
1797
+ };
1798
+ const refreshLineModel = () => {
1799
+ const bodyStyle = doc.defaultView?.getComputedStyle(el) ?? el.style;
1800
+ const body = {
1801
+ family: bodyStyle.fontFamily || "Helvetica",
1802
+ weight: bodyStyle.fontWeight || "400",
1803
+ style: bodyStyle.fontStyle || "normal"
1804
+ };
1805
+ const size = parseFloat(bodyStyle.fontSize || "16") || 16;
1806
+ const measured = /* @__PURE__ */ new Map();
1807
+ const measureFor = (face) => (family) => {
1808
+ const key = JSON.stringify([
1809
+ family,
1810
+ face.weight,
1811
+ face.style
1812
+ ]);
1813
+ if (!measured.has(key)) measured.set(key, webFontMetrics(family, doc, face));
1814
+ return measured.get(key);
1815
+ };
1816
+ const runs = Array.from(el.querySelectorAll("span, b, strong, i, em")).map((node) => {
1817
+ const style = doc.defaultView?.getComputedStyle(node) ?? node.style;
1818
+ const face = {
1819
+ family: style.fontFamily || body.family,
1820
+ weight: style.fontWeight || body.weight,
1821
+ style: style.fontStyle || body.style
1822
+ };
1823
+ return {
1824
+ node,
1825
+ lineHeight: lineModelFor(face.family, measureFor(face)).lineHeight
1826
+ };
1827
+ });
1828
+ const measure = measureFor(body);
1829
+ const lineHeight = lineModelFor(body.family, measure).lineHeight;
1830
+ const shift = firstLineShiftFor(body.family, size, measure);
1831
+ el.style.lineHeight = String(lineHeight);
1832
+ for (const run of runs) run.node.style.lineHeight = String(run.lineHeight);
1833
+ applyFirstLineShift(root, shift);
1834
+ };
1835
+ const render = (document, keepSelection) => {
1836
+ const selection = keepSelection ? currentSelection() : null;
1837
+ renderRichText(root, document, {
1838
+ scale: props.scale,
1839
+ cssFontFamily: host.cssFontFamily
1840
+ });
1841
+ refreshLineModel();
1842
+ rendered = document;
1843
+ if (selection) select(selection);
1844
+ };
1845
+ const serialiseAndReport = () => {
1846
+ refreshLineModel();
1847
+ const document = serializeRichText(root, props.scale);
1848
+ rendered = document;
1849
+ host.onInput(document);
1850
+ };
1851
+ const onInput = () => {
1852
+ if (!composing) serialiseAndReport();
1853
+ };
1854
+ const onCompositionStart = () => {
1855
+ composing = true;
1856
+ };
1857
+ const onCompositionEnd = () => {
1858
+ composing = false;
1859
+ serialiseAndReport();
1860
+ };
1861
+ const onKeyDown = (e) => {
1862
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
1863
+ const key = e.key.toLowerCase();
1864
+ const command = key === "b" ? "bold" : key === "i" ? "italic" : key === "u" ? "underline" : null;
1865
+ if (!command) return;
1866
+ e.preventDefault();
1867
+ host.onCommand(command);
1868
+ };
1869
+ const onPaste = (e) => {
1870
+ e.preventDefault();
1871
+ const text = e.clipboardData?.getData("text/plain") ?? "";
1872
+ doc.execCommand("insertText", false, text);
1873
+ };
1874
+ const onSelectionChange = () => {
1875
+ if (activeElement() !== el) return;
1876
+ host.onSelectionChange(currentSelection());
1877
+ };
1878
+ el.addEventListener("input", onInput);
1879
+ el.addEventListener("compositionstart", onCompositionStart);
1880
+ el.addEventListener("compositionend", onCompositionEnd);
1881
+ el.addEventListener("keydown", onKeyDown);
1882
+ el.addEventListener("paste", onPaste);
1883
+ doc.addEventListener("selectionchange", onSelectionChange);
1884
+ render(initial.document, false);
1885
+ const stopObservingFonts = observeWebFonts(doc, refreshLineModel);
1886
+ return {
1887
+ update(next) {
1888
+ const scaleChanged = next.scale !== props.scale;
1889
+ props = next;
1890
+ if (composing) return;
1891
+ if (!rendered || scaleChanged || !documentsEqual(next.document, rendered)) {
1892
+ render(next.document, activeElement() === el);
1893
+ return;
1894
+ }
1895
+ refreshLineModel();
1896
+ },
1897
+ selection: currentSelection,
1898
+ select,
1899
+ detach() {
1900
+ stopObservingFonts();
1901
+ el.removeEventListener("input", onInput);
1902
+ el.removeEventListener("compositionstart", onCompositionStart);
1903
+ el.removeEventListener("compositionend", onCompositionEnd);
1904
+ el.removeEventListener("keydown", onKeyDown);
1905
+ el.removeEventListener("paste", onPaste);
1906
+ doc.removeEventListener("selectionchange", onSelectionChange);
1907
+ }
1908
+ };
1909
+ }
1910
+ //#endregion
1911
+ exports.applyFirstLineShift = applyFirstLineShift;
1912
+ exports.attachRichTextEditor = attachRichTextEditor;
1123
1913
  exports.attachSelectionHandle = attachSelectionHandle;
1124
1914
  exports.bindPaintedImage = bindPaintedImage;
1125
1915
  exports.computeReleaseVelocity = computeReleaseVelocity;
@@ -1127,15 +1917,25 @@ exports.copySelection = copySelection;
1127
1917
  exports.createDefaultActionsUiAdapter = createDefaultActionsUiAdapter;
1128
1918
  exports.createStageGestureController = createStageGestureController;
1129
1919
  exports.createStageSurface = createStageSurface;
1920
+ exports.firstLineShiftFor = firstLineShiftFor;
1921
+ exports.indexedDbByteStore = indexedDbByteStore;
1922
+ exports.lineModelFor = lineModelFor;
1923
+ exports.mountWebFont = mountWebFont;
1130
1924
  exports.observeClientGeometry = observeClientGeometry;
1925
+ exports.offsetOfPosition = offsetOfPosition;
1131
1926
  exports.pickFile = pickFile;
1132
1927
  exports.pickImageFile = pickImageFile;
1133
1928
  exports.positionAnchoredRect = positionAnchoredRect;
1929
+ exports.positionOfOffset = positionOfOffset;
1134
1930
  exports.projectAnchoredTarget = projectAnchoredTarget;
1931
+ exports.renderRichText = renderRichText;
1135
1932
  exports.sanitizeExternalUri = sanitizeExternalUri;
1136
1933
  exports.saveFile = saveFile;
1934
+ exports.serializeRichText = serializeRichText;
1935
+ exports.styleDeltaOf = styleDeltaOf;
1137
1936
  exports.svgCursor = svgCursor;
1138
1937
  exports.vibrationFeedback = vibrationFeedback;
1938
+ exports.webFontMetrics = webFontMetrics;
1139
1939
  exports.wheelZoomFactor = wheelZoomFactor;
1140
1940
  exports.wireSelectionClipboard = wireSelectionClipboard;
1141
1941
  exports.wkFeedback = wkFeedback;