fable-editor 1.2.8 → 1.3.1
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 +34 -0
- package/dist/angular/core/word-image-paste.d.ts +27 -0
- package/dist/angular/esm2022/core/editor.mjs +360 -5
- package/dist/angular/esm2022/core/word-image-paste.mjs +189 -0
- package/dist/angular/fesm2022/fable-editor.mjs +547 -4
- package/dist/angular/fesm2022/fable-editor.mjs.map +1 -1
- package/dist/core/editor.css +14 -0
- package/dist/core/editor.d.ts +34 -0
- package/dist/core/editor.d.ts.map +1 -1
- package/dist/core/index.cjs +4 -4
- package/dist/core/index.mjs +1225 -927
- 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 +12 -12
- package/dist/react/index.mjs +1193 -895
- package/package.json +1 -1
- package/style.css +14 -0
|
@@ -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:
|
|
@@ -1791,6 +1980,152 @@ function firstStrongDir(text) {
|
|
|
1791
1980
|
}
|
|
1792
1981
|
return null;
|
|
1793
1982
|
}
|
|
1983
|
+
/* ---------------------------------------------------------- email-export table
|
|
1984
|
+
borders — used only by getContentForEmail(). Word describes a cell's borders
|
|
1985
|
+
as three parallel longhands (`border-color` / `border-style` / `border-width`),
|
|
1986
|
+
one value per side, and a browser re-copy of Word content keeps that split.
|
|
1987
|
+
Mail pipelines allowlist CSS one property at a time, and any of them that
|
|
1988
|
+
keeps `border-color` and `border-width` while dropping `border-style` erases
|
|
1989
|
+
the border outright — the initial `border-style` is `none`, so a width and a
|
|
1990
|
+
colour on their own draw nothing. Re-emitting every side as a single
|
|
1991
|
+
`border-<side>: <width> <style> <colour>` (collapsed to one `border:` when all
|
|
1992
|
+
four agree) makes a side survive or vanish as a unit instead. A side left with
|
|
1993
|
+
a width or a colour but no style is read back as `solid`, which also repairs
|
|
1994
|
+
content that was already flattened that way before it came back into the
|
|
1995
|
+
editor, and `windowtext` — a deprecated system colour Word still emits —
|
|
1996
|
+
becomes plain black. */
|
|
1997
|
+
const BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
|
|
1998
|
+
const BORDER_STYLE_KEYWORD = /^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i;
|
|
1999
|
+
const BORDER_WIDTH_VALUE = /^(thin|medium|thick|[+-]?(\d+\.?\d*|\.\d+)(px|pt|em|rem|ex|ch|cm|mm|in|pc|q)?)$/i;
|
|
2000
|
+
const BORDER_ZERO_WIDTH = /^[+-]?0*(\.0*)?(px|pt|em|rem|ex|ch|cm|mm|in|pc|q)?$/i;
|
|
2001
|
+
/* border-* properties that describe something other than one of the four sides,
|
|
2002
|
+
plus the CSS-wide keywords a shorthand cannot express. */
|
|
2003
|
+
const BORDER_NOT_A_SIDE = /^border-(collapse|spacing|radius|image|block|inline|start|end)/i;
|
|
2004
|
+
const BORDER_SIDE_PROP = /^border(?:-(top|right|bottom|left))?(?:-(width|style|color))?$/;
|
|
2005
|
+
const CSS_WIDE_KEYWORD = /^(inherit|initial|unset|revert|revert-layer)$/i;
|
|
2006
|
+
/* Split on top-level whitespace, so `rgb(0, 0, 0) solid` stays two values. */
|
|
2007
|
+
function splitCssValues(value) {
|
|
2008
|
+
const out = [];
|
|
2009
|
+
let depth = 0;
|
|
2010
|
+
let cur = '';
|
|
2011
|
+
for (const ch of value) {
|
|
2012
|
+
if (ch === '(')
|
|
2013
|
+
depth++;
|
|
2014
|
+
else if (ch === ')')
|
|
2015
|
+
depth--;
|
|
2016
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
2017
|
+
if (cur)
|
|
2018
|
+
out.push(cur);
|
|
2019
|
+
cur = '';
|
|
2020
|
+
}
|
|
2021
|
+
else
|
|
2022
|
+
cur += ch;
|
|
2023
|
+
}
|
|
2024
|
+
if (cur)
|
|
2025
|
+
out.push(cur);
|
|
2026
|
+
return out;
|
|
2027
|
+
}
|
|
2028
|
+
/* 1–4 values in CSS box order → an explicit [top, right, bottom, left]. */
|
|
2029
|
+
function boxSides(parts) {
|
|
2030
|
+
const [t, r = t, b = t, l = r] = parts;
|
|
2031
|
+
return [t, r, b, l];
|
|
2032
|
+
}
|
|
2033
|
+
/* `1pt solid windowtext` in any order; whatever the author left out falls back
|
|
2034
|
+
to its initial value, exactly as the real shorthand resets it. */
|
|
2035
|
+
function parseBorderShorthand(value) {
|
|
2036
|
+
const side = { width: 'medium', style: 'none', color: '' };
|
|
2037
|
+
splitCssValues(value).forEach((part) => {
|
|
2038
|
+
if (BORDER_STYLE_KEYWORD.test(part))
|
|
2039
|
+
side.style = part.toLowerCase();
|
|
2040
|
+
else if (BORDER_WIDTH_VALUE.test(part))
|
|
2041
|
+
side.width = part;
|
|
2042
|
+
else
|
|
2043
|
+
side.color = part;
|
|
2044
|
+
});
|
|
2045
|
+
return side;
|
|
2046
|
+
}
|
|
2047
|
+
function emailBorderColor(color) {
|
|
2048
|
+
if (!color || /^currentcolor$/i.test(color))
|
|
2049
|
+
return '';
|
|
2050
|
+
return /^windowtext$/i.test(color) ? '#000000' : color;
|
|
2051
|
+
}
|
|
2052
|
+
/* One side's declaration value, or '' when that side was never mentioned. */
|
|
2053
|
+
function sideBorderValue(side) {
|
|
2054
|
+
if (side.width === null && side.style === null && side.color === null)
|
|
2055
|
+
return '';
|
|
2056
|
+
const style = side.style ?? 'solid';
|
|
2057
|
+
if (style === 'none' || style === 'hidden')
|
|
2058
|
+
return 'none';
|
|
2059
|
+
if (side.width && BORDER_ZERO_WIDTH.test(side.width))
|
|
2060
|
+
return 'none';
|
|
2061
|
+
const width = side.width && side.width !== 'medium' ? side.width : '';
|
|
2062
|
+
return [width, style, emailBorderColor(side.color || '')].filter(Boolean).join(' ');
|
|
2063
|
+
}
|
|
2064
|
+
function hardenTableBorders(root) {
|
|
2065
|
+
root.querySelectorAll('table,td,th').forEach((el) => {
|
|
2066
|
+
const raw = el.getAttribute('style') || '';
|
|
2067
|
+
if (!/border/i.test(raw))
|
|
2068
|
+
return;
|
|
2069
|
+
const sides = {
|
|
2070
|
+
top: { width: null, style: null, color: null },
|
|
2071
|
+
right: { width: null, style: null, color: null },
|
|
2072
|
+
bottom: { width: null, style: null, color: null },
|
|
2073
|
+
left: { width: null, style: null, color: null }
|
|
2074
|
+
};
|
|
2075
|
+
const rest = [];
|
|
2076
|
+
let sawBorder = false;
|
|
2077
|
+
let bail = false;
|
|
2078
|
+
raw.split(';').forEach((decl) => {
|
|
2079
|
+
const i = decl.indexOf(':');
|
|
2080
|
+
if (i < 1) {
|
|
2081
|
+
if (decl.trim())
|
|
2082
|
+
rest.push(decl.trim());
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const prop = decl.slice(0, i).trim().toLowerCase();
|
|
2086
|
+
const value = decl.slice(i + 1).trim();
|
|
2087
|
+
const m = BORDER_SIDE_PROP.exec(prop);
|
|
2088
|
+
if (!m || BORDER_NOT_A_SIDE.test(prop)) {
|
|
2089
|
+
rest.push(prop + ':' + value);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
/* `border-color:inherit` and friends only mean anything against a
|
|
2093
|
+
parent this export has no say over — leave the cell untouched. */
|
|
2094
|
+
if (CSS_WIDE_KEYWORD.test(value)) {
|
|
2095
|
+
bail = true;
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
sawBorder = true;
|
|
2099
|
+
const one = m[1];
|
|
2100
|
+
const part = m[2];
|
|
2101
|
+
if (!part) {
|
|
2102
|
+
/* `border` / `border-<side>` resets all three components. */
|
|
2103
|
+
const parsed = parseBorderShorthand(value);
|
|
2104
|
+
(one ? [one] : BORDER_SIDES.slice()).forEach((s) => {
|
|
2105
|
+
sides[s] = { ...parsed };
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
else if (one) {
|
|
2109
|
+
sides[one][part] = value;
|
|
2110
|
+
}
|
|
2111
|
+
else {
|
|
2112
|
+
const per = boxSides(splitCssValues(value));
|
|
2113
|
+
BORDER_SIDES.forEach((s, n) => {
|
|
2114
|
+
sides[s][part] = per[n];
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
if (bail || !sawBorder)
|
|
2119
|
+
return;
|
|
2120
|
+
const values = BORDER_SIDES.map((s) => sideBorderValue(sides[s]));
|
|
2121
|
+
const out = rest.slice();
|
|
2122
|
+
if (values.every((v) => v && v === values[0]))
|
|
2123
|
+
out.push('border:' + values[0]);
|
|
2124
|
+
else
|
|
2125
|
+
BORDER_SIDES.forEach((s, n) => values[n] && out.push(`border-${s}:` + values[n]));
|
|
2126
|
+
el.setAttribute('style', out.join(';'));
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
1794
2129
|
/* Text belonging to el itself, skipping subtrees rooted at a descendant that
|
|
1795
2130
|
already carries its own explicit rtl/ltr dir — that subtree's direction is
|
|
1796
2131
|
already independently decided and shouldn't sway el's own detection. */
|
|
@@ -1907,6 +2242,7 @@ class FableEditor {
|
|
|
1907
2242
|
this.bindEvents();
|
|
1908
2243
|
this.initUI();
|
|
1909
2244
|
this.recordRevision();
|
|
2245
|
+
this.stampExistingImages();
|
|
1910
2246
|
this.options.onReady(this);
|
|
1911
2247
|
}
|
|
1912
2248
|
t(key) {
|
|
@@ -2231,7 +2567,7 @@ class FableEditor {
|
|
|
2231
2567
|
const fr = new FileReader();
|
|
2232
2568
|
fr.onload = () => {
|
|
2233
2569
|
this.restoreSel();
|
|
2234
|
-
|
|
2570
|
+
this.insertImageHTML(fr.result, file.name.replace(/"/g, ''));
|
|
2235
2571
|
this.onChange();
|
|
2236
2572
|
};
|
|
2237
2573
|
fr.readAsDataURL(file);
|
|
@@ -2346,6 +2682,42 @@ class FableEditor {
|
|
|
2346
2682
|
const hasAlign = /text-align\s*:/i.test(el.getAttribute('style') || '');
|
|
2347
2683
|
setStyle(el, hasAlign ? `direction:${d}` : `direction:${d};text-align:${d === 'rtl' ? 'right' : 'left'}`);
|
|
2348
2684
|
});
|
|
2685
|
+
/* Images: an email has none of the editor's CSS, so an image with no explicit
|
|
2686
|
+
size renders at its raw pixel size in the mail client. Take the size it
|
|
2687
|
+
actually has on screen from the live editor (index-matched — the clone was
|
|
2688
|
+
built from the same HTML) and make it explicit. The width attribute is what
|
|
2689
|
+
Outlook desktop follows; max-width keeps it inside narrow mobile clients.
|
|
2690
|
+
Documents saved before this existed are fixed here too, without rewriting
|
|
2691
|
+
anything the host has stored. */
|
|
2692
|
+
const liveImgs = this.ed.querySelectorAll('img');
|
|
2693
|
+
box.querySelectorAll('img').forEach((img, i) => {
|
|
2694
|
+
if (img.closest('.tpl-media'))
|
|
2695
|
+
return;
|
|
2696
|
+
const live = liveImgs[i];
|
|
2697
|
+
const w = Math.round(live?.getBoundingClientRect().width || 0);
|
|
2698
|
+
if (w > 0 && !img.getAttribute('width'))
|
|
2699
|
+
img.setAttribute('width', String(w));
|
|
2700
|
+
const style = img.getAttribute('style') || '';
|
|
2701
|
+
if (w > 0 && !/(^|;)\s*width\s*:/i.test(style))
|
|
2702
|
+
setStyle(img, `width:${w}px`);
|
|
2703
|
+
if (!/max-width\s*:/i.test(img.getAttribute('style') || ''))
|
|
2704
|
+
setStyle(img, 'max-width:100%');
|
|
2705
|
+
if (!/(^|;)\s*height\s*:/i.test(img.getAttribute('style') || ''))
|
|
2706
|
+
setStyle(img, 'height:auto');
|
|
2707
|
+
});
|
|
2708
|
+
/* Borders last, so the per-side shorthands it writes are the final word on
|
|
2709
|
+
every cell. Also pin border-collapse inline and mirror it onto the legacy
|
|
2710
|
+
cellspacing attribute: a mail pipeline that drops border-collapse leaves
|
|
2711
|
+
the table in `separate` mode, where any cellspacing of its own would open
|
|
2712
|
+
gaps between Word's deliberately shared cell edges. */
|
|
2713
|
+
hardenTableBorders(box);
|
|
2714
|
+
box.querySelectorAll('table').forEach((tbl) => {
|
|
2715
|
+
const style = tbl.getAttribute('style') || '';
|
|
2716
|
+
if (!/border-collapse\s*:/i.test(style))
|
|
2717
|
+
setStyle(tbl, 'border-collapse:collapse');
|
|
2718
|
+
if (!/border-spacing\s*:/i.test(style) && !tbl.hasAttribute('cellspacing'))
|
|
2719
|
+
tbl.setAttribute('cellspacing', '0');
|
|
2720
|
+
});
|
|
2349
2721
|
const dir = found || this.ed.getAttribute('dir') || this.dir();
|
|
2350
2722
|
box.querySelectorAll('table:not([dir])').forEach((tbl) => {
|
|
2351
2723
|
tbl.setAttribute('dir', dir);
|
|
@@ -2367,6 +2739,7 @@ class FableEditor {
|
|
|
2367
2739
|
this.clearCodeSel();
|
|
2368
2740
|
this.clearSelToolbar();
|
|
2369
2741
|
this.onChange();
|
|
2742
|
+
this.stampExistingImages();
|
|
2370
2743
|
}
|
|
2371
2744
|
insertContent(html) {
|
|
2372
2745
|
this.restoreSel();
|
|
@@ -4161,9 +4534,18 @@ class FableEditor {
|
|
|
4161
4534
|
}
|
|
4162
4535
|
previewDlg() {
|
|
4163
4536
|
this.dialog(this.t('previewttl'), (body) => {
|
|
4537
|
+
/* The preview renders the document at the editor's own column width, so an
|
|
4538
|
+
image is exactly the size the user left it at — a narrower preview pane
|
|
4539
|
+
would silently shrink it and misreport what will be sent. The dialog gets
|
|
4540
|
+
a wider cap to make that width reachable; on a viewport too small for it
|
|
4541
|
+
the image's max-width:100% scales things down rather than clipping. */
|
|
4542
|
+
body.closest('.dlg')?.classList.add('dlg-preview');
|
|
4164
4543
|
const box = document.createElement('div');
|
|
4544
|
+
box.className = 'pv-box';
|
|
4545
|
+
const w = this.contentWidth();
|
|
4165
4546
|
box.style.cssText =
|
|
4166
|
-
|
|
4547
|
+
`width:${w > 0 ? Math.round(w) : 640}px;max-width:100%;max-height:52vh;overflow:auto;` +
|
|
4548
|
+
'border:1px solid #e3e3e3;border-radius:6px;padding:14px;font-family:Helvetica,Arial,sans-serif;font-size:14px';
|
|
4167
4549
|
box.innerHTML = this.ed.innerHTML;
|
|
4168
4550
|
body.appendChild(box);
|
|
4169
4551
|
});
|
|
@@ -4407,11 +4789,91 @@ class FableEditor {
|
|
|
4407
4789
|
if (title)
|
|
4408
4790
|
img.title = title;
|
|
4409
4791
|
ph.replaceWith(img);
|
|
4792
|
+
this.stampImageSize(img);
|
|
4410
4793
|
if (this.phActive === ph)
|
|
4411
4794
|
this.clearImgPlaceholderSel();
|
|
4412
4795
|
this.refreshState();
|
|
4413
4796
|
this.onChange();
|
|
4414
4797
|
}
|
|
4798
|
+
/** Width of the editor's text column — the width `.earea img { max-width:100% }`
|
|
4799
|
+
* clamps an oversized image to. */
|
|
4800
|
+
contentWidth() {
|
|
4801
|
+
const cs = getComputedStyle(this.ed);
|
|
4802
|
+
const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
|
|
4803
|
+
return Math.max(0, this.ed.clientWidth - pad);
|
|
4804
|
+
}
|
|
4805
|
+
/** Inserts an <img> through execCommand and stamps its size once it has decoded.
|
|
4806
|
+
* A throwaway id is the only way to get a handle on what insertHTML created. */
|
|
4807
|
+
insertImageHTML(src, title) {
|
|
4808
|
+
const id = 'fable-img-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
4809
|
+
document.execCommand('insertHTML', false, `<img id="${id}" src="${src}"${title ? ` title="${title}"` : ''} alt="">`);
|
|
4810
|
+
const img = this.ed.querySelector('#' + id);
|
|
4811
|
+
if (!img)
|
|
4812
|
+
return;
|
|
4813
|
+
img.removeAttribute('id');
|
|
4814
|
+
this.stampImageSize(img);
|
|
4815
|
+
}
|
|
4816
|
+
/** Gives a newly inserted image an explicit size so it renders the same in the
|
|
4817
|
+
* editor, in Preview and in a sent email. Without it the image only looks right
|
|
4818
|
+
* inside `.earea`, whose `max-width:100%` rule exists nowhere else — which is why
|
|
4819
|
+
* the same picture came out at a different size once the mail was sent.
|
|
4820
|
+
* Leaves alone: images inside a template media slot (deliberately fluid) and any
|
|
4821
|
+
* image that already carries a size from its source. */
|
|
4822
|
+
stampImageSize(img, onSettled) {
|
|
4823
|
+
const finish = (stamped) => {
|
|
4824
|
+
if (onSettled)
|
|
4825
|
+
onSettled(stamped);
|
|
4826
|
+
else if (stamped)
|
|
4827
|
+
this.onChange();
|
|
4828
|
+
};
|
|
4829
|
+
const apply = () => {
|
|
4830
|
+
if (!this.ed.contains(img))
|
|
4831
|
+
return finish(false);
|
|
4832
|
+
if (img.closest('.tpl-media'))
|
|
4833
|
+
return finish(false);
|
|
4834
|
+
if (img.getAttribute('width') || img.getAttribute('height'))
|
|
4835
|
+
return finish(false);
|
|
4836
|
+
if (img.style.width || img.style.height)
|
|
4837
|
+
return finish(false);
|
|
4838
|
+
const natural = img.naturalWidth;
|
|
4839
|
+
if (!natural)
|
|
4840
|
+
return finish(false); /* not decoded (or jsdom) — nothing reliable to stamp */
|
|
4841
|
+
const max = this.contentWidth();
|
|
4842
|
+
const w = Math.round(max > 0 ? Math.min(natural, max) : natural);
|
|
4843
|
+
img.setAttribute('width', String(w));
|
|
4844
|
+
img.style.width = w + 'px';
|
|
4845
|
+
img.style.height = 'auto';
|
|
4846
|
+
finish(true);
|
|
4847
|
+
};
|
|
4848
|
+
/* `complete` is also true for an image that failed or has not decoded yet, so
|
|
4849
|
+
wait for load unless there are real pixel dimensions to read */
|
|
4850
|
+
if (img.complete && img.naturalWidth)
|
|
4851
|
+
apply();
|
|
4852
|
+
else {
|
|
4853
|
+
img.addEventListener('load', apply, { once: true });
|
|
4854
|
+
img.addEventListener('error', () => finish(false), { once: true });
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
/** Sizes images in content that arrived from outside the editor — a document saved
|
|
4858
|
+
* before the editor started recording image sizes. Without this such a document
|
|
4859
|
+
* keeps rendering at one size here and another in a sent mail. Images that already
|
|
4860
|
+
* carry a size, and template slots, are left alone, so content written by a
|
|
4861
|
+
* current version passes through untouched. Emits one change after the images have
|
|
4862
|
+
* settled rather than one per image, and none at all if nothing needed stamping. */
|
|
4863
|
+
stampExistingImages() {
|
|
4864
|
+
const imgs = Array.from(this.ed.querySelectorAll('img'));
|
|
4865
|
+
if (!imgs.length)
|
|
4866
|
+
return;
|
|
4867
|
+
let waiting = imgs.length;
|
|
4868
|
+
let changed = false;
|
|
4869
|
+
const settle = (stamped) => {
|
|
4870
|
+
if (stamped)
|
|
4871
|
+
changed = true;
|
|
4872
|
+
if (--waiting === 0 && changed)
|
|
4873
|
+
this.onChange();
|
|
4874
|
+
};
|
|
4875
|
+
imgs.forEach((img) => this.stampImageSize(img, settle));
|
|
4876
|
+
}
|
|
4415
4877
|
positionImgPhCtx() {
|
|
4416
4878
|
if (this.phActive && !document.body.contains(this.phActive)) {
|
|
4417
4879
|
this.clearImgPlaceholderSel();
|
|
@@ -5723,6 +6185,14 @@ class FableEditor {
|
|
|
5723
6185
|
window.removeEventListener('mousemove', mv);
|
|
5724
6186
|
window.removeEventListener('mouseup', up);
|
|
5725
6187
|
restore();
|
|
6188
|
+
/* mirror the final size onto the width attribute: Outlook desktop
|
|
6189
|
+
follows the attribute rather than the CSS, so without this a resized
|
|
6190
|
+
image still goes out at its original size */
|
|
6191
|
+
const w = parseInt(img.style.width, 10);
|
|
6192
|
+
if (w > 0 && !img.closest('.tpl-media')) {
|
|
6193
|
+
img.setAttribute('width', String(w));
|
|
6194
|
+
img.removeAttribute('height');
|
|
6195
|
+
}
|
|
5726
6196
|
this.onChange();
|
|
5727
6197
|
};
|
|
5728
6198
|
window.addEventListener('mousemove', mv);
|
|
@@ -5908,7 +6378,7 @@ class FableEditor {
|
|
|
5908
6378
|
if (imgItem && !html) {
|
|
5909
6379
|
const fr = new FileReader();
|
|
5910
6380
|
fr.onload = () => {
|
|
5911
|
-
|
|
6381
|
+
this.insertImageHTML(fr.result);
|
|
5912
6382
|
this.onChange();
|
|
5913
6383
|
};
|
|
5914
6384
|
fr.readAsDataURL(imgItem.getAsFile());
|
|
@@ -5927,11 +6397,84 @@ class FableEditor {
|
|
|
5927
6397
|
}
|
|
5928
6398
|
}
|
|
5929
6399
|
if (html) {
|
|
5930
|
-
|
|
6400
|
+
this.pasteRichHTML(html, cd, imgItem);
|
|
5931
6401
|
}
|
|
5932
6402
|
else {
|
|
5933
6403
|
document.execCommand('insertHTML', false, normalizeTextPaste(cd.getData('text/plain')));
|
|
6404
|
+
this.onChange();
|
|
6405
|
+
}
|
|
6406
|
+
}
|
|
6407
|
+
/** Rich-HTML paste. Before the (untouched) paste engine runs, pictures Word left
|
|
6408
|
+
* behind as unreadable file:// refs are pulled out of the clipboard's RTF flavor
|
|
6409
|
+
* and inlined — the same pairing TinyMCE PowerPaste does for
|
|
6410
|
+
* powerpaste_allow_local_images. Everything the engine already handled is
|
|
6411
|
+
* unaffected: with no RTF, or no recoverable picture, the HTML reaches it
|
|
6412
|
+
* byte-identical to before. */
|
|
6413
|
+
pasteRichHTML(html, cd, imgItem) {
|
|
6414
|
+
let injected = [];
|
|
6415
|
+
const rtf = cd.getData('text/rtf');
|
|
6416
|
+
if (rtf) {
|
|
6417
|
+
const res = injectRtfImages(html, rtf);
|
|
6418
|
+
html = res.html;
|
|
6419
|
+
injected = res.injected;
|
|
6420
|
+
}
|
|
6421
|
+
/* Word always supplies text/html, so the bitmap-only branch in handlePaste
|
|
6422
|
+
never fires for a Word paste. When exactly one image is still unresolved and
|
|
6423
|
+
the clipboard carries that bitmap, use it instead of the placeholder. */
|
|
6424
|
+
if (imgItem && countUnresolvedImages(html) === 1) {
|
|
6425
|
+
const blob = imgItem.getAsFile();
|
|
6426
|
+
if (blob) {
|
|
6427
|
+
const pending = html;
|
|
6428
|
+
this.saveSel();
|
|
6429
|
+
const fr = new FileReader();
|
|
6430
|
+
fr.onload = () => {
|
|
6431
|
+
const res = injectImages(pending, [fr.result]);
|
|
6432
|
+
this.finishRichPaste(res.html, injected.concat(res.injected), true);
|
|
6433
|
+
};
|
|
6434
|
+
fr.onerror = () => this.finishRichPaste(pending, injected, true);
|
|
6435
|
+
fr.readAsDataURL(blob);
|
|
6436
|
+
return;
|
|
6437
|
+
}
|
|
5934
6438
|
}
|
|
6439
|
+
this.finishRichPaste(html, injected);
|
|
6440
|
+
}
|
|
6441
|
+
/** Hands recovered pictures to the host's imageUploadHandler when one is
|
|
6442
|
+
* configured (PowerPaste's automatic_uploads equivalent), then inserts. Without a
|
|
6443
|
+
* handler they stay inline base64, which is what PowerPaste does by default. */
|
|
6444
|
+
finishRichPaste(html, injected, deferred = false) {
|
|
6445
|
+
if (!this.imageUploadHandler || !injected.length) {
|
|
6446
|
+
this.insertPastedHTML(html, deferred);
|
|
6447
|
+
return;
|
|
6448
|
+
}
|
|
6449
|
+
const handler = this.imageUploadHandler;
|
|
6450
|
+
const unique = injected.filter((src, i) => injected.indexOf(src) === i);
|
|
6451
|
+
if (!deferred)
|
|
6452
|
+
this.saveSel();
|
|
6453
|
+
Promise.all(unique.map((src, i) => {
|
|
6454
|
+
const file = dataUrlToFile(src, 'pasted-image-' + (i + 1));
|
|
6455
|
+
if (!file)
|
|
6456
|
+
return Promise.resolve(src);
|
|
6457
|
+
return handler(file).catch((err) => {
|
|
6458
|
+
this.onImageUploadError?.(err, file);
|
|
6459
|
+
return src; /* keep the inline copy so the picture is never lost */
|
|
6460
|
+
});
|
|
6461
|
+
})).then((urls) => {
|
|
6462
|
+
let out = html;
|
|
6463
|
+
unique.forEach((src, i) => {
|
|
6464
|
+
if (urls[i] !== src)
|
|
6465
|
+
out = out.split(src).join(urls[i]);
|
|
6466
|
+
});
|
|
6467
|
+
this.insertPastedHTML(out, true);
|
|
6468
|
+
});
|
|
6469
|
+
}
|
|
6470
|
+
/** `deferred` means the insert is happening after an async hop, so the caret the
|
|
6471
|
+
* paste started from has to be put back first. */
|
|
6472
|
+
insertPastedHTML(html, deferred = false) {
|
|
6473
|
+
if (deferred)
|
|
6474
|
+
this.restoreSel();
|
|
6475
|
+
document.execCommand('insertHTML', false, cleanPastedHTML(html, this.dir()));
|
|
6476
|
+
if (deferred)
|
|
6477
|
+
this.saveSel();
|
|
5935
6478
|
this.onChange();
|
|
5936
6479
|
}
|
|
5937
6480
|
/* ---------------------------------------------------------- init */
|