fable-editor 1.2.8 → 1.3.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.
@@ -1290,6 +1290,195 @@ function normalizeTextPaste(raw) {
1290
1290
  return esc(txt);
1291
1291
  }
1292
1292
 
1293
+ /* Recovers the pictures that desktop Word leaves out of the HTML clipboard flavor.
1294
+ Word writes <img src="file:///…/msohtmlclip1/01/clip_image001.png"> — a local temp
1295
+ file the browser cannot read — but the same paste also carries a text/rtf flavor
1296
+ with every picture embedded as hex inside a {\pict} group. Pairing the two is how
1297
+ TinyMCE PowerPaste implements powerpaste_allow_local_images.
1298
+
1299
+ This runs on the RAW html string BEFORE cleanPastedHTML() sees it: the paste engine
1300
+ already allows data: URIs, so it needs no change and its behaviour is unaffected.
1301
+ Anything this module cannot recover is left exactly as it was, and still ends up on
1302
+ the engine's existing "[local image — paste it separately]" placeholder. */
1303
+ /** Total decoded bytes we are willing to inline from one paste. A Word document full
1304
+ * of full-page screenshots can run to tens of MB; past this we leave the placeholders
1305
+ * rather than freeze the editor building base64 strings. */
1306
+ const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
1307
+ const USABLE_SRC = /^(https?:|data:image\/|blob:)/i;
1308
+ /** Scans for `{\keyword …}` groups, tracking brace depth so nested groups such as
1309
+ * `{\*\picprop …}` inside a picture do not terminate the match early (a lazy
1310
+ * `/\{\\pict[\s\S]*?\}/` regex gets this wrong and truncates the hex payload). */
1311
+ function findGroups(rtf, keyword) {
1312
+ const out = [];
1313
+ const needle = '{\\' + keyword;
1314
+ let i = 0;
1315
+ while ((i = rtf.indexOf(needle, i)) !== -1) {
1316
+ /* the char after the control word must be a delimiter, so \pict does not also
1317
+ match \pictscalex and friends */
1318
+ const after = rtf[i + needle.length];
1319
+ if (after && /[a-z0-9]/i.test(after)) {
1320
+ i += needle.length;
1321
+ continue;
1322
+ }
1323
+ let depth = 0;
1324
+ let j = i;
1325
+ for (; j < rtf.length; j++) {
1326
+ const c = rtf[j];
1327
+ if (c === '\\') {
1328
+ j++;
1329
+ continue;
1330
+ }
1331
+ if (c === '{')
1332
+ depth++;
1333
+ else if (c === '}') {
1334
+ depth--;
1335
+ if (!depth)
1336
+ break;
1337
+ }
1338
+ }
1339
+ out.push({ start: i, end: j + 1, body: rtf.slice(i + needle.length, j) });
1340
+ i = j + 1;
1341
+ }
1342
+ return out;
1343
+ }
1344
+ function hexToBytes(hex) {
1345
+ const bytes = new Uint8Array(hex.length / 2);
1346
+ for (let i = 0; i < bytes.length; i++)
1347
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
1348
+ return bytes;
1349
+ }
1350
+ function toBase64$1(bytes) {
1351
+ let bin = '';
1352
+ for (let i = 0; i < bytes.length; i++)
1353
+ bin += String.fromCharCode(bytes[i]);
1354
+ return btoa(bin);
1355
+ }
1356
+ function indexOfSig(bytes, sig) {
1357
+ outer: for (let i = 0; i + sig.length <= bytes.length; i++) {
1358
+ for (let j = 0; j < sig.length; j++)
1359
+ if (bytes[i + j] !== sig[j])
1360
+ continue outer;
1361
+ return i;
1362
+ }
1363
+ return -1;
1364
+ }
1365
+ const PNG_SIG = [0x89, 0x50, 0x4e, 0x47];
1366
+ const JPG_SIG = [0xff, 0xd8, 0xff];
1367
+ function decodePict(body) {
1368
+ let kind = null;
1369
+ if (/\\pngblip/.test(body))
1370
+ kind = 'image/png';
1371
+ else if (/\\jpegblip/.test(body))
1372
+ kind = 'image/jpeg';
1373
+ else if (/\\wmetafile\d*/.test(body) || /\\emfblip/.test(body))
1374
+ kind = 'metafile';
1375
+ if (!kind)
1376
+ return null;
1377
+ let hex = body
1378
+ .replace(/\{[^{}]*\}/g, '') // nested property groups
1379
+ .replace(/\\[a-z]+-?\d*\s?/gi, '') // control words
1380
+ .replace(/[^0-9a-fA-F]/g, '');
1381
+ if (hex.length % 2)
1382
+ hex = hex.slice(0, -1);
1383
+ if (!hex)
1384
+ return null;
1385
+ const bytes = hexToBytes(hex);
1386
+ if (kind === 'metafile') {
1387
+ /* Word wraps a bitmap in a metafile for compatibility; when there is a real
1388
+ PNG/JPEG inside we can use it. A genuine vector metafile we cannot render. */
1389
+ const png = indexOfSig(bytes, PNG_SIG);
1390
+ if (png >= 0)
1391
+ return 'data:image/png;base64,' + toBase64$1(bytes.slice(png));
1392
+ const jpg = indexOfSig(bytes, JPG_SIG);
1393
+ if (jpg >= 0)
1394
+ return 'data:image/jpeg;base64,' + toBase64$1(bytes.slice(jpg));
1395
+ return null;
1396
+ }
1397
+ return 'data:' + kind + ';base64,' + toBase64$1(bytes);
1398
+ }
1399
+ /** Every picture in the RTF flavor, in document order. */
1400
+ function extractRtfImages(rtf) {
1401
+ if (!rtf || rtf.indexOf('\\pict') === -1)
1402
+ return [];
1403
+ /* Word emits {\*\shppict{\pict …}}{\nonshppict{\pict …}} — the second group is a
1404
+ legacy duplicate of the first. Dropping it keeps picture order aligned with the
1405
+ <img> tags in the HTML flavor. */
1406
+ let cleaned = rtf;
1407
+ findGroups(rtf, 'nonshppict')
1408
+ .reverse()
1409
+ .forEach((g) => {
1410
+ cleaned = cleaned.slice(0, g.start) + cleaned.slice(g.end);
1411
+ });
1412
+ let budget = MAX_TOTAL_BYTES;
1413
+ return findGroups(cleaned, 'pict').map((g) => {
1414
+ if (budget <= 0)
1415
+ return null;
1416
+ const data = decodePict(g.body);
1417
+ if (data)
1418
+ budget -= data.length * 0.75; // base64 -> approximate byte count
1419
+ return data;
1420
+ });
1421
+ }
1422
+ /** Rewrites unusable <img src> values in raw pasted HTML with the pictures given, in
1423
+ * document order. Images that already have a usable src are left alone and do not
1424
+ * consume a slot, so a mixed paste (webmail image + Word image) stays aligned.
1425
+ * Anything without a matching picture keeps its original tag. */
1426
+ function injectImages(html, imgs) {
1427
+ if (!html || !imgs.length)
1428
+ return { html, injected: [] };
1429
+ const injected = [];
1430
+ let next = 0;
1431
+ const out = html.replace(/<img\b[^>]*>/gi, (tag) => {
1432
+ const src = (tag.match(/\ssrc\s*=\s*["']?([^"'\s>]+)/i) || [])[1] || '';
1433
+ if (USABLE_SRC.test(src))
1434
+ return tag;
1435
+ const data = imgs[next++];
1436
+ if (!data)
1437
+ return tag;
1438
+ injected.push(data);
1439
+ return /\ssrc\s*=\s*["']/i.test(tag)
1440
+ ? tag.replace(/(\ssrc\s*=\s*)(["'])[^"']*\2/i, '$1"' + data + '"')
1441
+ : tag.replace(/(\ssrc\s*=\s*)[^\s>]+/i, '$1"' + data + '"');
1442
+ });
1443
+ return { html: out, injected };
1444
+ }
1445
+ /** Pairs the HTML flavor of a Word paste with the pictures in its RTF flavor. */
1446
+ function injectRtfImages(html, rtf) {
1447
+ if (!html || !rtf)
1448
+ return { html, injected: [] };
1449
+ return injectImages(html, extractRtfImages(rtf));
1450
+ }
1451
+ /** True when the cleaned HTML still holds images the engine could not resolve — used
1452
+ * to decide whether a bitmap sitting in the clipboard is worth falling back to. */
1453
+ function countUnresolvedImages(html) {
1454
+ const tags = html.match(/<img\b[^>]*>/gi);
1455
+ if (!tags)
1456
+ return 0;
1457
+ return tags.filter((tag) => {
1458
+ const src = (tag.match(/\ssrc\s*=\s*["']?([^"'\s>]+)/i) || [])[1] || '';
1459
+ return !USABLE_SRC.test(src);
1460
+ }).length;
1461
+ }
1462
+ /** data: URI -> File, so recovered pictures can go through the host's
1463
+ * imageUploadHandler exactly like a picked file does. `baseName` gets the extension
1464
+ * that matches the URI's MIME type. */
1465
+ function dataUrlToFile(dataUrl, baseName) {
1466
+ const m = dataUrl.match(/^data:([^;,]+);base64,(.*)$/);
1467
+ if (!m)
1468
+ return null;
1469
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png';
1470
+ try {
1471
+ const bin = atob(m[2]);
1472
+ const bytes = new Uint8Array(bin.length);
1473
+ for (let i = 0; i < bin.length; i++)
1474
+ bytes[i] = bin.charCodeAt(i);
1475
+ return new File([bytes], baseName + '.' + (ext === 'jpeg' ? 'jpg' : ext), { type: m[1] });
1476
+ }
1477
+ catch (e) {
1478
+ return null;
1479
+ }
1480
+ }
1481
+
1293
1482
  /* Minimal .docx → HTML converter with no dependencies.
1294
1483
  Parses the ZIP container manually and inflates entries with the browser's
1295
1484
  DecompressionStream, then maps WordprocessingML to editor-friendly HTML:
@@ -1907,6 +2096,7 @@ class FableEditor {
1907
2096
  this.bindEvents();
1908
2097
  this.initUI();
1909
2098
  this.recordRevision();
2099
+ this.stampExistingImages();
1910
2100
  this.options.onReady(this);
1911
2101
  }
1912
2102
  t(key) {
@@ -2231,7 +2421,7 @@ class FableEditor {
2231
2421
  const fr = new FileReader();
2232
2422
  fr.onload = () => {
2233
2423
  this.restoreSel();
2234
- document.execCommand('insertHTML', false, `<img src="${fr.result}" title="${file.name.replace(/"/g, '')}" alt="">`);
2424
+ this.insertImageHTML(fr.result, file.name.replace(/"/g, ''));
2235
2425
  this.onChange();
2236
2426
  };
2237
2427
  fr.readAsDataURL(file);
@@ -2346,6 +2536,29 @@ class FableEditor {
2346
2536
  const hasAlign = /text-align\s*:/i.test(el.getAttribute('style') || '');
2347
2537
  setStyle(el, hasAlign ? `direction:${d}` : `direction:${d};text-align:${d === 'rtl' ? 'right' : 'left'}`);
2348
2538
  });
2539
+ /* Images: an email has none of the editor's CSS, so an image with no explicit
2540
+ size renders at its raw pixel size in the mail client. Take the size it
2541
+ actually has on screen from the live editor (index-matched — the clone was
2542
+ built from the same HTML) and make it explicit. The width attribute is what
2543
+ Outlook desktop follows; max-width keeps it inside narrow mobile clients.
2544
+ Documents saved before this existed are fixed here too, without rewriting
2545
+ anything the host has stored. */
2546
+ const liveImgs = this.ed.querySelectorAll('img');
2547
+ box.querySelectorAll('img').forEach((img, i) => {
2548
+ if (img.closest('.tpl-media'))
2549
+ return;
2550
+ const live = liveImgs[i];
2551
+ const w = Math.round(live?.getBoundingClientRect().width || 0);
2552
+ if (w > 0 && !img.getAttribute('width'))
2553
+ img.setAttribute('width', String(w));
2554
+ const style = img.getAttribute('style') || '';
2555
+ if (w > 0 && !/(^|;)\s*width\s*:/i.test(style))
2556
+ setStyle(img, `width:${w}px`);
2557
+ if (!/max-width\s*:/i.test(img.getAttribute('style') || ''))
2558
+ setStyle(img, 'max-width:100%');
2559
+ if (!/(^|;)\s*height\s*:/i.test(img.getAttribute('style') || ''))
2560
+ setStyle(img, 'height:auto');
2561
+ });
2349
2562
  const dir = found || this.ed.getAttribute('dir') || this.dir();
2350
2563
  box.querySelectorAll('table:not([dir])').forEach((tbl) => {
2351
2564
  tbl.setAttribute('dir', dir);
@@ -2367,6 +2580,7 @@ class FableEditor {
2367
2580
  this.clearCodeSel();
2368
2581
  this.clearSelToolbar();
2369
2582
  this.onChange();
2583
+ this.stampExistingImages();
2370
2584
  }
2371
2585
  insertContent(html) {
2372
2586
  this.restoreSel();
@@ -4161,9 +4375,18 @@ class FableEditor {
4161
4375
  }
4162
4376
  previewDlg() {
4163
4377
  this.dialog(this.t('previewttl'), (body) => {
4378
+ /* The preview renders the document at the editor's own column width, so an
4379
+ image is exactly the size the user left it at — a narrower preview pane
4380
+ would silently shrink it and misreport what will be sent. The dialog gets
4381
+ a wider cap to make that width reachable; on a viewport too small for it
4382
+ the image's max-width:100% scales things down rather than clipping. */
4383
+ body.closest('.dlg')?.classList.add('dlg-preview');
4164
4384
  const box = document.createElement('div');
4385
+ box.className = 'pv-box';
4386
+ const w = this.contentWidth();
4165
4387
  box.style.cssText =
4166
- 'width:640px;max-width:78vw;max-height:52vh;overflow:auto;border:1px solid #e3e3e3;border-radius:6px;padding:14px;font-family:Helvetica,Arial,sans-serif;font-size:14px';
4388
+ `width:${w > 0 ? Math.round(w) : 640}px;max-width:100%;max-height:52vh;overflow:auto;` +
4389
+ 'border:1px solid #e3e3e3;border-radius:6px;padding:14px;font-family:Helvetica,Arial,sans-serif;font-size:14px';
4167
4390
  box.innerHTML = this.ed.innerHTML;
4168
4391
  body.appendChild(box);
4169
4392
  });
@@ -4407,11 +4630,91 @@ class FableEditor {
4407
4630
  if (title)
4408
4631
  img.title = title;
4409
4632
  ph.replaceWith(img);
4633
+ this.stampImageSize(img);
4410
4634
  if (this.phActive === ph)
4411
4635
  this.clearImgPlaceholderSel();
4412
4636
  this.refreshState();
4413
4637
  this.onChange();
4414
4638
  }
4639
+ /** Width of the editor's text column — the width `.earea img { max-width:100% }`
4640
+ * clamps an oversized image to. */
4641
+ contentWidth() {
4642
+ const cs = getComputedStyle(this.ed);
4643
+ const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
4644
+ return Math.max(0, this.ed.clientWidth - pad);
4645
+ }
4646
+ /** Inserts an <img> through execCommand and stamps its size once it has decoded.
4647
+ * A throwaway id is the only way to get a handle on what insertHTML created. */
4648
+ insertImageHTML(src, title) {
4649
+ const id = 'fable-img-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
4650
+ document.execCommand('insertHTML', false, `<img id="${id}" src="${src}"${title ? ` title="${title}"` : ''} alt="">`);
4651
+ const img = this.ed.querySelector('#' + id);
4652
+ if (!img)
4653
+ return;
4654
+ img.removeAttribute('id');
4655
+ this.stampImageSize(img);
4656
+ }
4657
+ /** Gives a newly inserted image an explicit size so it renders the same in the
4658
+ * editor, in Preview and in a sent email. Without it the image only looks right
4659
+ * inside `.earea`, whose `max-width:100%` rule exists nowhere else — which is why
4660
+ * the same picture came out at a different size once the mail was sent.
4661
+ * Leaves alone: images inside a template media slot (deliberately fluid) and any
4662
+ * image that already carries a size from its source. */
4663
+ stampImageSize(img, onSettled) {
4664
+ const finish = (stamped) => {
4665
+ if (onSettled)
4666
+ onSettled(stamped);
4667
+ else if (stamped)
4668
+ this.onChange();
4669
+ };
4670
+ const apply = () => {
4671
+ if (!this.ed.contains(img))
4672
+ return finish(false);
4673
+ if (img.closest('.tpl-media'))
4674
+ return finish(false);
4675
+ if (img.getAttribute('width') || img.getAttribute('height'))
4676
+ return finish(false);
4677
+ if (img.style.width || img.style.height)
4678
+ return finish(false);
4679
+ const natural = img.naturalWidth;
4680
+ if (!natural)
4681
+ return finish(false); /* not decoded (or jsdom) — nothing reliable to stamp */
4682
+ const max = this.contentWidth();
4683
+ const w = Math.round(max > 0 ? Math.min(natural, max) : natural);
4684
+ img.setAttribute('width', String(w));
4685
+ img.style.width = w + 'px';
4686
+ img.style.height = 'auto';
4687
+ finish(true);
4688
+ };
4689
+ /* `complete` is also true for an image that failed or has not decoded yet, so
4690
+ wait for load unless there are real pixel dimensions to read */
4691
+ if (img.complete && img.naturalWidth)
4692
+ apply();
4693
+ else {
4694
+ img.addEventListener('load', apply, { once: true });
4695
+ img.addEventListener('error', () => finish(false), { once: true });
4696
+ }
4697
+ }
4698
+ /** Sizes images in content that arrived from outside the editor — a document saved
4699
+ * before the editor started recording image sizes. Without this such a document
4700
+ * keeps rendering at one size here and another in a sent mail. Images that already
4701
+ * carry a size, and template slots, are left alone, so content written by a
4702
+ * current version passes through untouched. Emits one change after the images have
4703
+ * settled rather than one per image, and none at all if nothing needed stamping. */
4704
+ stampExistingImages() {
4705
+ const imgs = Array.from(this.ed.querySelectorAll('img'));
4706
+ if (!imgs.length)
4707
+ return;
4708
+ let waiting = imgs.length;
4709
+ let changed = false;
4710
+ const settle = (stamped) => {
4711
+ if (stamped)
4712
+ changed = true;
4713
+ if (--waiting === 0 && changed)
4714
+ this.onChange();
4715
+ };
4716
+ imgs.forEach((img) => this.stampImageSize(img, settle));
4717
+ }
4415
4718
  positionImgPhCtx() {
4416
4719
  if (this.phActive && !document.body.contains(this.phActive)) {
4417
4720
  this.clearImgPlaceholderSel();
@@ -5723,6 +6026,14 @@ class FableEditor {
5723
6026
  window.removeEventListener('mousemove', mv);
5724
6027
  window.removeEventListener('mouseup', up);
5725
6028
  restore();
6029
+ /* mirror the final size onto the width attribute: Outlook desktop
6030
+ follows the attribute rather than the CSS, so without this a resized
6031
+ image still goes out at its original size */
6032
+ const w = parseInt(img.style.width, 10);
6033
+ if (w > 0 && !img.closest('.tpl-media')) {
6034
+ img.setAttribute('width', String(w));
6035
+ img.removeAttribute('height');
6036
+ }
5726
6037
  this.onChange();
5727
6038
  };
5728
6039
  window.addEventListener('mousemove', mv);
@@ -5908,7 +6219,7 @@ class FableEditor {
5908
6219
  if (imgItem && !html) {
5909
6220
  const fr = new FileReader();
5910
6221
  fr.onload = () => {
5911
- document.execCommand('insertHTML', false, `<img src="${fr.result}" alt="">`);
6222
+ this.insertImageHTML(fr.result);
5912
6223
  this.onChange();
5913
6224
  };
5914
6225
  fr.readAsDataURL(imgItem.getAsFile());
@@ -5927,11 +6238,84 @@ class FableEditor {
5927
6238
  }
5928
6239
  }
5929
6240
  if (html) {
5930
- document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
6241
+ this.pasteRichHTML(html, cd, imgItem);
5931
6242
  }
5932
6243
  else {
5933
6244
  document.execCommand('insertHTML', false, normalizeTextPaste(cd.getData('text/plain')));
6245
+ this.onChange();
6246
+ }
6247
+ }
6248
+ /** Rich-HTML paste. Before the (untouched) paste engine runs, pictures Word left
6249
+ * behind as unreadable file:// refs are pulled out of the clipboard's RTF flavor
6250
+ * and inlined — the same pairing TinyMCE PowerPaste does for
6251
+ * powerpaste_allow_local_images. Everything the engine already handled is
6252
+ * unaffected: with no RTF, or no recoverable picture, the HTML reaches it
6253
+ * byte-identical to before. */
6254
+ pasteRichHTML(html, cd, imgItem) {
6255
+ let injected = [];
6256
+ const rtf = cd.getData('text/rtf');
6257
+ if (rtf) {
6258
+ const res = injectRtfImages(html, rtf);
6259
+ html = res.html;
6260
+ injected = res.injected;
6261
+ }
6262
+ /* Word always supplies text/html, so the bitmap-only branch in handlePaste
6263
+ never fires for a Word paste. When exactly one image is still unresolved and
6264
+ the clipboard carries that bitmap, use it instead of the placeholder. */
6265
+ if (imgItem && countUnresolvedImages(html) === 1) {
6266
+ const blob = imgItem.getAsFile();
6267
+ if (blob) {
6268
+ const pending = html;
6269
+ this.saveSel();
6270
+ const fr = new FileReader();
6271
+ fr.onload = () => {
6272
+ const res = injectImages(pending, [fr.result]);
6273
+ this.finishRichPaste(res.html, injected.concat(res.injected), true);
6274
+ };
6275
+ fr.onerror = () => this.finishRichPaste(pending, injected, true);
6276
+ fr.readAsDataURL(blob);
6277
+ return;
6278
+ }
6279
+ }
6280
+ this.finishRichPaste(html, injected);
6281
+ }
6282
+ /** Hands recovered pictures to the host's imageUploadHandler when one is
6283
+ * configured (PowerPaste's automatic_uploads equivalent), then inserts. Without a
6284
+ * handler they stay inline base64, which is what PowerPaste does by default. */
6285
+ finishRichPaste(html, injected, deferred = false) {
6286
+ if (!this.imageUploadHandler || !injected.length) {
6287
+ this.insertPastedHTML(html, deferred);
6288
+ return;
5934
6289
  }
6290
+ const handler = this.imageUploadHandler;
6291
+ const unique = injected.filter((src, i) => injected.indexOf(src) === i);
6292
+ if (!deferred)
6293
+ this.saveSel();
6294
+ Promise.all(unique.map((src, i) => {
6295
+ const file = dataUrlToFile(src, 'pasted-image-' + (i + 1));
6296
+ if (!file)
6297
+ return Promise.resolve(src);
6298
+ return handler(file).catch((err) => {
6299
+ this.onImageUploadError?.(err, file);
6300
+ return src; /* keep the inline copy so the picture is never lost */
6301
+ });
6302
+ })).then((urls) => {
6303
+ let out = html;
6304
+ unique.forEach((src, i) => {
6305
+ if (urls[i] !== src)
6306
+ out = out.split(src).join(urls[i]);
6307
+ });
6308
+ this.insertPastedHTML(out, true);
6309
+ });
6310
+ }
6311
+ /** `deferred` means the insert is happening after an async hop, so the caret the
6312
+ * paste started from has to be put back first. */
6313
+ insertPastedHTML(html, deferred = false) {
6314
+ if (deferred)
6315
+ this.restoreSel();
6316
+ document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
6317
+ if (deferred)
6318
+ this.saveSel();
5935
6319
  this.onChange();
5936
6320
  }
5937
6321
  /* ---------------------------------------------------------- init */