@embedpdf/web 3.0.0-next.12 → 3.0.0-next.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.
- package/dist/index.cjs +761 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -1
- package/dist/index.d.ts +233 -1
- package/dist/index.js +751 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1158,6 +1158,758 @@ function indexedDbByteStore(dbName, opts = {}) {
|
|
|
1158
1158
|
};
|
|
1159
1159
|
}
|
|
1160
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;
|
|
1161
1913
|
exports.attachSelectionHandle = attachSelectionHandle;
|
|
1162
1914
|
exports.bindPaintedImage = bindPaintedImage;
|
|
1163
1915
|
exports.computeReleaseVelocity = computeReleaseVelocity;
|
|
@@ -1165,16 +1917,25 @@ exports.copySelection = copySelection;
|
|
|
1165
1917
|
exports.createDefaultActionsUiAdapter = createDefaultActionsUiAdapter;
|
|
1166
1918
|
exports.createStageGestureController = createStageGestureController;
|
|
1167
1919
|
exports.createStageSurface = createStageSurface;
|
|
1920
|
+
exports.firstLineShiftFor = firstLineShiftFor;
|
|
1168
1921
|
exports.indexedDbByteStore = indexedDbByteStore;
|
|
1922
|
+
exports.lineModelFor = lineModelFor;
|
|
1923
|
+
exports.mountWebFont = mountWebFont;
|
|
1169
1924
|
exports.observeClientGeometry = observeClientGeometry;
|
|
1925
|
+
exports.offsetOfPosition = offsetOfPosition;
|
|
1170
1926
|
exports.pickFile = pickFile;
|
|
1171
1927
|
exports.pickImageFile = pickImageFile;
|
|
1172
1928
|
exports.positionAnchoredRect = positionAnchoredRect;
|
|
1929
|
+
exports.positionOfOffset = positionOfOffset;
|
|
1173
1930
|
exports.projectAnchoredTarget = projectAnchoredTarget;
|
|
1931
|
+
exports.renderRichText = renderRichText;
|
|
1174
1932
|
exports.sanitizeExternalUri = sanitizeExternalUri;
|
|
1175
1933
|
exports.saveFile = saveFile;
|
|
1934
|
+
exports.serializeRichText = serializeRichText;
|
|
1935
|
+
exports.styleDeltaOf = styleDeltaOf;
|
|
1176
1936
|
exports.svgCursor = svgCursor;
|
|
1177
1937
|
exports.vibrationFeedback = vibrationFeedback;
|
|
1938
|
+
exports.webFontMetrics = webFontMetrics;
|
|
1178
1939
|
exports.wheelZoomFactor = wheelZoomFactor;
|
|
1179
1940
|
exports.wireSelectionClipboard = wireSelectionClipboard;
|
|
1180
1941
|
exports.wkFeedback = wkFeedback;
|