fable-editor 1.2.7 → 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.
- package/dist/angular/core/editor.d.ts +51 -1
- package/dist/angular/core/word-image-paste.d.ts +27 -0
- package/dist/angular/esm2022/core/editor.mjs +266 -20
- package/dist/angular/esm2022/core/word-image-paste.mjs +189 -0
- package/dist/angular/fesm2022/fable-editor.mjs +453 -19
- package/dist/angular/fesm2022/fable-editor.mjs.map +1 -1
- package/dist/core/editor.css +28 -1
- package/dist/core/editor.d.ts +51 -1
- package/dist/core/editor.d.ts.map +1 -1
- package/dist/core/index.cjs +4 -4
- package/dist/core/index.mjs +1008 -765
- package/dist/core/word-image-paste.d.ts +28 -0
- package/dist/core/word-image-paste.d.ts.map +1 -0
- package/dist/react/index.cjs +11 -11
- package/dist/react/index.mjs +1146 -903
- package/package.json +1 -1
- package/style.css +28 -1
|
@@ -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:
|
|
@@ -1828,7 +2017,12 @@ class FableEditor {
|
|
|
1828
2017
|
foreColor = '#000000';
|
|
1829
2018
|
backColor = '#FACC15';
|
|
1830
2019
|
openPop = null;
|
|
1831
|
-
|
|
2020
|
+
/** Stack of currently-open flyout submenus, shallowest first (e.g. for
|
|
2021
|
+
* Table > Cell > Cell background: [Cell flyout, Cell background flyout]).
|
|
2022
|
+
* A single slot isn't enough past one level deep - opening a 2nd-level
|
|
2023
|
+
* flyout would remove the 1st-level one still needed to bridge visually
|
|
2024
|
+
* back to the top-level popup, leaving a gap. */
|
|
2025
|
+
subStack = [];
|
|
1832
2026
|
popAnchor = null;
|
|
1833
2027
|
dlgOvl = null;
|
|
1834
2028
|
tblActive = null;
|
|
@@ -1902,6 +2096,7 @@ class FableEditor {
|
|
|
1902
2096
|
this.bindEvents();
|
|
1903
2097
|
this.initUI();
|
|
1904
2098
|
this.recordRevision();
|
|
2099
|
+
this.stampExistingImages();
|
|
1905
2100
|
this.options.onReady(this);
|
|
1906
2101
|
}
|
|
1907
2102
|
t(key) {
|
|
@@ -2018,7 +2213,7 @@ class FableEditor {
|
|
|
2018
2213
|
if (this.openPop &&
|
|
2019
2214
|
!this.openPop.contains(e.target) &&
|
|
2020
2215
|
!this.popAnchor.contains(e.target) &&
|
|
2021
|
-
!
|
|
2216
|
+
!this.subContains(e.target)) {
|
|
2022
2217
|
this.closePop();
|
|
2023
2218
|
}
|
|
2024
2219
|
});
|
|
@@ -2175,8 +2370,17 @@ class FableEditor {
|
|
|
2175
2370
|
this.positionVidCtx();
|
|
2176
2371
|
this.positionCodeCtx();
|
|
2177
2372
|
});
|
|
2178
|
-
this.onWin('scroll', () => {
|
|
2179
|
-
this
|
|
2373
|
+
this.onWin('scroll', (e) => {
|
|
2374
|
+
/* this fires for ANY scroll in the document (capture:true is what
|
|
2375
|
+
lets it catch a scrollable ancestor, not just the window) -
|
|
2376
|
+
including the popup's own overflow:auto scrolling through a long
|
|
2377
|
+
menu. Only close it for a scroll that happened outside the open
|
|
2378
|
+
menu/flyouts; otherwise scrolling a long dropdown would close it
|
|
2379
|
+
on the very first scroll tick instead of scrolling its content. */
|
|
2380
|
+
const t = e.target;
|
|
2381
|
+
const withinOpenMenu = t instanceof Node && ((this.openPop?.contains(t) ?? false) || this.subContains(t));
|
|
2382
|
+
if (!withinOpenMenu)
|
|
2383
|
+
this.closePop();
|
|
2180
2384
|
this.positionTableHandles();
|
|
2181
2385
|
this.positionImageHandles();
|
|
2182
2386
|
this.positionImgPhCtx();
|
|
@@ -2217,7 +2421,7 @@ class FableEditor {
|
|
|
2217
2421
|
const fr = new FileReader();
|
|
2218
2422
|
fr.onload = () => {
|
|
2219
2423
|
this.restoreSel();
|
|
2220
|
-
|
|
2424
|
+
this.insertImageHTML(fr.result, file.name.replace(/"/g, ''));
|
|
2221
2425
|
this.onChange();
|
|
2222
2426
|
};
|
|
2223
2427
|
fr.readAsDataURL(file);
|
|
@@ -2260,7 +2464,7 @@ class FableEditor {
|
|
|
2260
2464
|
(this.vidCtx && this.vidCtx.contains(e.target)) ||
|
|
2261
2465
|
(this.codeCtx && this.codeCtx.contains(e.target)) ||
|
|
2262
2466
|
(this.openPop && this.openPop.contains(e.target)) ||
|
|
2263
|
-
|
|
2467
|
+
this.subContains(e.target))
|
|
2264
2468
|
return;
|
|
2265
2469
|
this.clearTableHandles();
|
|
2266
2470
|
this.clearImageHandles();
|
|
@@ -2332,6 +2536,29 @@ class FableEditor {
|
|
|
2332
2536
|
const hasAlign = /text-align\s*:/i.test(el.getAttribute('style') || '');
|
|
2333
2537
|
setStyle(el, hasAlign ? `direction:${d}` : `direction:${d};text-align:${d === 'rtl' ? 'right' : 'left'}`);
|
|
2334
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
|
+
});
|
|
2335
2562
|
const dir = found || this.ed.getAttribute('dir') || this.dir();
|
|
2336
2563
|
box.querySelectorAll('table:not([dir])').forEach((tbl) => {
|
|
2337
2564
|
tbl.setAttribute('dir', dir);
|
|
@@ -2353,6 +2580,7 @@ class FableEditor {
|
|
|
2353
2580
|
this.clearCodeSel();
|
|
2354
2581
|
this.clearSelToolbar();
|
|
2355
2582
|
this.onChange();
|
|
2583
|
+
this.stampExistingImages();
|
|
2356
2584
|
}
|
|
2357
2585
|
insertContent(html) {
|
|
2358
2586
|
this.restoreSel();
|
|
@@ -2601,8 +2829,28 @@ class FableEditor {
|
|
|
2601
2829
|
}
|
|
2602
2830
|
/* ---------------------------------------------------------- popups / menus */
|
|
2603
2831
|
closeSub() {
|
|
2604
|
-
this.
|
|
2605
|
-
|
|
2832
|
+
this.closeSubFrom(0);
|
|
2833
|
+
}
|
|
2834
|
+
/** Removes stacked flyouts from `level` (0 = shallowest) onward, keeping
|
|
2835
|
+
* anything shallower - e.g. hovering a sibling item inside the "Cell"
|
|
2836
|
+
* flyout should drop a deeper "Cell background" flyout but keep "Cell"
|
|
2837
|
+
* itself and the top-level popup it bridges to. */
|
|
2838
|
+
closeSubFrom(level) {
|
|
2839
|
+
while (this.subStack.length > level)
|
|
2840
|
+
this.subStack.pop().remove();
|
|
2841
|
+
}
|
|
2842
|
+
/** True if `node` is inside any currently-open flyout submenu (any depth). */
|
|
2843
|
+
subContains(node) {
|
|
2844
|
+
return this.subStack.some((el) => el.contains(node));
|
|
2845
|
+
}
|
|
2846
|
+
/** 0 for the top-level popup itself; otherwise this container's depth in
|
|
2847
|
+
* the flyout stack + 1. Used to know how many deeper flyouts to close
|
|
2848
|
+
* when opening or hovering within `container`. */
|
|
2849
|
+
subLevelOf(container) {
|
|
2850
|
+
if (container === this.openPop)
|
|
2851
|
+
return 0;
|
|
2852
|
+
const idx = this.subStack.indexOf(container);
|
|
2853
|
+
return idx === -1 ? this.subStack.length : idx + 1;
|
|
2606
2854
|
}
|
|
2607
2855
|
closePop() {
|
|
2608
2856
|
this.closeSub();
|
|
@@ -2617,7 +2865,17 @@ class FableEditor {
|
|
|
2617
2865
|
}
|
|
2618
2866
|
}
|
|
2619
2867
|
openSubFor(item, anchor) {
|
|
2620
|
-
|
|
2868
|
+
/* captured before truncating the stack - for a flyout nested inside
|
|
2869
|
+
another flyout (e.g. Table > Cell > Cell background), anchor is itself
|
|
2870
|
+
a descendant of the currently-open sub-popup; measuring after removing
|
|
2871
|
+
it would read a detached, zeroed-out rect and land the new flyout at
|
|
2872
|
+
the top-left corner of the screen */
|
|
2873
|
+
const r = anchor.getBoundingClientRect();
|
|
2874
|
+
/* only close flyouts deeper than the one anchor lives in - closing
|
|
2875
|
+
everything (the old behavior) would also remove that same containing
|
|
2876
|
+
flyout, leaving a visual gap back to the top-level popup */
|
|
2877
|
+
const parentPop = anchor.closest('.pop');
|
|
2878
|
+
this.closeSubFrom(parentPop ? this.subLevelOf(parentPop) : 0);
|
|
2621
2879
|
const sub = document.createElement('div');
|
|
2622
2880
|
sub.className = 'pop sub';
|
|
2623
2881
|
sub.dir = this.dir();
|
|
@@ -2630,7 +2888,6 @@ class FableEditor {
|
|
|
2630
2888
|
e.preventDefault();
|
|
2631
2889
|
});
|
|
2632
2890
|
document.body.appendChild(sub);
|
|
2633
|
-
const r = anchor.getBoundingClientRect();
|
|
2634
2891
|
const isRtl = this.dir() === 'rtl';
|
|
2635
2892
|
// overlap the parent item slightly so the pointer can travel into the flyout
|
|
2636
2893
|
let x = isRtl ? r.left - sub.offsetWidth + 2 : r.right - 2;
|
|
@@ -2639,7 +2896,7 @@ class FableEditor {
|
|
|
2639
2896
|
y = Math.max(8 + scrollY, Math.min(y, scrollY + innerHeight - sub.offsetHeight - 8));
|
|
2640
2897
|
sub.style.left = x + 'px';
|
|
2641
2898
|
sub.style.top = y + 'px';
|
|
2642
|
-
this.
|
|
2899
|
+
this.subStack.push(sub);
|
|
2643
2900
|
}
|
|
2644
2901
|
popup(anchor, build, cls) {
|
|
2645
2902
|
if (this.openPop && this.popAnchor === anchor) {
|
|
@@ -2655,7 +2912,11 @@ class FableEditor {
|
|
|
2655
2912
|
const r = anchor.getBoundingClientRect();
|
|
2656
2913
|
// align the menu with its control: at least as wide as the anchor
|
|
2657
2914
|
el.style.minWidth = Math.max(160, Math.round(r.width)) + 'px';
|
|
2658
|
-
|
|
2915
|
+
// clamp so a long menu (its own max-height/overflow makes it scrollable)
|
|
2916
|
+
// stays fully on-screen instead of running off the bottom of the viewport
|
|
2917
|
+
let y = r.bottom + scrollY + 2;
|
|
2918
|
+
y = Math.max(8 + scrollY, Math.min(y, scrollY + innerHeight - el.offsetHeight - 8));
|
|
2919
|
+
el.style.top = y + 'px';
|
|
2659
2920
|
const isRtl = this.dir() === 'rtl';
|
|
2660
2921
|
let x = isRtl ? r.right - el.offsetWidth : r.left;
|
|
2661
2922
|
x = Math.max(8, Math.min(x + scrollX, scrollX + innerWidth - el.offsetWidth - 8));
|
|
@@ -2687,8 +2948,11 @@ class FableEditor {
|
|
|
2687
2948
|
b.addEventListener('mouseenter', () => {
|
|
2688
2949
|
if (hasSub)
|
|
2689
2950
|
this.openSubFor(it, b);
|
|
2690
|
-
|
|
2691
|
-
|
|
2951
|
+
/* hovering a no-submenu item drops whatever deeper flyout was open
|
|
2952
|
+
from a previously-hovered sibling, but keeps el itself and any
|
|
2953
|
+
shallower ancestors intact */
|
|
2954
|
+
else
|
|
2955
|
+
this.closeSubFrom(this.subLevelOf(el));
|
|
2692
2956
|
it.hover?.(true);
|
|
2693
2957
|
});
|
|
2694
2958
|
b.addEventListener('mouseleave', () => it.hover?.(false));
|
|
@@ -3274,8 +3538,8 @@ class FableEditor {
|
|
|
3274
3538
|
buildToolbarRegistry() {
|
|
3275
3539
|
const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
|
|
3276
3540
|
const registry = {
|
|
3277
|
-
undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
|
|
3278
|
-
redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
|
|
3541
|
+
undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo'), 'undo'),
|
|
3542
|
+
redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo'), 'redo'),
|
|
3279
3543
|
preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
|
|
3280
3544
|
print: () => this.tbtn(IC.printic, this.t('print'), () => this.printDoc()),
|
|
3281
3545
|
importword: () => this.tbtn(IC.wordic, this.t('importword'), () => this.pickWordDoc()),
|
|
@@ -4111,9 +4375,18 @@ class FableEditor {
|
|
|
4111
4375
|
}
|
|
4112
4376
|
previewDlg() {
|
|
4113
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');
|
|
4114
4384
|
const box = document.createElement('div');
|
|
4385
|
+
box.className = 'pv-box';
|
|
4386
|
+
const w = this.contentWidth();
|
|
4115
4387
|
box.style.cssText =
|
|
4116
|
-
|
|
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';
|
|
4117
4390
|
box.innerHTML = this.ed.innerHTML;
|
|
4118
4391
|
body.appendChild(box);
|
|
4119
4392
|
});
|
|
@@ -4357,11 +4630,91 @@ class FableEditor {
|
|
|
4357
4630
|
if (title)
|
|
4358
4631
|
img.title = title;
|
|
4359
4632
|
ph.replaceWith(img);
|
|
4633
|
+
this.stampImageSize(img);
|
|
4360
4634
|
if (this.phActive === ph)
|
|
4361
4635
|
this.clearImgPlaceholderSel();
|
|
4362
4636
|
this.refreshState();
|
|
4363
4637
|
this.onChange();
|
|
4364
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
|
+
}
|
|
4365
4718
|
positionImgPhCtx() {
|
|
4366
4719
|
if (this.phActive && !document.body.contains(this.phActive)) {
|
|
4367
4720
|
this.clearImgPlaceholderSel();
|
|
@@ -5673,6 +6026,14 @@ class FableEditor {
|
|
|
5673
6026
|
window.removeEventListener('mousemove', mv);
|
|
5674
6027
|
window.removeEventListener('mouseup', up);
|
|
5675
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
|
+
}
|
|
5676
6037
|
this.onChange();
|
|
5677
6038
|
};
|
|
5678
6039
|
window.addEventListener('mousemove', mv);
|
|
@@ -5858,7 +6219,7 @@ class FableEditor {
|
|
|
5858
6219
|
if (imgItem && !html) {
|
|
5859
6220
|
const fr = new FileReader();
|
|
5860
6221
|
fr.onload = () => {
|
|
5861
|
-
|
|
6222
|
+
this.insertImageHTML(fr.result);
|
|
5862
6223
|
this.onChange();
|
|
5863
6224
|
};
|
|
5864
6225
|
fr.readAsDataURL(imgItem.getAsFile());
|
|
@@ -5877,11 +6238,84 @@ class FableEditor {
|
|
|
5877
6238
|
}
|
|
5878
6239
|
}
|
|
5879
6240
|
if (html) {
|
|
5880
|
-
|
|
6241
|
+
this.pasteRichHTML(html, cd, imgItem);
|
|
5881
6242
|
}
|
|
5882
6243
|
else {
|
|
5883
6244
|
document.execCommand('insertHTML', false, normalizeTextPaste(cd.getData('text/plain')));
|
|
6245
|
+
this.onChange();
|
|
5884
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;
|
|
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();
|
|
5885
6319
|
this.onChange();
|
|
5886
6320
|
}
|
|
5887
6321
|
/* ---------------------------------------------------------- init */
|