@h-k-dev/angular-email-editor 0.0.6 → 0.0.7
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.
|
@@ -1265,6 +1265,13 @@ function passesDualContrast(hex) {
|
|
|
1265
1265
|
return (contrastRatio(hex, DUAL_CONTRAST_LIGHT) >= DUAL_CONTRAST_MIN_RATIO &&
|
|
1266
1266
|
contrastRatio(hex, DUAL_CONTRAST_DARK) >= DUAL_CONTRAST_MIN_RATIO);
|
|
1267
1267
|
}
|
|
1268
|
+
/**
|
|
1269
|
+
* The dark surfaces the dark variants are held to: Apple Mail's reading pane
|
|
1270
|
+
* and Outlook's (new Outlook for Windows, Outlook.com) — the clients that
|
|
1271
|
+
* read the email's own dark-mode styles (dark-mode.ts). AA (4.5:1) for a
|
|
1272
|
+
* colour on each, and AAA (7:1) for the body's black on each.
|
|
1273
|
+
*/
|
|
1274
|
+
const DARK_MODE_SURFACES = ['#1e1e1e', '#292929'];
|
|
1268
1275
|
/** Flip a `#rrggbb` colour the way a forced-inversion client (Gmail/Outlook
|
|
1269
1276
|
dark mode) roughly does: each channel to its complement. */
|
|
1270
1277
|
function invert(hex) {
|
|
@@ -1311,7 +1318,7 @@ const FILL_TEXT_COLOR_RGB = 'rgb(32, 33, 36)';
|
|
|
1311
1318
|
const FILL_TEXT_COLOR_LIGHT = '#ffffff';
|
|
1312
1319
|
/** A `#rgb`, `#rrggbb` or CSSOM `rgb()` / `rgba()` colour as `#rrggbb`, or
|
|
1313
1320
|
null for anything else (a keyword, a function this does not read). */
|
|
1314
|
-
function
|
|
1321
|
+
function cssColorToHex(raw) {
|
|
1315
1322
|
const v = raw.trim().toLowerCase();
|
|
1316
1323
|
if (/^#[0-9a-f]{6}$/.test(v))
|
|
1317
1324
|
return v;
|
|
@@ -1334,7 +1341,7 @@ function cssomRgb(hex) {
|
|
|
1334
1341
|
/** The text a fill is paired with: white on a dark fill, near-black on
|
|
1335
1342
|
anything else (a colour this cannot read included). */
|
|
1336
1343
|
function fillTextColor(background) {
|
|
1337
|
-
const hex =
|
|
1344
|
+
const hex = cssColorToHex(background);
|
|
1338
1345
|
if (!hex)
|
|
1339
1346
|
return FILL_TEXT_COLOR;
|
|
1340
1347
|
return contrastRatio(hex, FILL_TEXT_COLOR_LIGHT) > contrastRatio(hex, FILL_TEXT_COLOR)
|
|
@@ -1365,54 +1372,68 @@ function passesDualBackground(hex) {
|
|
|
1365
1372
|
* passing {@link passesDualBackground} — proven by test. Shared by every
|
|
1366
1373
|
* background affordance (text highlight, table cells, column panels) so fills
|
|
1367
1374
|
* read the same everywhere and survive inversion. Black carries white text.
|
|
1375
|
+
*
|
|
1376
|
+
* Each pale fill has a dark variant (dark-mode.ts): the same hue at about
|
|
1377
|
+
* the luminance of a dark client's page, so its paired text's variant reads
|
|
1378
|
+
* on it at AAA and every text colour's variant at AA. Black has none: white
|
|
1379
|
+
* on it already reads on a dark page.
|
|
1368
1380
|
*/
|
|
1369
1381
|
const emailBackgroundPalette = [
|
|
1370
|
-
{ name: 'White', value: '#ffffff' },
|
|
1371
|
-
{ name: 'Gray', value: '#f1f3f4' },
|
|
1382
|
+
{ name: 'White', value: '#ffffff', dark: '#1e1e1e' },
|
|
1383
|
+
{ name: 'Gray', value: '#f1f3f4', dark: '#2f3031' },
|
|
1372
1384
|
{ name: 'Black', value: '#202124' },
|
|
1373
|
-
{ name: 'Red', value: '#fce8e6' },
|
|
1374
|
-
{ name: 'Orange', value: '#feefe3' },
|
|
1375
|
-
{ name: 'Yellow', value: '#fef7e0' },
|
|
1376
|
-
{ name: 'Green', value: '#e6f4ea' },
|
|
1377
|
-
{ name: 'Teal', value: '#e0f2f1' },
|
|
1378
|
-
{ name: 'Blue', value: '#e8f0fe' },
|
|
1379
|
-
{ name: 'Purple', value: '#f3e8fd' },
|
|
1380
|
-
{ name: 'Pink', value: '#fce7f0' },
|
|
1385
|
+
{ name: 'Red', value: '#fce8e6', dark: '#4a2624' },
|
|
1386
|
+
{ name: 'Orange', value: '#feefe3', dark: '#462a10' },
|
|
1387
|
+
{ name: 'Yellow', value: '#fef7e0', dark: '#3b2f01' },
|
|
1388
|
+
{ name: 'Green', value: '#e6f4ea', dark: '#183624' },
|
|
1389
|
+
{ name: 'Teal', value: '#e0f2f1', dark: '#083635' },
|
|
1390
|
+
{ name: 'Blue', value: '#e8f0fe', dark: '#22304b' },
|
|
1391
|
+
{ name: 'Purple', value: '#f3e8fd', dark: '#3e264f' },
|
|
1392
|
+
{ name: 'Pink', value: '#fce7f0', dark: '#4a2437' },
|
|
1381
1393
|
];
|
|
1382
1394
|
/**
|
|
1383
1395
|
* The curated text palette: mid-tone hues that pass {@link passesDualContrast}
|
|
1384
1396
|
* — proven by test, not by promise. This is what a color picker should offer
|
|
1385
1397
|
* instead of an arbitrary hex input.
|
|
1386
1398
|
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1399
|
+
* Every colour reads at AA (4.5:1) on white and on every pale fill, and so
|
|
1400
|
+
* does its inversion on black (Gmail's dark mode). Its dark variant reads at
|
|
1401
|
+
* AA on {@link DARK_MODE_SURFACES} and on every fill's dark variant — what
|
|
1402
|
+
* Apple Mail and Outlook show, where the email's dark-mode styles are read
|
|
1403
|
+
* (dark-mode.ts). The tones are each hue's lightest that holds on the
|
|
1404
|
+
* palest-but-one fill; the variants are the same hue, lit just enough.
|
|
1405
|
+
*
|
|
1406
|
+
* Black and white are the two exceptions to the mid-tone rule (no neutral
|
|
1407
|
+
* near either end passes: the band runs from #616161 to #949494), offered
|
|
1408
|
+
* because a writer needs them — black to take coloured text back to plain,
|
|
1409
|
+
* white for text on a dark fill. Black is the body's text, so it is held to
|
|
1410
|
+
* AAA (7:1) in both modes.
|
|
1391
1411
|
*/
|
|
1392
1412
|
const emailTextPalette = [
|
|
1393
1413
|
{
|
|
1394
1414
|
name: 'Black',
|
|
1395
1415
|
value: '#202124',
|
|
1396
|
-
|
|
1416
|
+
dark: '#e8eaed',
|
|
1417
|
+
exception: 'Vanishes on a dark client that keeps explicit colours and reads no dark-mode styles; inverters flip it, the rest take its dark variant.',
|
|
1397
1418
|
},
|
|
1398
|
-
{ name: 'Gray', value: '#5f6368' },
|
|
1419
|
+
{ name: 'Gray', value: '#5f6368', dark: '#969a9f' },
|
|
1399
1420
|
{
|
|
1400
1421
|
name: 'White',
|
|
1401
1422
|
value: '#ffffff',
|
|
1402
1423
|
exception: 'Invisible on a white page — it is for text on a dark fill.',
|
|
1403
1424
|
},
|
|
1404
|
-
{ name: 'Brown', value: '#
|
|
1405
|
-
{ name: 'Red', value: '#c5221f' },
|
|
1406
|
-
{ name: 'Orange', value: '#
|
|
1407
|
-
{ name: 'Amber', value: '#
|
|
1408
|
-
{ name: 'Olive', value: '#
|
|
1409
|
-
{ name: 'Green', value: '#
|
|
1410
|
-
{ name: 'Teal', value: '#0f766e' },
|
|
1411
|
-
{ name: 'Cyan', value: '#0e7490' },
|
|
1412
|
-
{ name: 'Blue', value: '#
|
|
1413
|
-
{ name: 'Indigo', value: '#
|
|
1414
|
-
{ name: 'Purple', value: '#9333ea' },
|
|
1415
|
-
{ name: 'Pink', value: '#c2185b' },
|
|
1425
|
+
{ name: 'Brown', value: '#836459', dark: '#b39287' },
|
|
1426
|
+
{ name: 'Red', value: '#c5221f', dark: '#ff695b' },
|
|
1427
|
+
{ name: 'Orange', value: '#bf3e06', dark: '#f67145' },
|
|
1428
|
+
{ name: 'Amber', value: '#9e5a09', dark: '#d38943' },
|
|
1429
|
+
{ name: 'Olive', value: '#6e6e06', dark: '#9d9e34' },
|
|
1430
|
+
{ name: 'Green', value: '#0f7b33', dark: '#4dac61' },
|
|
1431
|
+
{ name: 'Teal', value: '#0f766e', dark: '#50a89f' },
|
|
1432
|
+
{ name: 'Cyan', value: '#0e7490', dark: '#4da4c1' },
|
|
1433
|
+
{ name: 'Blue', value: '#0366da', dark: '#5299ff' },
|
|
1434
|
+
{ name: 'Indigo', value: '#5858e2', dark: '#8590fe' },
|
|
1435
|
+
{ name: 'Purple', value: '#9333ea', dark: '#b77efe' },
|
|
1436
|
+
{ name: 'Pink', value: '#c2185b', dark: '#fd6490' },
|
|
1416
1437
|
];
|
|
1417
1438
|
|
|
1418
1439
|
/** An inline atom with a box of its own — what a range can cover like a
|
|
@@ -2057,6 +2078,19 @@ const inlinePadding = (el) => inlineBox(el, 'padding');
|
|
|
2057
2078
|
function isZeroSide(side) {
|
|
2058
2079
|
return /^0(?:px|em|rem|%)?$/i.test(side.trim());
|
|
2059
2080
|
}
|
|
2081
|
+
/** Whether an element says, inline, that it has no space above — its top
|
|
2082
|
+
margin declared (`margin` or `margin-top`, the later winning) and zero.
|
|
2083
|
+
One that says nothing had a client's default space above it. What marks
|
|
2084
|
+
a heading or a list as **layout**: flush, its spacing left to the layout
|
|
2085
|
+
around it. */
|
|
2086
|
+
function declaresFlushTop(el) {
|
|
2087
|
+
const top = parseDeclarations$1(el.getAttribute('style') ?? '')
|
|
2088
|
+
.filter(({ name }) => name === 'margin' || name === 'margin-top')
|
|
2089
|
+
.at(-1);
|
|
2090
|
+
if (!top)
|
|
2091
|
+
return false;
|
|
2092
|
+
return isZeroSide(top.name === 'margin' ? top.value.trim().split(/\s+/)[0] : top.value);
|
|
2093
|
+
}
|
|
2060
2094
|
/**
|
|
2061
2095
|
* Rewrites, under `root`, every `style` attribute whose text an engine
|
|
2062
2096
|
* might read wrong — see the module note — into the same declarations in
|
|
@@ -2577,11 +2611,11 @@ const Table = defineNode({
|
|
|
2577
2611
|
// Index-addressed variants for the hover controls (a handle targets a
|
|
2578
2612
|
// specific row/column, independent of where the cursor sits).
|
|
2579
2613
|
addRowAt: (index) => editTable((tr, rect) => {
|
|
2580
|
-
addRow(tr, rect, clamp$
|
|
2614
|
+
addRow(tr, rect, clamp$4(index, 0, rect.map.height));
|
|
2581
2615
|
return true;
|
|
2582
2616
|
}),
|
|
2583
2617
|
addColumnAt: (index) => editTable((tr, rect) => {
|
|
2584
|
-
addColumn$1(tr, rect, clamp$
|
|
2618
|
+
addColumn$1(tr, rect, clamp$4(index, 0, rect.map.width));
|
|
2585
2619
|
rescaleForNewColumn(tr, rect.tableStart - 1);
|
|
2586
2620
|
return true;
|
|
2587
2621
|
}),
|
|
@@ -2813,7 +2847,7 @@ function findTableContext(state) {
|
|
|
2813
2847
|
}
|
|
2814
2848
|
return null;
|
|
2815
2849
|
}
|
|
2816
|
-
const clamp$
|
|
2850
|
+
const clamp$4 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
2817
2851
|
/** Runs a structural edit against the table holding the selection. The
|
|
2818
2852
|
transaction is built even while probing (no dispatch) so enablement is
|
|
2819
2853
|
reported by the edit itself — cheap, and it can't disagree with what the
|
|
@@ -6735,7 +6769,7 @@ function holdsCommentChild(element) {
|
|
|
6735
6769
|
return false;
|
|
6736
6770
|
}
|
|
6737
6771
|
/** No elements and no text but HTML whitespace. */
|
|
6738
|
-
function isBare(element) {
|
|
6772
|
+
function isBare$1(element) {
|
|
6739
6773
|
return element.children.length === 0 && /^[ \t\n\f\r]*$/.test(element.textContent ?? '');
|
|
6740
6774
|
}
|
|
6741
6775
|
/** Same tag and attributes, element by element. */
|
|
@@ -6808,7 +6842,7 @@ function recognize(schema, rule, typeOf) {
|
|
|
6808
6842
|
// Nothing inside, where the node's own rendering of nothing puts
|
|
6809
6843
|
// something: MJML's divider is an empty `<p>` drawing a border, and the
|
|
6810
6844
|
// `<br>` an empty line is written with would give it a line's height.
|
|
6811
|
-
if (isBare(content) && canonicalPath[canonicalPath.length - 1].hasChildNodes())
|
|
6845
|
+
if (isBare$1(content) && canonicalPath[canonicalPath.length - 1].hasChildNodes())
|
|
6812
6846
|
return false;
|
|
6813
6847
|
if (!leaf && !type.inlineContent && !holdsForeignBlocks) {
|
|
6814
6848
|
let match = type.contentMatch;
|
|
@@ -9595,6 +9629,203 @@ const trimWhitespace = (text) => text.replace(/^ | $/g, '');
|
|
|
9595
9629
|
as a plain space, or as nothing. */
|
|
9596
9630
|
const sourceText = (text) => escapeText(text).replace(/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, (ch) => (ch === '\u00a0' ? ' ' : `&#${ch.charCodeAt(0)};`));
|
|
9597
9631
|
|
|
9632
|
+
/**
|
|
9633
|
+
* Dark mode, written into the email: the palette's own dark variants
|
|
9634
|
+
* ({@link PaletteColor.dark}) for the clients that read an email's dark-mode
|
|
9635
|
+
* styles, so a curated colour reads at AA there too — which no single value
|
|
9636
|
+
* can do on a white page and a dark one at once (dual-contrast.ts).
|
|
9637
|
+
*
|
|
9638
|
+
* - **Apple Mail** (macOS, iOS) and **new Outlook / Outlook.com**: the
|
|
9639
|
+
* document says it is drawn for both schemes (`color-scheme: light dark`),
|
|
9640
|
+
* and each palette colour it carries takes its variant — through
|
|
9641
|
+
* `@media (prefers-color-scheme: dark)` for Apple, `[data-ogsc]` /
|
|
9642
|
+
* `[data-ogsb]` for Outlook, which marks a message it darkens that way.
|
|
9643
|
+
* - **Gmail** and **classic Outlook for Windows** read neither and invert
|
|
9644
|
+
* the colours themselves; the light values are chosen to read after that
|
|
9645
|
+
* inversion, and the rules sit in a `<style>` of their own, so a client
|
|
9646
|
+
* that throws the block away keeps every other rule.
|
|
9647
|
+
*
|
|
9648
|
+
* Only the palette's colours move, and only where the move reads: a fill
|
|
9649
|
+
* goes dark when nothing in it wears a colour outside the palette (dark text
|
|
9650
|
+
* on it would vanish), and a text colour lights up when no background over
|
|
9651
|
+
* it stays light. A colour typed into the HTML source is the author's own,
|
|
9652
|
+
* as everywhere (principle 9) — it stays, and so does what holds it.
|
|
9653
|
+
*
|
|
9654
|
+
* The canonical HTML never carries any of this: {@link markDarkMode} works
|
|
9655
|
+
* on the email document only (`emailDocument`), and the editor shows the
|
|
9656
|
+
* same rendering through {@link darkModeEditorCss} — one set of selectors,
|
|
9657
|
+
* so the two cannot drift.
|
|
9658
|
+
*/
|
|
9659
|
+
/** The library's palette, both sides. */
|
|
9660
|
+
const emailDarkModePalette = {
|
|
9661
|
+
text: emailTextPalette,
|
|
9662
|
+
background: emailBackgroundPalette,
|
|
9663
|
+
};
|
|
9664
|
+
/** The class a text colour's element wears in the email: `aee-dt-c5221f`. */
|
|
9665
|
+
const DARK_TEXT_CLASS = 'aee-dt-';
|
|
9666
|
+
/** The class a fill's element wears in the email: `aee-db-fce8e6`. */
|
|
9667
|
+
const DARK_FILL_CLASS = 'aee-db-';
|
|
9668
|
+
/** What a document says to be drawn in both schemes: Apple Mail then reads
|
|
9669
|
+
its dark-mode rules (and leaves its own recolouring be). */
|
|
9670
|
+
const COLOR_SCHEME_META = '<meta name="color-scheme" content="light dark">' +
|
|
9671
|
+
'<meta name="supported-color-schemes" content="light dark">';
|
|
9672
|
+
/** The same, for the stylesheet. */
|
|
9673
|
+
const COLOR_SCHEME_RULE = ':root { color-scheme: light dark; supported-color-schemes: light dark; }';
|
|
9674
|
+
/** The ways a colour is written in a style: as the editor writes it (`#hex`)
|
|
9675
|
+
and as the CSSOM serialises it (`rgb(r, g, b)`) — an rgb fill (columns)
|
|
9676
|
+
is kept in that form. */
|
|
9677
|
+
function spellings(hex) {
|
|
9678
|
+
const n = parseInt(hex.slice(1), 16);
|
|
9679
|
+
return [hex, `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`];
|
|
9680
|
+
}
|
|
9681
|
+
/** An element whose own text colour is `hex`. */
|
|
9682
|
+
function textSelector(hex) {
|
|
9683
|
+
return [
|
|
9684
|
+
...spellings(hex).flatMap((value) => [
|
|
9685
|
+
`[style^="color: ${value}" i]`,
|
|
9686
|
+
`[style*="; color: ${value}" i]`,
|
|
9687
|
+
]),
|
|
9688
|
+
`font[color="${hex}" i]`,
|
|
9689
|
+
].join(', ');
|
|
9690
|
+
}
|
|
9691
|
+
/** An element filled with `hex` and nothing over it (no picture). */
|
|
9692
|
+
function fillSelector(hex) {
|
|
9693
|
+
return [
|
|
9694
|
+
...spellings(hex).map((value) => `[style*="background-color: ${value}" i]`),
|
|
9695
|
+
`[bgcolor="${hex}" i]`,
|
|
9696
|
+
]
|
|
9697
|
+
.map((selector) => `${selector}:not([style*="url(" i]):not([background])`)
|
|
9698
|
+
.join(', ');
|
|
9699
|
+
}
|
|
9700
|
+
/** Any element with a text colour of its own. */
|
|
9701
|
+
const ANY_TEXT = '[style^="color" i], [style*="; color" i], [style*=";color" i], font[color]';
|
|
9702
|
+
/** Any element with a background of its own. */
|
|
9703
|
+
const ANY_FILL = '[style*="background" i], [bgcolor], [background]';
|
|
9704
|
+
function variants(colors, selector) {
|
|
9705
|
+
const seen = new Set();
|
|
9706
|
+
const out = [];
|
|
9707
|
+
for (const color of colors) {
|
|
9708
|
+
const hex = color.value.toLowerCase();
|
|
9709
|
+
if (!color.dark || !/^#[0-9a-f]{6}$/.test(hex) || seen.has(hex))
|
|
9710
|
+
continue;
|
|
9711
|
+
seen.add(hex);
|
|
9712
|
+
out.push({ hex, dark: color.dark.toLowerCase(), selector: selector(hex) });
|
|
9713
|
+
}
|
|
9714
|
+
return out;
|
|
9715
|
+
}
|
|
9716
|
+
function selectors(palette) {
|
|
9717
|
+
const text = variants(palette.text, textSelector);
|
|
9718
|
+
const fill = variants(palette.background, fillSelector);
|
|
9719
|
+
const mappedText = text.map((v) => v.selector).join(', ');
|
|
9720
|
+
return {
|
|
9721
|
+
text,
|
|
9722
|
+
fill,
|
|
9723
|
+
foreign: mappedText ? `:is(${ANY_TEXT}):not(${mappedText})` : `:is(${ANY_TEXT})`,
|
|
9724
|
+
anyFill: ANY_FILL,
|
|
9725
|
+
mappedFill: fill.map((v) => v.selector).join(', '),
|
|
9726
|
+
};
|
|
9727
|
+
}
|
|
9728
|
+
/**
|
|
9729
|
+
* Marks the palette's colours in an email body for dark mode: a fill that
|
|
9730
|
+
* goes dark wears `aee-db-<hex>`, a text colour that lights up wears
|
|
9731
|
+
* `aee-dt-<hex>`, and `css` holds the rules for exactly those classes.
|
|
9732
|
+
*
|
|
9733
|
+
* A fill goes dark when it is the palette's, nothing in it (itself
|
|
9734
|
+
* included) wears a colour outside the palette, and every background round
|
|
9735
|
+
* it goes dark too. A text colour lights up when it is the palette's and
|
|
9736
|
+
* every background over it (its own included) goes dark — so lit text never
|
|
9737
|
+
* stands on a light fill, nor dark text on a dark one.
|
|
9738
|
+
*/
|
|
9739
|
+
function markDarkMode(html, palette = emailDarkModePalette) {
|
|
9740
|
+
const sel = selectors(palette);
|
|
9741
|
+
if (!sel.text.length && !sel.fill.length)
|
|
9742
|
+
return { html, css: '' };
|
|
9743
|
+
const template = document.createElement('template');
|
|
9744
|
+
template.innerHTML = html;
|
|
9745
|
+
const usedText = new Map();
|
|
9746
|
+
const usedFill = new Map();
|
|
9747
|
+
// Bottom-up: whether a subtree wears only the palette's colours.
|
|
9748
|
+
const clean = new Map();
|
|
9749
|
+
const visitClean = (el) => {
|
|
9750
|
+
let ok = !el.matches(sel.foreign);
|
|
9751
|
+
for (const child of Array.from(el.children))
|
|
9752
|
+
ok = visitClean(child) && ok;
|
|
9753
|
+
clean.set(el, ok);
|
|
9754
|
+
return ok;
|
|
9755
|
+
};
|
|
9756
|
+
// Top-down: classes, under no background that stays light.
|
|
9757
|
+
const visit = (el, underLight) => {
|
|
9758
|
+
let light = underLight;
|
|
9759
|
+
if (el.matches(sel.anyFill)) {
|
|
9760
|
+
const fill = sel.fill.find((v) => el.matches(v.selector));
|
|
9761
|
+
if (fill && clean.get(el) && !underLight) {
|
|
9762
|
+
el.classList.add(DARK_FILL_CLASS + fill.hex.slice(1));
|
|
9763
|
+
usedFill.set(fill.hex, fill.dark);
|
|
9764
|
+
}
|
|
9765
|
+
else {
|
|
9766
|
+
light = true;
|
|
9767
|
+
}
|
|
9768
|
+
}
|
|
9769
|
+
if (!light) {
|
|
9770
|
+
const text = sel.text.find((v) => el.matches(v.selector));
|
|
9771
|
+
if (text) {
|
|
9772
|
+
el.classList.add(DARK_TEXT_CLASS + text.hex.slice(1));
|
|
9773
|
+
usedText.set(text.hex, text.dark);
|
|
9774
|
+
}
|
|
9775
|
+
}
|
|
9776
|
+
for (const child of Array.from(el.children))
|
|
9777
|
+
visit(child, light);
|
|
9778
|
+
};
|
|
9779
|
+
const roots = Array.from(template.content.children);
|
|
9780
|
+
for (const root of roots)
|
|
9781
|
+
visitClean(root);
|
|
9782
|
+
for (const root of roots)
|
|
9783
|
+
visit(root, false);
|
|
9784
|
+
if (!usedText.size && !usedFill.size)
|
|
9785
|
+
return { html, css: '' };
|
|
9786
|
+
const textRules = [...usedText].map(([hex, dark]) => `.${DARK_TEXT_CLASS}${hex.slice(1)} { color: ${dark} !important; }`);
|
|
9787
|
+
const fillRules = [...usedFill].map(([hex, dark]) => `.${DARK_FILL_CLASS}${hex.slice(1)} { background-color: ${dark} !important; }`);
|
|
9788
|
+
const css = [
|
|
9789
|
+
`@media (prefers-color-scheme: dark) { ${[...fillRules, ...textRules].join(' ')} }`,
|
|
9790
|
+
...fillRules.map((rule) => `[data-ogsb] ${rule}`),
|
|
9791
|
+
...textRules.map((rule) => `[data-ogsc] ${rule}`),
|
|
9792
|
+
].join('\n');
|
|
9793
|
+
return { html: template.innerHTML, css };
|
|
9794
|
+
}
|
|
9795
|
+
/**
|
|
9796
|
+
* The same dark rendering for the editor's own surface: every rule of
|
|
9797
|
+
* {@link markDarkMode}, as a stylesheet over the live text under `scope`
|
|
9798
|
+
* (the editor root while it shows dark) — the editor's markup is never
|
|
9799
|
+
* touched. Each colour hands its variant over in a custom property; two
|
|
9800
|
+
* rules then apply them where the email's classes would go.
|
|
9801
|
+
*
|
|
9802
|
+
* darkModeEditorCss(palette, '.paper[data-color-scheme="dark"] > .aee-editor')
|
|
9803
|
+
*/
|
|
9804
|
+
function darkModeEditorCss(scope, palette = emailDarkModePalette) {
|
|
9805
|
+
const sel = selectors(palette);
|
|
9806
|
+
if (!sel.text.length && !sel.fill.length)
|
|
9807
|
+
return '';
|
|
9808
|
+
// A background that stays light: one outside the palette, or one holding a
|
|
9809
|
+
// colour outside it. Everything under it stays as it is.
|
|
9810
|
+
const darkFill = sel.mappedFill
|
|
9811
|
+
? `:is(${sel.mappedFill}):not(${sel.foreign}):not(:has(${sel.foreign}))`
|
|
9812
|
+
: null;
|
|
9813
|
+
const lightFill = darkFill ? `:is(${sel.anyFill}):not(${darkFill})` : `:is(${sel.anyFill})`;
|
|
9814
|
+
const rules = [];
|
|
9815
|
+
for (const v of sel.fill)
|
|
9816
|
+
rules.push(`${scope} :is(${v.selector}) { --aee-dark-fill: ${v.dark}; }`);
|
|
9817
|
+
for (const v of sel.text)
|
|
9818
|
+
rules.push(`${scope} :is(${v.selector}) { --aee-dark-text: ${v.dark}; }`);
|
|
9819
|
+
if (darkFill) {
|
|
9820
|
+
rules.push(`${scope} ${darkFill}:not(${lightFill} *) { background-color: var(--aee-dark-fill) !important; }`);
|
|
9821
|
+
}
|
|
9822
|
+
const mappedText = sel.text.map((v) => v.selector).join(', ');
|
|
9823
|
+
if (mappedText) {
|
|
9824
|
+
rules.push(`${scope} :is(${mappedText}):not(${lightFill}):not(${lightFill} *) { color: var(--aee-dark-text) !important; }`);
|
|
9825
|
+
}
|
|
9826
|
+
return rules.join('\n');
|
|
9827
|
+
}
|
|
9828
|
+
|
|
9598
9829
|
const RENDERING_CLIENTS = [
|
|
9599
9830
|
'apple-mail',
|
|
9600
9831
|
'gmail',
|
|
@@ -9608,12 +9839,15 @@ const RENDERING_CLIENTS = [
|
|
|
9608
9839
|
* put in, and the client's own surface (its default face, size and colours)
|
|
9609
9840
|
* is laid underneath. The result is a whole document for a sandboxed frame.
|
|
9610
9841
|
*
|
|
9611
|
-
* - **Apple Mail** reads it all: the document as it is, on Apple's surface
|
|
9842
|
+
* - **Apple Mail** reads it all: the document as it is, on Apple's surface;
|
|
9843
|
+
* in dark mode its `prefers-color-scheme: dark` rules apply, so a
|
|
9844
|
+
* fragment's palette colours take their dark variants (dark-mode.ts).
|
|
9612
9845
|
* - **Gmail** drops the comments, the form controls (a hamburger's
|
|
9613
9846
|
* checkbox and label), every `<style>` rule whose selector it cannot
|
|
9614
9847
|
* match (pseudo-classes, attribute selectors, sibling combinators), and
|
|
9615
9848
|
* the declarations the data marks as ignored there (position, flex and
|
|
9616
|
-
* grid, transforms, animation).
|
|
9849
|
+
* grid, transforms, animation). It reads no `prefers-color-scheme`,
|
|
9850
|
+
* and its dark mode inverts the colours.
|
|
9617
9851
|
* - **Outlook on Windows** reads what the Word engine reads: the
|
|
9618
9852
|
* conditional comments meant for it are opened (`[if mso]`, `[if mso |
|
|
9619
9853
|
* IE]`, `[if gte mso 9]`) and the parts kept from it are taken out
|
|
@@ -9623,14 +9857,30 @@ const RENDERING_CLIENTS = [
|
|
|
9623
9857
|
* shadows, and the rest), and an `inline-block` block stacks. VML is left
|
|
9624
9858
|
* standing — a browser draws none of it, so a picture behind text shows
|
|
9625
9859
|
* as the colour beneath, which is what an Outlook without VML shows too.
|
|
9860
|
+
* Its dark mode (classic Outlook; the new one is Outlook.com's) recolours
|
|
9861
|
+
* the message itself, as Gmail's does, onto its own dark surface.
|
|
9626
9862
|
*
|
|
9627
9863
|
* Takes a fragment (the editor's canonical HTML) or a whole document (an
|
|
9628
9864
|
* import as it was pasted): a document keeps its head — its `<style>`,
|
|
9629
|
-
* subject to the client — with the client's surface laid before it.
|
|
9865
|
+
* subject to the client — with the client's surface laid before it. A
|
|
9866
|
+
* fragment is drawn as `emailDocument` sends it: its dark-mode classes
|
|
9867
|
+
* and rules put in. Outlook.com's markers (`[data-ogsc]`) are set by none
|
|
9868
|
+
* of the three, so their rules never apply here.
|
|
9630
9869
|
*/
|
|
9631
9870
|
function renderForClient(html, client, options = {}) {
|
|
9632
|
-
const
|
|
9871
|
+
const palette = options.darkMode ?? emailDarkModePalette;
|
|
9872
|
+
const dark = palette && !hasDocumentEnvelope(html) ? markDarkMode(html, palette) : null;
|
|
9873
|
+
const marked = dark ? dark.html : html;
|
|
9874
|
+
const source = client === 'outlook-desktop' ? openOutlookConditionals(marked) : marked;
|
|
9633
9875
|
const doc = new DOMParser().parseFromString(source, 'text/html');
|
|
9876
|
+
if (dark) {
|
|
9877
|
+
const sheet = doc.createElement('style');
|
|
9878
|
+
sheet.textContent = [COLOR_SCHEME_RULE, dark.css].filter(Boolean).join(' ');
|
|
9879
|
+
doc.head.appendChild(sheet);
|
|
9880
|
+
}
|
|
9881
|
+
for (const sheet of Array.from(doc.querySelectorAll('style'))) {
|
|
9882
|
+
sheet.textContent = resolveColorScheme(dropRules(sheet.textContent ?? '', OUTLOOK_COM_MARKER), client === 'apple-mail' && !!options.dark);
|
|
9883
|
+
}
|
|
9634
9884
|
if (client !== 'apple-mail')
|
|
9635
9885
|
dropComments(doc);
|
|
9636
9886
|
if (client === 'gmail') {
|
|
@@ -9660,30 +9910,40 @@ function renderForClient(html, client, options = {}) {
|
|
|
9660
9910
|
return '<!doctype html>' + doc.documentElement.outerHTML;
|
|
9661
9911
|
}
|
|
9662
9912
|
/** Each client's surface: its reading pane's default face, size and
|
|
9663
|
-
colours, under whatever the email says for itself.
|
|
9913
|
+
colours, under whatever the email says for itself. Light whatever the
|
|
9914
|
+
viewer's own scheme is: the frame is the client, not the page round it. */
|
|
9664
9915
|
const SURFACE = {
|
|
9665
9916
|
'apple-mail': `
|
|
9917
|
+
:root { color-scheme: light !important; }
|
|
9666
9918
|
body { margin: 16px; background: #ffffff; color: #000000;
|
|
9667
9919
|
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
9668
9920
|
font-size: 15px; line-height: 1.35; -webkit-text-size-adjust: 100%; }
|
|
9669
9921
|
`,
|
|
9670
9922
|
gmail: `
|
|
9923
|
+
:root { color-scheme: light !important; }
|
|
9671
9924
|
body { margin: 16px; background: #ffffff; color: #222222;
|
|
9672
9925
|
font-family: Arial, Helvetica, sans-serif; font-size: 13px; line-height: 1.4;
|
|
9673
9926
|
word-wrap: break-word; }
|
|
9674
9927
|
`,
|
|
9675
9928
|
'outlook-desktop': `
|
|
9929
|
+
:root { color-scheme: light !important; }
|
|
9676
9930
|
body { margin: 16px; background: #ffffff; color: #000000;
|
|
9677
9931
|
font-family: Calibri, Arial, sans-serif; font-size: 14.67px; line-height: 1.2; }
|
|
9678
9932
|
/* The Word engine draws no block side by side: a column stacks. */
|
|
9679
9933
|
div[style*="inline-block"] { display: block !important; width: auto !important; }
|
|
9680
9934
|
`,
|
|
9681
9935
|
};
|
|
9682
|
-
/** Each client's dark mode
|
|
9683
|
-
|
|
9684
|
-
|
|
9936
|
+
/** Each client's dark mode. Apple Mail turns the surface dark and leaves
|
|
9937
|
+
the message's colours to the message — its own dark-mode rules
|
|
9938
|
+
included. Gmail inverts the message, keeping the hue. Classic Outlook
|
|
9939
|
+
for Windows recolours it too (Litmus' "full invert"; it reads no
|
|
9940
|
+
dark-mode rule), onto its own #292929: `invert(a)` takes white to
|
|
9941
|
+
1 - a, so a = 1 - 41/255 lands the white page on it, and a picture,
|
|
9942
|
+
put through the inversion once more, comes out eased into the page's
|
|
9943
|
+
range. */
|
|
9685
9944
|
const DARK = {
|
|
9686
9945
|
'apple-mail': `
|
|
9946
|
+
:root { color-scheme: dark !important; }
|
|
9687
9947
|
html { background: #1e1e1e; } body { background: #1e1e1e; color: #ffffff; }
|
|
9688
9948
|
`,
|
|
9689
9949
|
gmail: `
|
|
@@ -9691,9 +9951,24 @@ const DARK = {
|
|
|
9691
9951
|
img { filter: invert(1) hue-rotate(180deg); }
|
|
9692
9952
|
`,
|
|
9693
9953
|
'outlook-desktop': `
|
|
9694
|
-
html {
|
|
9954
|
+
html { filter: invert(0.8392) hue-rotate(180deg); background: #ffffff; }
|
|
9955
|
+
img { filter: invert(1) hue-rotate(180deg); }
|
|
9695
9956
|
`,
|
|
9696
9957
|
};
|
|
9958
|
+
/** Outlook.com's markers: set on a message it darkens, by none of the
|
|
9959
|
+
clients drawn here. */
|
|
9960
|
+
const OUTLOOK_COM_MARKER = /\[data-ogs[bc]\]/i;
|
|
9961
|
+
/** The stylesheet without the rules whose selector `marker` matches. */
|
|
9962
|
+
function dropRules(css, marker) {
|
|
9963
|
+
return marker.test(css) ? rewriteRules(css, (selector) => !marker.test(selector)) : css;
|
|
9964
|
+
}
|
|
9965
|
+
/** `prefers-color-scheme` answered for the frame: `dark` true where the
|
|
9966
|
+
client is drawn dark and reads the query (Apple Mail), false everywhere
|
|
9967
|
+
else — a client that reads no such query never matches it, whatever
|
|
9968
|
+
the viewer's own scheme. */
|
|
9969
|
+
function resolveColorScheme(css, dark) {
|
|
9970
|
+
return css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_, scheme) => (scheme.toLowerCase() === 'dark') === dark ? '(min-width: 0px)' : '(max-width: -1px)');
|
|
9971
|
+
}
|
|
9697
9972
|
/** The Word engine reads what the conditional comments keep for it, and
|
|
9698
9973
|
not what they keep from it. */
|
|
9699
9974
|
function openOutlookConditionals(html) {
|
|
@@ -9920,12 +10195,23 @@ function imageText(element) {
|
|
|
9920
10195
|
* keeps the cap — narrower, still whole — and loses nothing else, which is
|
|
9921
10196
|
* the same rule the canonical HTML already keeps.
|
|
9922
10197
|
*
|
|
10198
|
+
* Dark mode is the other enhancement (dark-mode.ts): the document says it
|
|
10199
|
+
* is drawn for both schemes, and the palette's colours in it wear a class
|
|
10200
|
+
* that their dark variant hangs on, in a `<style>` of its own — a client
|
|
10201
|
+
* that reads none of it (Gmail, classic Outlook) inverts the light colours,
|
|
10202
|
+
* which are chosen to read inverted.
|
|
10203
|
+
*
|
|
9923
10204
|
* The result is for the transport, not for the editor: parse and
|
|
9924
10205
|
* `emailPlainText` take the fragment `serializeToHTML` returns (the preview
|
|
9925
10206
|
* text would otherwise read as body text).
|
|
9926
10207
|
*/
|
|
9927
10208
|
function emailDocument(html, options = {}) {
|
|
9928
|
-
const { lang, dir = 'auto', title, previewText } = options;
|
|
10209
|
+
const { lang, dir = 'auto', title, previewText, darkMode = emailDarkModePalette } = options;
|
|
10210
|
+
// Columns first: the stacking class goes in by pattern, on the markup as
|
|
10211
|
+
// the editor wrote it; the dark-mode classes join it on the element.
|
|
10212
|
+
const stacked = markStackingColumns(html);
|
|
10213
|
+
const dark = darkMode ? markDarkMode(stacked, darkMode) : null;
|
|
10214
|
+
const darkStyle = dark ? [COLOR_SCHEME_RULE, dark.css].filter(Boolean).join(' ') : '';
|
|
9929
10215
|
const langAttr = lang ? ` lang="${escapeAttribute$1(lang)}"` : '';
|
|
9930
10216
|
const dirAttr = ` dir="${dir}"`;
|
|
9931
10217
|
return ('<!doctype html>' +
|
|
@@ -9935,14 +10221,16 @@ function emailDocument(html, options = {}) {
|
|
|
9935
10221
|
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' +
|
|
9936
10222
|
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
9937
10223
|
'<meta name="x-apple-disable-message-reformatting">' +
|
|
10224
|
+
(dark ? COLOR_SCHEME_META : '') +
|
|
9938
10225
|
`<title>${escapeText(title ?? '')}</title>` +
|
|
9939
10226
|
OUTLOOK_SETTINGS +
|
|
9940
10227
|
`<style type="text/css">${STACK_RULE}</style>` +
|
|
10228
|
+
(dark ? `<style type="text/css">${darkStyle}</style>` : '') +
|
|
9941
10229
|
'</head>' +
|
|
9942
10230
|
`<body style="${BODY_STYLE}">` +
|
|
9943
10231
|
`<div role="article" aria-roledescription="email"${langAttr}${dirAttr}>` +
|
|
9944
10232
|
(previewText ? previewBlock(previewText) : '') +
|
|
9945
|
-
|
|
10233
|
+
(dark ? dark.html : stacked) +
|
|
9946
10234
|
'</div>' +
|
|
9947
10235
|
'</body>' +
|
|
9948
10236
|
'</html>');
|
|
@@ -10440,11 +10728,32 @@ const toggleList = (listType, itemType) => (state, dispatch, view) => {
|
|
|
10440
10728
|
const depth = findListDepth($from);
|
|
10441
10729
|
if (depth === null)
|
|
10442
10730
|
return wrapInList(listType)(state, dispatch);
|
|
10443
|
-
|
|
10731
|
+
const list = $from.node(depth);
|
|
10732
|
+
if (list.type === listType)
|
|
10444
10733
|
return liftListItem(itemType)(state, dispatch, view);
|
|
10445
|
-
|
|
10734
|
+
// The other flavour, in place: a layout list stays one.
|
|
10735
|
+
dispatch?.(state.tr.setNodeMarkup($from.before(depth), listType, { layout: list.attrs['layout'] }));
|
|
10446
10736
|
return true;
|
|
10447
10737
|
};
|
|
10738
|
+
/**
|
|
10739
|
+
* The layout list's toggle: wraps the selection in a layout list, makes the
|
|
10740
|
+
* list it is in one (either flavour, kept flush), or lifts out of it when it
|
|
10741
|
+
* is one already.
|
|
10742
|
+
*/
|
|
10743
|
+
const toggleLayoutList = (listType, itemType) => (state, dispatch, view) => {
|
|
10744
|
+
const { $from } = state.selection;
|
|
10745
|
+
const depth = findListDepth($from);
|
|
10746
|
+
if (depth === null)
|
|
10747
|
+
return wrapInList(listType, { layout: true })(state, dispatch);
|
|
10748
|
+
const list = $from.node(depth);
|
|
10749
|
+
if (list.type === listType && list.attrs['layout']) {
|
|
10750
|
+
return liftListItem(itemType)(state, dispatch, view);
|
|
10751
|
+
}
|
|
10752
|
+
dispatch?.(state.tr.setNodeMarkup($from.before(depth), listType, { ...list.attrs, layout: true }));
|
|
10753
|
+
return true;
|
|
10754
|
+
};
|
|
10755
|
+
/** Whether the selection is in a list of `type`, a layout one or not. */
|
|
10756
|
+
const inList = (state, type, layout) => isNodeActive(state, type, { layout });
|
|
10448
10757
|
/**
|
|
10449
10758
|
* Tiptap-inspired helper: Intercepts the default splitListItem transaction
|
|
10450
10759
|
* and ensures marks (bold, italic, etc.) are carried over to the new list item.
|
|
@@ -10458,10 +10767,63 @@ const splitListItemKeepMarks = (itemType) => (state, dispatch, view) => {
|
|
|
10458
10767
|
dispatch?.(tr);
|
|
10459
10768
|
}, view);
|
|
10460
10769
|
};
|
|
10461
|
-
/** The
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
|
|
10770
|
+
/** The space above and below a list: Gmail's own. */
|
|
10771
|
+
const LIST_SPACE = 12;
|
|
10772
|
+
/**
|
|
10773
|
+
* A list's canonical inline style. The indent is the ledger's answer to the
|
|
10774
|
+
* 40px-per-level default that eats a third of a phone screen: 24px, nested
|
|
10775
|
+
* lists compounding it, readable at 320px. A list keeps 12px above and below
|
|
10776
|
+
* — Gmail's spacing, the margins the schema writes besides a heading's
|
|
10777
|
+
* (positive px margins render in Gmail, Outlook's Word engine and Apple Mail
|
|
10778
|
+
* alike; where one meets a heading's or another list's, Outlook may add the
|
|
10779
|
+
* two, up to 12px past what a browser shows). A **layout** list sits flush,
|
|
10780
|
+
* `margin: 0`, spacing left to the layout around it — and so does every
|
|
10781
|
+
* nested list, which belongs to its item's line (see {@link listRepair}).
|
|
10782
|
+
*/
|
|
10783
|
+
const listStyle = (layout = false) => `margin: ${layout ? '0px' : `${LIST_SPACE}px 0px`}; padding-left: 24px;`;
|
|
10784
|
+
/** Whether a parsed list is a layout list: written flush (the composer's own
|
|
10785
|
+
before lists were spaced, a builder's reset), or nested in another. */
|
|
10786
|
+
const parsedLayout = (dom) => declaresFlushTop(dom) || !!dom.parentElement?.closest('li, ul, ol');
|
|
10787
|
+
/** Ranges a run of transactions wrote, in the last document's positions. */
|
|
10788
|
+
const writtenRanges = (trs) => {
|
|
10789
|
+
let ranges = [];
|
|
10790
|
+
for (const tr of trs) {
|
|
10791
|
+
for (const map of tr.mapping.maps) {
|
|
10792
|
+
ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]);
|
|
10793
|
+
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
|
|
10794
|
+
if (newEnd > newStart)
|
|
10795
|
+
ranges.push([newStart, newEnd]);
|
|
10796
|
+
});
|
|
10797
|
+
}
|
|
10798
|
+
}
|
|
10799
|
+
return ranges;
|
|
10800
|
+
};
|
|
10801
|
+
/**
|
|
10802
|
+
* Keeps a nested list flush: Tab makes one with the default attrs, and its
|
|
10803
|
+
* margin would push the sub-items off their parent's line. Only what the
|
|
10804
|
+
* transactions wrote is looked at; a nested list is made layout, never back.
|
|
10805
|
+
*/
|
|
10806
|
+
const listRepair = () => new Plugin({
|
|
10807
|
+
key: new PluginKey('listRepair'),
|
|
10808
|
+
appendTransaction: (transactions, _old, state) => {
|
|
10809
|
+
if (!transactions.some((tr) => tr.docChanged))
|
|
10810
|
+
return undefined;
|
|
10811
|
+
const tr = state.tr;
|
|
10812
|
+
const end = state.doc.content.size;
|
|
10813
|
+
for (const [from, to] of writtenRanges(transactions)) {
|
|
10814
|
+
state.doc.nodesBetween(Math.min(from, end), Math.min(to, end), (node, pos, parent) => {
|
|
10815
|
+
if (node.isTextblock)
|
|
10816
|
+
return false;
|
|
10817
|
+
const nested = parent?.type.name === 'listItem';
|
|
10818
|
+
if (nested && node.type.spec.group?.includes('list') && !node.attrs['layout']) {
|
|
10819
|
+
tr.setNodeAttribute(pos, 'layout', true);
|
|
10820
|
+
}
|
|
10821
|
+
return true;
|
|
10822
|
+
});
|
|
10823
|
+
}
|
|
10824
|
+
return tr.docChanged ? tr : undefined;
|
|
10825
|
+
},
|
|
10826
|
+
});
|
|
10465
10827
|
const ListItem = defineNode({
|
|
10466
10828
|
name: 'listItem',
|
|
10467
10829
|
spec: {
|
|
@@ -10481,11 +10843,16 @@ const ListItem = defineNode({
|
|
|
10481
10843
|
const BulletList = defineNode({
|
|
10482
10844
|
name: 'bulletList',
|
|
10483
10845
|
spec: {
|
|
10846
|
+
attrs: {
|
|
10847
|
+
/** Flush, without the space around it — see {@link listStyle}. */
|
|
10848
|
+
layout: { default: false },
|
|
10849
|
+
},
|
|
10484
10850
|
content: 'listItem+',
|
|
10485
10851
|
group: 'block list',
|
|
10486
|
-
parseDOM: [{ tag: 'ul' }],
|
|
10487
|
-
toDOM: () => ['ul', { style:
|
|
10852
|
+
parseDOM: [{ tag: 'ul', getAttrs: (dom) => ({ layout: parsedLayout(dom) }) }],
|
|
10853
|
+
toDOM: (node) => ['ul', { style: listStyle(node.attrs['layout']) }, 0],
|
|
10488
10854
|
},
|
|
10855
|
+
plugins: () => [listRepair()],
|
|
10489
10856
|
commands: ({ schema }) => ({
|
|
10490
10857
|
toggleBulletList: () => toggleList(schema.nodes['bulletList'], schema.nodes['listItem']),
|
|
10491
10858
|
}),
|
|
@@ -10504,14 +10871,27 @@ const BulletList = defineNode({
|
|
|
10504
10871
|
keywords: ['ul', 'unordered', 'list'],
|
|
10505
10872
|
icon: 'format_list_bulleted',
|
|
10506
10873
|
command: toggleList(schema.nodes['bulletList'], schema.nodes['listItem']),
|
|
10507
|
-
isActive: (state) =>
|
|
10874
|
+
isActive: (state) => inList(state, schema.nodes['bulletList'], false),
|
|
10875
|
+
},
|
|
10876
|
+
{
|
|
10877
|
+
id: 'bulleted-list-layout',
|
|
10878
|
+
section: 'blocks',
|
|
10879
|
+
title: 'Bulleted list (layout)',
|
|
10880
|
+
keywords: ['ul', 'unordered', 'list', 'layout', 'flush'],
|
|
10881
|
+
icon: 'format_list_bulleted',
|
|
10882
|
+
command: toggleLayoutList(schema.nodes['bulletList'], schema.nodes['listItem']),
|
|
10883
|
+
isActive: (state) => inList(state, schema.nodes['bulletList'], true),
|
|
10508
10884
|
},
|
|
10509
10885
|
],
|
|
10510
10886
|
});
|
|
10511
10887
|
const OrderedList = defineNode({
|
|
10512
10888
|
name: 'orderedList',
|
|
10513
10889
|
spec: {
|
|
10514
|
-
attrs: {
|
|
10890
|
+
attrs: {
|
|
10891
|
+
order: { default: 1 },
|
|
10892
|
+
/** Flush, without the space around it — see {@link listStyle}. */
|
|
10893
|
+
layout: { default: false },
|
|
10894
|
+
},
|
|
10515
10895
|
content: 'listItem+',
|
|
10516
10896
|
group: 'block list',
|
|
10517
10897
|
parseDOM: [
|
|
@@ -10519,12 +10899,16 @@ const OrderedList = defineNode({
|
|
|
10519
10899
|
tag: 'ol',
|
|
10520
10900
|
getAttrs: (node) => ({
|
|
10521
10901
|
order: node.hasAttribute('start') ? +node.getAttribute('start') : 1,
|
|
10902
|
+
layout: parsedLayout(node),
|
|
10522
10903
|
}),
|
|
10523
10904
|
},
|
|
10524
10905
|
],
|
|
10525
|
-
toDOM: (node) =>
|
|
10526
|
-
|
|
10527
|
-
|
|
10906
|
+
toDOM: (node) => {
|
|
10907
|
+
const style = listStyle(node.attrs['layout']);
|
|
10908
|
+
return node.attrs['order'] === 1
|
|
10909
|
+
? ['ol', { style }, 0]
|
|
10910
|
+
: ['ol', { start: node.attrs['order'], style }, 0];
|
|
10911
|
+
},
|
|
10528
10912
|
},
|
|
10529
10913
|
commands: ({ schema }) => ({
|
|
10530
10914
|
toggleOrderedList: () => toggleList(schema.nodes['orderedList'], schema.nodes['listItem']),
|
|
@@ -10544,7 +10928,16 @@ const OrderedList = defineNode({
|
|
|
10544
10928
|
keywords: ['ol', 'ordered', 'list'],
|
|
10545
10929
|
icon: 'format_list_numbered',
|
|
10546
10930
|
command: toggleList(schema.nodes['orderedList'], schema.nodes['listItem']),
|
|
10547
|
-
isActive: (state) =>
|
|
10931
|
+
isActive: (state) => inList(state, schema.nodes['orderedList'], false),
|
|
10932
|
+
},
|
|
10933
|
+
{
|
|
10934
|
+
id: 'numbered-list-layout',
|
|
10935
|
+
section: 'blocks',
|
|
10936
|
+
title: 'Numbered list (layout)',
|
|
10937
|
+
keywords: ['ol', 'ordered', 'list', 'layout', 'flush'],
|
|
10938
|
+
icon: 'format_list_numbered',
|
|
10939
|
+
command: toggleLayoutList(schema.nodes['orderedList'], schema.nodes['listItem']),
|
|
10940
|
+
isActive: (state) => inList(state, schema.nodes['orderedList'], true),
|
|
10548
10941
|
},
|
|
10549
10942
|
],
|
|
10550
10943
|
});
|
|
@@ -11612,50 +12005,101 @@ const HardBreak = defineNode({
|
|
|
11612
12005
|
}),
|
|
11613
12006
|
});
|
|
11614
12007
|
|
|
11615
|
-
|
|
12008
|
+
/** The levels the schema knows. No h5 or h6: an imported one is a plain
|
|
12009
|
+
line, and `##### ` stays the text it is. */
|
|
12010
|
+
const HEADING_LEVELS = [1, 2, 3, 4];
|
|
11616
12011
|
/** The ledger's conservative scale: reads at 320px and on a desktop alike. */
|
|
11617
|
-
const HEADING_SIZES = { 1: 24, 2: 20, 3: 18, 4: 16
|
|
11618
|
-
/**
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
12012
|
+
const HEADING_SIZES = { 1: 24, 2: 20, 3: 18, 4: 16 };
|
|
12013
|
+
/** The space above a heading, by level: its size less {@link HEADING_SPACE_BELOW}. */
|
|
12014
|
+
const HEADING_SPACE_ABOVE = { 1: 20, 2: 16, 3: 14, 4: 12 };
|
|
12015
|
+
/** The space below every heading. */
|
|
12016
|
+
const HEADING_SPACE_BELOW = 4;
|
|
12017
|
+
/**
|
|
12018
|
+
* A heading's canonical inline style. A heading sits nearer the words it
|
|
12019
|
+
* opens than the words before it (proximity): a margin above, a small one
|
|
12020
|
+
* below — an exception to principle 3, with a list's (lists.ts). Positive px
|
|
12021
|
+
* margins render in Gmail, Outlook's Word engine and Apple Mail alike
|
|
12022
|
+
* (caniemail, 2026-09). Body lines carry none, so two margins meet only
|
|
12023
|
+
* between headings and lists, where a browser collapses the pair and Word
|
|
12024
|
+
* may add it: the space below is one constant 4px, and the space above is
|
|
12025
|
+
* the size less it, so heading after heading Outlook's worst case lands on
|
|
12026
|
+
* the size itself (24/20/18/16), never more than 4px off. A
|
|
12027
|
+
* **layout** heading — a band's title, a column's — sits flush: `margin: 0`,
|
|
12028
|
+
* spacing left to the layout around it. No `line-height` — Outlook
|
|
12029
|
+
* substitutes its own (the client-support module says so) and our output
|
|
12030
|
+
* stays lint-clean. Incoming heading styles never survive: the scale is the
|
|
12031
|
+
* schema's, not the author's.
|
|
12032
|
+
*/
|
|
12033
|
+
const headingStyle = (level, layout = false) => {
|
|
12034
|
+
const size = HEADING_SIZES[level] ?? 16;
|
|
12035
|
+
const above = HEADING_SPACE_ABOVE[level] ?? size - HEADING_SPACE_BELOW;
|
|
12036
|
+
const margin = layout ? '0px' : `${above}px 0px ${HEADING_SPACE_BELOW}px`;
|
|
12037
|
+
return `margin: ${margin}; font-size: ${size}px;`;
|
|
12038
|
+
};
|
|
12039
|
+
/** Makes the line a heading of `level`; a layout heading when `layout`. */
|
|
12040
|
+
const toHeading = (type, level, layout = false) => setBlockType(type, { level, layout });
|
|
12041
|
+
/** Makes the line a layout heading, at its level when it is a heading
|
|
12042
|
+
already, else at 1. */
|
|
12043
|
+
const toLayoutHeading = (type) => (state, dispatch, view) => {
|
|
12044
|
+
const { $from } = state.selection;
|
|
12045
|
+
const level = $from.parent.type === type ? $from.parent.attrs['level'] : 1;
|
|
12046
|
+
return toHeading(type, level, true)(state, dispatch, view);
|
|
12047
|
+
};
|
|
11624
12048
|
const Heading = defineNode({
|
|
11625
12049
|
name: 'heading',
|
|
11626
12050
|
spec: {
|
|
11627
|
-
attrs: {
|
|
12051
|
+
attrs: {
|
|
12052
|
+
level: { default: 1 },
|
|
12053
|
+
/** Flush, without the space above — see {@link headingStyle}. */
|
|
12054
|
+
layout: { default: false },
|
|
12055
|
+
},
|
|
11628
12056
|
content: 'inline*',
|
|
11629
12057
|
group: 'block',
|
|
11630
12058
|
defining: true,
|
|
11631
|
-
parseDOM:
|
|
12059
|
+
parseDOM: HEADING_LEVELS.map((level) => ({
|
|
12060
|
+
tag: `h${level}`,
|
|
12061
|
+
// Written flush — the composer's own before headings were spaced, a
|
|
12062
|
+
// builder's reset title — it stays flush.
|
|
12063
|
+
getAttrs: (dom) => ({ level, layout: declaresFlushTop(dom) }),
|
|
12064
|
+
})),
|
|
11632
12065
|
toDOM: (node) => [
|
|
11633
12066
|
`h${node.attrs['level']}`,
|
|
11634
|
-
{ style: headingStyle(node.attrs['level']) },
|
|
12067
|
+
{ style: headingStyle(node.attrs['level'], node.attrs['layout']) },
|
|
11635
12068
|
0,
|
|
11636
12069
|
],
|
|
11637
12070
|
},
|
|
11638
12071
|
commands: ({ schema }) => ({
|
|
11639
|
-
setHeading: (level) =>
|
|
12072
|
+
setHeading: (level, layout = false) => toHeading(schema.nodes['heading'], level, layout),
|
|
12073
|
+
setLayoutHeading: () => toLayoutHeading(schema.nodes['heading']),
|
|
11640
12074
|
}),
|
|
11641
|
-
keymap: ({ schema }) => Object.fromEntries(
|
|
12075
|
+
keymap: ({ schema }) => Object.fromEntries(HEADING_LEVELS.map((level) => [
|
|
11642
12076
|
`Shift-Ctrl-${level}`,
|
|
11643
|
-
|
|
12077
|
+
toHeading(schema.nodes['heading'], level),
|
|
11644
12078
|
])),
|
|
11645
12079
|
inputRules: ({ schema }) => [
|
|
11646
|
-
// `## ` at the start of a block becomes an h2,
|
|
11647
|
-
textblockTypeInputRule(/^(#{1,
|
|
12080
|
+
// `## ` at the start of a block becomes an h2, up to `#### `.
|
|
12081
|
+
textblockTypeInputRule(/^(#{1,4})\s$/, schema.nodes['heading'], (match) => ({
|
|
11648
12082
|
level: match[1].length,
|
|
11649
12083
|
})),
|
|
11650
12084
|
],
|
|
11651
|
-
actions: ({ schema }) => [
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
12085
|
+
actions: ({ schema }) => [
|
|
12086
|
+
...[1, 2, 3].map((level) => ({
|
|
12087
|
+
id: `heading-${level}`,
|
|
12088
|
+
section: 'blocks',
|
|
12089
|
+
title: `Heading ${level}`,
|
|
12090
|
+
keywords: [`h${level}`, 'title', 'heading'],
|
|
12091
|
+
icon: `format_h${level}`,
|
|
12092
|
+
command: toHeading(schema.nodes['heading'], level),
|
|
12093
|
+
})),
|
|
12094
|
+
{
|
|
12095
|
+
id: 'heading-layout',
|
|
12096
|
+
section: 'blocks',
|
|
12097
|
+
title: 'Heading (layout)',
|
|
12098
|
+
keywords: ['title', 'heading', 'layout', 'flush', 'banner'],
|
|
12099
|
+
icon: 'title',
|
|
12100
|
+
command: toLayoutHeading(schema.nodes['heading']),
|
|
12101
|
+
},
|
|
12102
|
+
],
|
|
11659
12103
|
});
|
|
11660
12104
|
|
|
11661
12105
|
/** The ledger's answer to reply chains whose margins stack until text is one
|
|
@@ -12259,7 +12703,7 @@ function pickAndInsertImages(view, schema) {
|
|
|
12259
12703
|
}
|
|
12260
12704
|
/** Floor for a resized image: below this a pad has nothing left to grab. */
|
|
12261
12705
|
const MIN_IMAGE_WIDTH = 40;
|
|
12262
|
-
const clamp$
|
|
12706
|
+
const clamp$3 = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
12263
12707
|
/** Room an image leaves at the end of its line for the caret (decided
|
|
12264
12708
|
2026-09-24): the widest an image gets in a line is the line less this,
|
|
12265
12709
|
so the caret can always stand to its right — on a drop, and at the top
|
|
@@ -12307,6 +12751,9 @@ function filledPlaceholderAttrs(placeholder, picked) {
|
|
|
12307
12751
|
width: placeholder.width ?? picked.width ?? null,
|
|
12308
12752
|
};
|
|
12309
12753
|
}
|
|
12754
|
+
/** The words a decoration hands the frame — the placeholder's `image`, in
|
|
12755
|
+
the host's language — or the English default. */
|
|
12756
|
+
const frameWords = (decorations) => decorations.find((decoration) => typeof decoration.spec['placeholder'] === 'string')?.spec['placeholder'] ?? 'Choose an image';
|
|
12310
12757
|
/** A frame standing in for the image: the placeholder awaiting its file, or
|
|
12311
12758
|
a `cid:` whose part the registry does not hold. The app owns the pixels. */
|
|
12312
12759
|
function imageFrame(className, label) {
|
|
@@ -12346,12 +12793,14 @@ function imageFrame(className, label) {
|
|
|
12346
12793
|
* pads: size the frame first, then click it to pick the file that fills it
|
|
12347
12794
|
* ({@link filledPlaceholderAttrs} keeps the frame's width). The slide-deck
|
|
12348
12795
|
* model — Keynote's media placeholder — and the image counterpart of the
|
|
12349
|
-
* merge tag.
|
|
12796
|
+
* merge tag. Its words are the placeholder's `image` (a node decoration
|
|
12797
|
+
* whose spec carries `placeholder`), "Choose an image" without one.
|
|
12350
12798
|
*/
|
|
12351
12799
|
class ImageView {
|
|
12352
12800
|
node;
|
|
12353
12801
|
view;
|
|
12354
12802
|
getPos;
|
|
12803
|
+
decorations;
|
|
12355
12804
|
dom;
|
|
12356
12805
|
img;
|
|
12357
12806
|
frame;
|
|
@@ -12359,10 +12808,11 @@ class ImageView {
|
|
|
12359
12808
|
pad released over the wrapper, and that click must not open the
|
|
12360
12809
|
picker. */
|
|
12361
12810
|
resized = false;
|
|
12362
|
-
constructor(node, view, getPos) {
|
|
12811
|
+
constructor(node, view, getPos, decorations = []) {
|
|
12363
12812
|
this.node = node;
|
|
12364
12813
|
this.view = view;
|
|
12365
12814
|
this.getPos = getPos;
|
|
12815
|
+
this.decorations = decorations;
|
|
12366
12816
|
this.dom = document.createElement('span');
|
|
12367
12817
|
this.dom.className = 'aee-image';
|
|
12368
12818
|
this.apply(node);
|
|
@@ -12382,7 +12832,7 @@ class ImageView {
|
|
|
12382
12832
|
render(node) {
|
|
12383
12833
|
const src = node.attrs['src'];
|
|
12384
12834
|
if (!src)
|
|
12385
|
-
return imageFrame('aee-image__placeholder',
|
|
12835
|
+
return imageFrame('aee-image__placeholder', frameWords(this.decorations));
|
|
12386
12836
|
const img = DOMSerializer.renderSpec(document, node.type.spec.toDOM(node)).dom;
|
|
12387
12837
|
const cid = /^\s*cid:(.+)$/i.exec(src)?.[1]?.trim();
|
|
12388
12838
|
if (cid) {
|
|
@@ -12393,10 +12843,11 @@ class ImageView {
|
|
|
12393
12843
|
}
|
|
12394
12844
|
return img;
|
|
12395
12845
|
}
|
|
12396
|
-
update(node) {
|
|
12846
|
+
update(node, decorations) {
|
|
12397
12847
|
if (node.type !== this.node.type)
|
|
12398
12848
|
return false;
|
|
12399
12849
|
this.node = node;
|
|
12850
|
+
this.decorations = decorations;
|
|
12400
12851
|
const img = this.render(node);
|
|
12401
12852
|
this.img.replaceWith(img);
|
|
12402
12853
|
this.img = img;
|
|
@@ -12477,7 +12928,7 @@ class ImageView {
|
|
|
12477
12928
|
this.resized = true;
|
|
12478
12929
|
const preview = (ev) => {
|
|
12479
12930
|
const delta = ev.clientX - startX;
|
|
12480
|
-
width = clamp$
|
|
12931
|
+
width = clamp$3(Math.round(rect.width + (side === 'right' ? delta : -delta)), MIN_IMAGE_WIDTH, max);
|
|
12481
12932
|
this.frame.style.width = `${width}px`;
|
|
12482
12933
|
this.frame.style.height = `${Math.round(width * ratio)}px`;
|
|
12483
12934
|
};
|
|
@@ -12734,7 +13185,11 @@ const Image = defineNode({
|
|
|
12734
13185
|
}),
|
|
12735
13186
|
new Plugin({
|
|
12736
13187
|
key: new PluginKey('imageView'),
|
|
12737
|
-
props: {
|
|
13188
|
+
props: {
|
|
13189
|
+
nodeViews: {
|
|
13190
|
+
image: (node, view, getPos, decorations) => new ImageView(node, view, getPos, decorations),
|
|
13191
|
+
},
|
|
13192
|
+
},
|
|
12738
13193
|
}),
|
|
12739
13194
|
],
|
|
12740
13195
|
});
|
|
@@ -12785,6 +13240,350 @@ const Divider = defineNode({
|
|
|
12785
13240
|
],
|
|
12786
13241
|
});
|
|
12787
13242
|
|
|
13243
|
+
/** The class every block of a {@link BlockSelection} wears — the wash the
|
|
13244
|
+
app paints over it, since the selection itself draws nothing. */
|
|
13245
|
+
const BLOCK_SELECTED_CLASS = 'aee-block--selected';
|
|
13246
|
+
/** The class the blocks of a {@link BlockSelection} just let go of wear, for
|
|
13247
|
+
as long as an animation the app gives it runs — the wash fading out
|
|
13248
|
+
instead of vanishing. Gone once the animation ends, at once with none. */
|
|
13249
|
+
const BLOCK_RELEASING_CLASS = 'aee-block--releasing';
|
|
13250
|
+
/** One node decoration per block of `selection`, wearing `className`. */
|
|
13251
|
+
function blockDecorations(doc, selection, className) {
|
|
13252
|
+
return DecorationSet.create(doc, selection.blocks.map(({ node, pos }) => Decoration.node(pos, pos + node.nodeSize, { class: className })));
|
|
13253
|
+
}
|
|
13254
|
+
/** A selection of `anchor`..`head` when both are seams between top-level
|
|
13255
|
+
blocks and it holds at least one, else the nearest valid selection at
|
|
13256
|
+
`head` — what a mapped or restored block selection falls back to once
|
|
13257
|
+
its blocks are gone. */
|
|
13258
|
+
function blocksOr(doc, anchor, head) {
|
|
13259
|
+
const $anchor = doc.resolve(anchor);
|
|
13260
|
+
const $head = doc.resolve(head);
|
|
13261
|
+
if (anchor !== head && $anchor.depth === 0 && $head.depth === 0) {
|
|
13262
|
+
return new BlockSelection($anchor, $head);
|
|
13263
|
+
}
|
|
13264
|
+
return Selection.near($head);
|
|
13265
|
+
}
|
|
13266
|
+
/** Where top-level block `index` starts. */
|
|
13267
|
+
function blockStart(doc, index) {
|
|
13268
|
+
let pos = 0;
|
|
13269
|
+
for (let i = 0; i < index; i++)
|
|
13270
|
+
pos += doc.child(i).nodeSize;
|
|
13271
|
+
return pos;
|
|
13272
|
+
}
|
|
13273
|
+
class BlockBookmark {
|
|
13274
|
+
anchor;
|
|
13275
|
+
head;
|
|
13276
|
+
constructor(anchor, head) {
|
|
13277
|
+
this.anchor = anchor;
|
|
13278
|
+
this.head = head;
|
|
13279
|
+
}
|
|
13280
|
+
map(mapping) {
|
|
13281
|
+
const forward = this.head > this.anchor;
|
|
13282
|
+
return new BlockBookmark(mapping.map(this.anchor, forward ? 1 : -1), mapping.map(this.head, forward ? -1 : 1));
|
|
13283
|
+
}
|
|
13284
|
+
resolve(doc) {
|
|
13285
|
+
return blocksOr(doc, this.anchor, this.head);
|
|
13286
|
+
}
|
|
13287
|
+
}
|
|
13288
|
+
/**
|
|
13289
|
+
* Whole top-level blocks selected as blocks — Notion's block selection: the
|
|
13290
|
+
* marquee's ({@link createBlockMarquee}) and the second `Mod-a`'s.
|
|
13291
|
+
*
|
|
13292
|
+
* Its ends are seams between top-level blocks (depth 0), so it always holds
|
|
13293
|
+
* whole blocks, one or more, in a run: an email is one column of them, and
|
|
13294
|
+
* a box dragged down it covers a run. A columns block or a table is one
|
|
13295
|
+
* block — selected whole, never cell by cell (that is `CellSelection`).
|
|
13296
|
+
*
|
|
13297
|
+
* It copies, cuts, deletes and is pasted over as the blocks (ProseMirror's
|
|
13298
|
+
* own handling, through its range), and draws nothing itself: `visible` is
|
|
13299
|
+
* false, so the browser's highlight is hidden, and every block in it wears
|
|
13300
|
+
* {@link BLOCK_SELECTED_CLASS} for the app to paint (see {@link SelectedBlocks}).
|
|
13301
|
+
*/
|
|
13302
|
+
class BlockSelection extends Selection {
|
|
13303
|
+
/** The blocks from `anchorBlock` to `headBlock`, both the positions where
|
|
13304
|
+
a top-level block starts; the head is the end Shift-arrows move. */
|
|
13305
|
+
static create(doc, anchorBlock, headBlock = anchorBlock) {
|
|
13306
|
+
const $anchor = doc.resolve(anchorBlock);
|
|
13307
|
+
const $head = doc.resolve(headBlock);
|
|
13308
|
+
const anchorNode = $anchor.nodeAfter;
|
|
13309
|
+
const headNode = $head.nodeAfter;
|
|
13310
|
+
if ($anchor.depth !== 0 || $head.depth !== 0 || !anchorNode || !headNode) {
|
|
13311
|
+
throw new RangeError('BlockSelection.create takes the starts of top-level blocks');
|
|
13312
|
+
}
|
|
13313
|
+
return headBlock >= anchorBlock
|
|
13314
|
+
? new BlockSelection($anchor, doc.resolve(headBlock + headNode.nodeSize))
|
|
13315
|
+
: new BlockSelection(doc.resolve(anchorBlock + anchorNode.nodeSize), $head);
|
|
13316
|
+
}
|
|
13317
|
+
/** Every block of the document. */
|
|
13318
|
+
static all(doc) {
|
|
13319
|
+
return new BlockSelection(doc.resolve(0), doc.resolve(doc.content.size));
|
|
13320
|
+
}
|
|
13321
|
+
constructor($anchor, $head) {
|
|
13322
|
+
super($anchor, $head);
|
|
13323
|
+
}
|
|
13324
|
+
/** The selected blocks, top to bottom. */
|
|
13325
|
+
get blocks() {
|
|
13326
|
+
const blocks = [];
|
|
13327
|
+
this.$from.doc.nodesBetween(this.from, this.to, (node, pos) => {
|
|
13328
|
+
blocks.push({ node, pos });
|
|
13329
|
+
return false;
|
|
13330
|
+
});
|
|
13331
|
+
return blocks;
|
|
13332
|
+
}
|
|
13333
|
+
/** The first block's index and the last's among the document's blocks. */
|
|
13334
|
+
get indices() {
|
|
13335
|
+
return { first: this.$from.index(0), last: this.$to.index(0) - 1 };
|
|
13336
|
+
}
|
|
13337
|
+
/** The block at the head's end: the one Shift-arrows grow or shrink from. */
|
|
13338
|
+
get headIndex() {
|
|
13339
|
+
const { first, last } = this.indices;
|
|
13340
|
+
return this.head > this.anchor ? last : first;
|
|
13341
|
+
}
|
|
13342
|
+
/** The block at the anchor's end, which stays while Shift-arrows move. */
|
|
13343
|
+
get anchorIndex() {
|
|
13344
|
+
const { first, last } = this.indices;
|
|
13345
|
+
return this.head > this.anchor ? first : last;
|
|
13346
|
+
}
|
|
13347
|
+
// Each end keeps to its blocks: a block inserted at a seam of the
|
|
13348
|
+
// selection lands outside it.
|
|
13349
|
+
map(doc, mapping) {
|
|
13350
|
+
return new BlockBookmark(this.anchor, this.head).map(mapping).resolve(doc);
|
|
13351
|
+
}
|
|
13352
|
+
eq(other) {
|
|
13353
|
+
return (other instanceof BlockSelection && other.anchor === this.anchor && other.head === this.head);
|
|
13354
|
+
}
|
|
13355
|
+
toJSON() {
|
|
13356
|
+
return { type: 'aeeBlocks', anchor: this.anchor, head: this.head };
|
|
13357
|
+
}
|
|
13358
|
+
static fromJSON(doc, json) {
|
|
13359
|
+
if (typeof json.anchor !== 'number' || typeof json.head !== 'number') {
|
|
13360
|
+
throw new RangeError('Invalid input for BlockSelection.fromJSON');
|
|
13361
|
+
}
|
|
13362
|
+
const selection = blocksOr(doc, json.anchor, json.head);
|
|
13363
|
+
if (!(selection instanceof BlockSelection)) {
|
|
13364
|
+
throw new RangeError('BlockSelection.fromJSON: not a run of top-level blocks');
|
|
13365
|
+
}
|
|
13366
|
+
return selection;
|
|
13367
|
+
}
|
|
13368
|
+
getBookmark() {
|
|
13369
|
+
return new BlockBookmark(this.anchor, this.head);
|
|
13370
|
+
}
|
|
13371
|
+
}
|
|
13372
|
+
// Drawn by the app, never by the browser (`ProseMirror-hideselection`).
|
|
13373
|
+
BlockSelection.prototype.visible = false;
|
|
13374
|
+
Selection.jsonID('aeeBlocks', BlockSelection);
|
|
13375
|
+
/** The one block above or below the head (ArrowUp / ArrowDown), as Notion
|
|
13376
|
+
walks a block selection; at the document's edge it stays. */
|
|
13377
|
+
const stepBlocks = (dir) => (state, dispatch) => {
|
|
13378
|
+
const { selection, doc } = state;
|
|
13379
|
+
if (!(selection instanceof BlockSelection))
|
|
13380
|
+
return false;
|
|
13381
|
+
const index = Math.min(Math.max(selection.headIndex + dir, 0), doc.childCount - 1);
|
|
13382
|
+
const next = BlockSelection.create(doc, blockStart(doc, index));
|
|
13383
|
+
if (!next.eq(selection))
|
|
13384
|
+
dispatch?.(state.tr.setSelection(next).scrollIntoView());
|
|
13385
|
+
return true;
|
|
13386
|
+
};
|
|
13387
|
+
/** The head moved by one block, the anchor kept (Shift-ArrowUp / -Down):
|
|
13388
|
+
grows the run, or shrinks it back toward the anchor. */
|
|
13389
|
+
const extendBlocks = (dir) => (state, dispatch) => {
|
|
13390
|
+
const { selection, doc } = state;
|
|
13391
|
+
if (!(selection instanceof BlockSelection))
|
|
13392
|
+
return false;
|
|
13393
|
+
const head = Math.min(Math.max(selection.headIndex + dir, 0), doc.childCount - 1);
|
|
13394
|
+
const next = BlockSelection.create(doc, blockStart(doc, selection.anchorIndex), blockStart(doc, head));
|
|
13395
|
+
if (!next.eq(selection))
|
|
13396
|
+
dispatch?.(state.tr.setSelection(next).scrollIntoView());
|
|
13397
|
+
return true;
|
|
13398
|
+
};
|
|
13399
|
+
/** The caret a block selection leaves to: at the start of the first block
|
|
13400
|
+
(`'start'`), the end of the last (`'end'`), or the head's end of its block
|
|
13401
|
+
(`'head'` — the block you were last on). */
|
|
13402
|
+
function caretOf(selection, to) {
|
|
13403
|
+
const doc = selection.$from.doc;
|
|
13404
|
+
const backward = selection.head < selection.anchor;
|
|
13405
|
+
const atStart = to === 'start' || (to === 'head' && backward);
|
|
13406
|
+
return atStart
|
|
13407
|
+
? Selection.near(doc.resolve(selection.from), 1)
|
|
13408
|
+
: Selection.near(doc.resolve(selection.to), -1);
|
|
13409
|
+
}
|
|
13410
|
+
/**
|
|
13411
|
+
* Lets a block selection go — the caret at the head's end of its block, as
|
|
13412
|
+
* Escape leaves it, but not scrolled to: the press that let go is elsewhere.
|
|
13413
|
+
* Whether the view holds one or not, nothing else changes; `true` if it did.
|
|
13414
|
+
*/
|
|
13415
|
+
function releaseBlockSelection(view) {
|
|
13416
|
+
const { selection } = view.state;
|
|
13417
|
+
if (!(selection instanceof BlockSelection))
|
|
13418
|
+
return false;
|
|
13419
|
+
view.dispatch(view.state.tr.setSelection(caretOf(selection, 'head')));
|
|
13420
|
+
return true;
|
|
13421
|
+
}
|
|
13422
|
+
/** Back to text (ArrowLeft / ArrowRight, Enter and Escape), scrolled to. */
|
|
13423
|
+
const leaveBlocks = (to) => (state, dispatch) => {
|
|
13424
|
+
const { selection } = state;
|
|
13425
|
+
if (!(selection instanceof BlockSelection))
|
|
13426
|
+
return false;
|
|
13427
|
+
dispatch?.(state.tr.setSelection(caretOf(selection, to)).scrollIntoView());
|
|
13428
|
+
return true;
|
|
13429
|
+
};
|
|
13430
|
+
/** The blocks removed — Backspace and Delete alike. */
|
|
13431
|
+
const deleteBlocks = (state, dispatch) => {
|
|
13432
|
+
if (!(state.selection instanceof BlockSelection))
|
|
13433
|
+
return false;
|
|
13434
|
+
dispatch?.(state.tr.deleteSelection().scrollIntoView());
|
|
13435
|
+
return true;
|
|
13436
|
+
};
|
|
13437
|
+
/** `Mod-a` again: the whole document, already selected as text, selected as
|
|
13438
|
+
blocks. The first `Mod-a` stays the text's own select-all. */
|
|
13439
|
+
const selectAllBlocks = (state, dispatch) => {
|
|
13440
|
+
if (!(state.selection instanceof AllSelection))
|
|
13441
|
+
return false;
|
|
13442
|
+
dispatch?.(state.tr.setSelection(BlockSelection.all(state.doc)));
|
|
13443
|
+
return true;
|
|
13444
|
+
};
|
|
13445
|
+
/** Input that would type into a block selection: typing does nothing to
|
|
13446
|
+
selected blocks (Notion's rule) — a paste or a drop replaces them. */
|
|
13447
|
+
const TYPING = /^insert(?!FromPaste|FromDrop)/;
|
|
13448
|
+
/**
|
|
13449
|
+
* What a {@link BlockSelection} does once there is one: every block in it
|
|
13450
|
+
* lit ({@link BLOCK_SELECTED_CLASS}), and Notion's keys — ArrowUp / ArrowDown
|
|
13451
|
+
* walk to the block above or below, Shift-ArrowUp / -Down grow or shrink the
|
|
13452
|
+
* run, ArrowLeft / ArrowRight leave to the caret at its start or end, Enter
|
|
13453
|
+
* and Escape to the caret in the head's block, Backspace / Delete remove the
|
|
13454
|
+
* blocks; a second `Mod-a` selects every block. Typing does nothing; a
|
|
13455
|
+
* paste replaces the blocks.
|
|
13456
|
+
*
|
|
13457
|
+
* The next press anywhere lets the blocks go, as Notion's do — in the editor
|
|
13458
|
+
* the press is ProseMirror's (or the marquee's) and replaces the selection
|
|
13459
|
+
* itself; outside it, a press that takes focus from the editor (another
|
|
13460
|
+
* field, a title bar, the page) leaves the caret at the head's block
|
|
13461
|
+
* ({@link releaseBlockSelection}). A control that keeps the editor focused —
|
|
13462
|
+
* a formatting toolbar acting on the selection — keeps the blocks. However
|
|
13463
|
+
* they go, the blocks let go wear {@link BLOCK_RELEASING_CLASS} while the
|
|
13464
|
+
* app's fade runs, so the wash eases out rather than vanishing.
|
|
13465
|
+
*
|
|
13466
|
+
* In the kits: the selection is reachable by keyboard alone. The pointer's
|
|
13467
|
+
* way in, the marquee, is the host's to opt into ({@link createBlockMarquee}),
|
|
13468
|
+
* since it needs a surface around the editor to start from.
|
|
13469
|
+
*/
|
|
13470
|
+
const SelectedBlocks = defineExtension({
|
|
13471
|
+
name: 'selectedBlocks',
|
|
13472
|
+
keymap: () => ({
|
|
13473
|
+
ArrowUp: stepBlocks(-1),
|
|
13474
|
+
ArrowDown: stepBlocks(1),
|
|
13475
|
+
'Shift-ArrowUp': extendBlocks(-1),
|
|
13476
|
+
'Shift-ArrowDown': extendBlocks(1),
|
|
13477
|
+
ArrowLeft: leaveBlocks('start'),
|
|
13478
|
+
ArrowRight: leaveBlocks('end'),
|
|
13479
|
+
Enter: leaveBlocks('head'),
|
|
13480
|
+
Escape: leaveBlocks('head'),
|
|
13481
|
+
Backspace: deleteBlocks,
|
|
13482
|
+
Delete: deleteBlocks,
|
|
13483
|
+
'Mod-a': selectAllBlocks,
|
|
13484
|
+
}),
|
|
13485
|
+
plugins: () => {
|
|
13486
|
+
const key = new PluginKey('selectedBlocks');
|
|
13487
|
+
let released = 0;
|
|
13488
|
+
return [
|
|
13489
|
+
new Plugin({
|
|
13490
|
+
key,
|
|
13491
|
+
// The blocks just let go, while their wash fades: the selection that
|
|
13492
|
+
// held them is gone, so they are kept here — mapped through every
|
|
13493
|
+
// change, dropped with any block deleted — until the fade is over.
|
|
13494
|
+
state: {
|
|
13495
|
+
init: () => null,
|
|
13496
|
+
apply(tr, releasing, before, after) {
|
|
13497
|
+
const done = tr.getMeta(key);
|
|
13498
|
+
if (after.selection instanceof BlockSelection)
|
|
13499
|
+
return null;
|
|
13500
|
+
if (before.selection instanceof BlockSelection) {
|
|
13501
|
+
const set = blockDecorations(before.doc, before.selection, BLOCK_RELEASING_CLASS);
|
|
13502
|
+
return { id: ++released, set: set.map(tr.mapping, tr.doc) };
|
|
13503
|
+
}
|
|
13504
|
+
if (!releasing || done === releasing.id)
|
|
13505
|
+
return null;
|
|
13506
|
+
return { id: releasing.id, set: releasing.set.map(tr.mapping, tr.doc) };
|
|
13507
|
+
},
|
|
13508
|
+
},
|
|
13509
|
+
view: (view) => {
|
|
13510
|
+
const doc = view.dom.ownerDocument;
|
|
13511
|
+
// A press outside the editor, while blocks are selected: once it
|
|
13512
|
+
// has done what it does (moved focus, or kept it), the blocks go
|
|
13513
|
+
// unless the editor still has focus.
|
|
13514
|
+
const press = (event) => {
|
|
13515
|
+
if (!(view.state.selection instanceof BlockSelection))
|
|
13516
|
+
return;
|
|
13517
|
+
if (view.dom.contains(event.target))
|
|
13518
|
+
return;
|
|
13519
|
+
setTimeout(() => {
|
|
13520
|
+
if (!view.isDestroyed && !view.hasFocus())
|
|
13521
|
+
releaseBlockSelection(view);
|
|
13522
|
+
});
|
|
13523
|
+
};
|
|
13524
|
+
doc.addEventListener('mousedown', press, true);
|
|
13525
|
+
// A release begins: the releasing class goes as soon as the
|
|
13526
|
+
// animations the host's stylesheet gave it end — at once, with
|
|
13527
|
+
// none (no fade, reduced motion, a browser without the API).
|
|
13528
|
+
let watched = 0;
|
|
13529
|
+
const fadeOut = (id) => {
|
|
13530
|
+
const animations = (doc.getAnimations?.() ?? []).filter((animation) => {
|
|
13531
|
+
const target = animation.effect?.target;
|
|
13532
|
+
return !!target && view.dom.contains(target) && target.classList.contains(BLOCK_RELEASING_CLASS);
|
|
13533
|
+
});
|
|
13534
|
+
void Promise.all(animations.map((animation) => animation.finished.catch(() => { }))).then(() => {
|
|
13535
|
+
if (!view.isDestroyed && key.getState(view.state)?.id === id) {
|
|
13536
|
+
view.dispatch(view.state.tr.setMeta(key, id).setMeta('addToHistory', false));
|
|
13537
|
+
}
|
|
13538
|
+
});
|
|
13539
|
+
};
|
|
13540
|
+
return {
|
|
13541
|
+
update: () => {
|
|
13542
|
+
const releasing = key.getState(view.state);
|
|
13543
|
+
if (!releasing || releasing.id === watched)
|
|
13544
|
+
return;
|
|
13545
|
+
watched = releasing.id;
|
|
13546
|
+
fadeOut(releasing.id);
|
|
13547
|
+
},
|
|
13548
|
+
destroy: () => doc.removeEventListener('mousedown', press, true),
|
|
13549
|
+
};
|
|
13550
|
+
},
|
|
13551
|
+
props: {
|
|
13552
|
+
decorations(state) {
|
|
13553
|
+
const { selection } = state;
|
|
13554
|
+
if (selection instanceof BlockSelection) {
|
|
13555
|
+
return blockDecorations(state.doc, selection, BLOCK_SELECTED_CLASS);
|
|
13556
|
+
}
|
|
13557
|
+
return key.getState(state)?.set ?? null;
|
|
13558
|
+
},
|
|
13559
|
+
handleTextInput: (view) => view.state.selection instanceof BlockSelection,
|
|
13560
|
+
handleDOMEvents: {
|
|
13561
|
+
beforeinput(view, event) {
|
|
13562
|
+
if (!(view.state.selection instanceof BlockSelection))
|
|
13563
|
+
return false;
|
|
13564
|
+
if (!TYPING.test(event.inputType))
|
|
13565
|
+
return false;
|
|
13566
|
+
event.preventDefault();
|
|
13567
|
+
return true;
|
|
13568
|
+
},
|
|
13569
|
+
},
|
|
13570
|
+
},
|
|
13571
|
+
}),
|
|
13572
|
+
];
|
|
13573
|
+
},
|
|
13574
|
+
});
|
|
13575
|
+
|
|
13576
|
+
/**
|
|
13577
|
+
* Presses a gesture has taken for itself — the block marquee's — so the
|
|
13578
|
+
* editor's other press listeners (the inline atoms' drag) leave them alone.
|
|
13579
|
+
* Internal: not part of the library's API.
|
|
13580
|
+
*/
|
|
13581
|
+
const claimed = new WeakSet();
|
|
13582
|
+
const claimPress = (event) => {
|
|
13583
|
+
claimed.add(event);
|
|
13584
|
+
};
|
|
13585
|
+
const isClaimedPress = (event) => claimed.has(event);
|
|
13586
|
+
|
|
12788
13587
|
/** The class a text range puts on every inline atom it covers — an image,
|
|
12789
13588
|
a button: the highlight the app draws, since the browser draws none. */
|
|
12790
13589
|
const IN_SELECTION_CLASS = 'aee-atom--in-selection';
|
|
@@ -12976,6 +13775,9 @@ const InlineAtomSelection = defineExtension({
|
|
|
12976
13775
|
const down = (event) => {
|
|
12977
13776
|
if (!view.editable || event.button !== 0 || event.shiftKey || event.detail > 1)
|
|
12978
13777
|
return;
|
|
13778
|
+
// The block marquee's press: a box, not a range.
|
|
13779
|
+
if (isClaimedPress(event))
|
|
13780
|
+
return;
|
|
12979
13781
|
const atom = atomAt(view, event.target);
|
|
12980
13782
|
const { selection } = view.state;
|
|
12981
13783
|
// The selected atom's press is ProseMirror's: its drag moves it.
|
|
@@ -13064,8 +13866,12 @@ const InlineAtomSelection = defineExtension({
|
|
|
13064
13866
|
},
|
|
13065
13867
|
decorations(state) {
|
|
13066
13868
|
const { selection } = state;
|
|
13067
|
-
|
|
13869
|
+
// Selected blocks are lit whole (`SelectedBlocks`), atoms and all.
|
|
13870
|
+
if (selection.empty ||
|
|
13871
|
+
selection instanceof NodeSelection ||
|
|
13872
|
+
selection instanceof BlockSelection) {
|
|
13068
13873
|
return null;
|
|
13874
|
+
}
|
|
13069
13875
|
const decorations = [];
|
|
13070
13876
|
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
|
|
13071
13877
|
if (isInlineAtom(node)) {
|
|
@@ -13526,7 +14332,7 @@ const ColumnResize = defineExtension({
|
|
|
13526
14332
|
}),
|
|
13527
14333
|
],
|
|
13528
14334
|
});
|
|
13529
|
-
const clamp$
|
|
14335
|
+
const clamp$2 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
13530
14336
|
const round1 = (n) => Math.round(n * 10) / 10;
|
|
13531
14337
|
/** The declared width of each grid column (`null` = unspecified, shares the
|
|
13532
14338
|
leftover) — read from the first cell in the column that declares one. */
|
|
@@ -13587,7 +14393,7 @@ function setColumnBoundary(tablePos, boundary, leftPct) {
|
|
|
13587
14393
|
const pair = effective[boundary] + effective[boundary + 1];
|
|
13588
14394
|
if (pair < MIN_COLUMN_PCT * 2)
|
|
13589
14395
|
return false;
|
|
13590
|
-
const left = round1(clamp$
|
|
14396
|
+
const left = round1(clamp$2(leftPct, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT));
|
|
13591
14397
|
const right = round1(pair - left);
|
|
13592
14398
|
if (dispatch) {
|
|
13593
14399
|
const tr = state.tr;
|
|
@@ -13640,15 +14446,15 @@ function setTableBox(tablePos, offsetPct, widthPct, absorb) {
|
|
|
13640
14446
|
return false;
|
|
13641
14447
|
const map = TableMap.get(table);
|
|
13642
14448
|
const oldWidth = table.attrs['width'];
|
|
13643
|
-
const offset = round1(clamp$
|
|
13644
|
-
let width = round1(clamp$
|
|
14449
|
+
const offset = round1(clamp$2(offsetPct, 0, 100 - MIN_TABLE_PCT));
|
|
14450
|
+
let width = round1(clamp$2(widthPct, MIN_TABLE_PCT, 100 - offset));
|
|
13645
14451
|
if (dispatch) {
|
|
13646
14452
|
const tr = state.tr;
|
|
13647
14453
|
if (absorb && map.width > 1) {
|
|
13648
14454
|
const effective = effectiveWidths(table);
|
|
13649
14455
|
const absorbIndex = absorb === 'first' ? 0 : map.width - 1;
|
|
13650
14456
|
const others = 100 - effective[absorbIndex];
|
|
13651
|
-
width = round1(clamp$
|
|
14457
|
+
width = round1(clamp$2(width, minTableWidth(oldWidth, others), 100 - offset));
|
|
13652
14458
|
const factor = oldWidth / width;
|
|
13653
14459
|
const pcts = effective.map((pct, index) => index === absorbIndex ? 0 : round1(pct * factor));
|
|
13654
14460
|
pcts[absorbIndex] = round1(100 - pcts.reduce((sum, pct) => sum + pct, 0));
|
|
@@ -14023,7 +14829,7 @@ class TableView {
|
|
|
14023
14829
|
return;
|
|
14024
14830
|
const startX = event.clientX;
|
|
14025
14831
|
const startLeft = effective.slice(0, boundary + 1).reduce((sum, width) => sum + width, 0);
|
|
14026
|
-
const leftAt = (ev) => clamp$
|
|
14832
|
+
const leftAt = (ev) => clamp$2(effective[boundary] + ((ev.clientX - startX) / tableWidth) * 100, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT);
|
|
14027
14833
|
// Deferred commit: mid-drag ONLY the guide line moves, at full float
|
|
14028
14834
|
// precision. The table itself must not reflow per pointermove — its text
|
|
14029
14835
|
// rewrapping every frame reads as the grid flying apart (tried, reverted).
|
|
@@ -14079,8 +14885,8 @@ class TableView {
|
|
|
14079
14885
|
const minWidth = minTableWidth(oldWidth, others);
|
|
14080
14886
|
const pctAt = (ev) => ((ev.clientX - wrapperLeft) / wrapperWidth) * 100;
|
|
14081
14887
|
const edgeAt = (ev) => side === 'left'
|
|
14082
|
-
? clamp$
|
|
14083
|
-
: clamp$
|
|
14888
|
+
? clamp$2(pctAt(ev), 0, right - minWidth)
|
|
14889
|
+
: clamp$2(pctAt(ev), offset + minWidth, 100);
|
|
14084
14890
|
const preview = (ev) => {
|
|
14085
14891
|
const edge = edgeAt(ev);
|
|
14086
14892
|
handle.style.left = `${edge}%`;
|
|
@@ -14230,7 +15036,7 @@ const ColumnsResize = defineExtension({
|
|
|
14230
15036
|
}),
|
|
14231
15037
|
],
|
|
14232
15038
|
});
|
|
14233
|
-
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
15039
|
+
const clamp$1 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
14234
15040
|
/**
|
|
14235
15041
|
* `div.aee-columns-wrap (editorial gutter) > div.aee-columns-box (the email's
|
|
14236
15042
|
* container style) > div.aee-columns (contentDOM) + div.aee-col-lines`.
|
|
@@ -14353,7 +15159,7 @@ class ColumnsView {
|
|
|
14353
15159
|
const startX = event.clientX;
|
|
14354
15160
|
const startLeft = caps.slice(0, boundary + 1).reduce((sum, cap) => sum + cap, 0);
|
|
14355
15161
|
// px in the editor are px in the email: the box is the 600px container.
|
|
14356
|
-
const leftAt = (ev) => clamp(caps[boundary] + (ev.clientX - startX), MIN_COLUMN_CAP, pair - MIN_COLUMN_CAP);
|
|
15162
|
+
const leftAt = (ev) => clamp$1(caps[boundary] + (ev.clientX - startX), MIN_COLUMN_CAP, pair - MIN_COLUMN_CAP);
|
|
14357
15163
|
const preview = (ev) => {
|
|
14358
15164
|
line.style.left = `${startLeft - caps[boundary] + leftAt(ev)}px`;
|
|
14359
15165
|
};
|
|
@@ -15082,6 +15888,10 @@ const withParagraph = (paragraph) => [
|
|
|
15082
15888
|
Button,
|
|
15083
15889
|
// Images and buttons selected like characters: a drag covers them.
|
|
15084
15890
|
InlineAtomSelection,
|
|
15891
|
+
// Whole blocks selected as blocks (Notion's): lit, and walked by its
|
|
15892
|
+
// keys. Before the tables and gap-cursor, whose arrows it outranks while
|
|
15893
|
+
// there is one.
|
|
15894
|
+
SelectedBlocks,
|
|
15085
15895
|
Table,
|
|
15086
15896
|
TableRow,
|
|
15087
15897
|
TableCell,
|
|
@@ -15736,6 +16546,372 @@ function createAutocompletePlugin(options) {
|
|
|
15736
16546
|
});
|
|
15737
16547
|
}
|
|
15738
16548
|
|
|
16549
|
+
/** The class of the box a drag draws — the host paints it, in the same wash
|
|
16550
|
+
as the blocks it lights ({@link BLOCK_SELECTED_CLASS}). */
|
|
16551
|
+
const BLOCK_MARQUEE_CLASS = 'aee-marquee';
|
|
16552
|
+
/** What a press on the surface may not start: its own controls. */
|
|
16553
|
+
const INTERACTIVE = 'button, a[href], input, textarea, select, label, summary, [role="button"], [contenteditable]';
|
|
16554
|
+
/** How close to the scroller's edge, in px, a held drag scrolls it — and
|
|
16555
|
+
the fastest it goes, in px per frame, at (or past) the edge. */
|
|
16556
|
+
const EDGE = 40;
|
|
16557
|
+
const MAX_SPEED = 20;
|
|
16558
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
16559
|
+
/** The client box of what an element shows of its content: inside its
|
|
16560
|
+
borders, without its scrollbars. */
|
|
16561
|
+
function viewport(element) {
|
|
16562
|
+
const rect = element.getBoundingClientRect();
|
|
16563
|
+
const left = rect.left + element.clientLeft;
|
|
16564
|
+
const top = rect.top + element.clientTop;
|
|
16565
|
+
return { left, top, right: left + element.clientWidth, bottom: top + element.clientHeight };
|
|
16566
|
+
}
|
|
16567
|
+
/** Whether a press landed on the element's scrollbar (outside its client
|
|
16568
|
+
box but inside its border box). */
|
|
16569
|
+
function onScrollbar(element, event) {
|
|
16570
|
+
const inner = viewport(element);
|
|
16571
|
+
return event.clientX >= inner.right || event.clientY >= inner.bottom;
|
|
16572
|
+
}
|
|
16573
|
+
/** The nearest ancestor (or the element) that scrolls vertically. */
|
|
16574
|
+
function scrollerOf(element) {
|
|
16575
|
+
for (let node = element; node; node = node.parentElement) {
|
|
16576
|
+
const { overflowY } = getComputedStyle(node);
|
|
16577
|
+
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
|
|
16578
|
+
return node;
|
|
16579
|
+
}
|
|
16580
|
+
}
|
|
16581
|
+
return null;
|
|
16582
|
+
}
|
|
16583
|
+
/** How far `scroller` (or, with none, the window) has scrolled. */
|
|
16584
|
+
function scrollOf(scroller) {
|
|
16585
|
+
return scroller
|
|
16586
|
+
? { x: scroller.scrollLeft, y: scroller.scrollTop }
|
|
16587
|
+
: { x: window.scrollX, y: window.scrollY };
|
|
16588
|
+
}
|
|
16589
|
+
/** Each marquee editor's start, for {@link beginBlockMarquee}. */
|
|
16590
|
+
const starts = new WeakMap();
|
|
16591
|
+
/**
|
|
16592
|
+
* Notion's marquee: a drag from the empty room around the text draws a box,
|
|
16593
|
+
* and every top-level block it touches is selected — a {@link BlockSelection},
|
|
16594
|
+
* lit block by block in the box's own wash. Opt-in, with
|
|
16595
|
+
* {@link SelectedBlocks} in the kit (which this needs for the keys and the
|
|
16596
|
+
* lit blocks).
|
|
16597
|
+
*
|
|
16598
|
+
* **Where it starts.** A press on the surface outside the editor (its
|
|
16599
|
+
* margin), or on the editor's own box between and below its blocks — never
|
|
16600
|
+
* on a block: a press on text is the text's, and a drag across lines stays a
|
|
16601
|
+
* text selection, as email users expect. Nor on a control of the surface's,
|
|
16602
|
+
* a scrollbar, or with a modifier held. The press is kept from the browser
|
|
16603
|
+
* and ProseMirror alike (no caret jump, no native selection), so a press
|
|
16604
|
+
* released in place is placed here: the caret at the nearest spot in the
|
|
16605
|
+
* text, as a click in the margin of a page puts it in Google Docs.
|
|
16606
|
+
*
|
|
16607
|
+
* **While it drags.** The box is a `div` ({@link BLOCK_MARQUEE_CLASS}) in the
|
|
16608
|
+
* canvas — the surface, unless the host gives a larger one — against the
|
|
16609
|
+
* pointer, clipped to the canvas's viewport, so it may reach over whatever
|
|
16610
|
+
* frames the editor; held near the scroller's edge (or past it) it scrolls,
|
|
16611
|
+
* and the box keeps its origin in the content. Each move hit-tests the
|
|
16612
|
+
* top-level blocks' boxes (the one measured thing — a box drawn by a pointer
|
|
16613
|
+
* has no other geometry) against what the box covers of the surface, and
|
|
16614
|
+
* selects the run it touches; touching none, the selection the press found
|
|
16615
|
+
* comes back. Only blocks are ever selected, wherever the box reaches.
|
|
16616
|
+
* Escape cancels the drag.
|
|
16617
|
+
*/
|
|
16618
|
+
const createBlockMarquee = (options = {}) => {
|
|
16619
|
+
const threshold = options.threshold ?? 4;
|
|
16620
|
+
return defineExtension({
|
|
16621
|
+
name: 'blockMarquee',
|
|
16622
|
+
plugins: () => {
|
|
16623
|
+
// The press handler of this editor's view (one per `plugins` call).
|
|
16624
|
+
let begin = null;
|
|
16625
|
+
return [
|
|
16626
|
+
new Plugin({
|
|
16627
|
+
key: new PluginKey('blockMarquee'),
|
|
16628
|
+
view: (view) => {
|
|
16629
|
+
const surface = options.surface?.(view) ?? view.dom.parentElement ?? view.dom;
|
|
16630
|
+
const canvas = options.canvas?.(view) ?? surface;
|
|
16631
|
+
let gesture = null;
|
|
16632
|
+
/** The containing block the box is positioned against, and how
|
|
16633
|
+
its frame (the content's, scrolled) maps from the client. */
|
|
16634
|
+
const frame = (box) => {
|
|
16635
|
+
const host = box.offsetParent;
|
|
16636
|
+
if (!host || host === document.body || host === document.documentElement) {
|
|
16637
|
+
return { x: window.scrollX, y: window.scrollY };
|
|
16638
|
+
}
|
|
16639
|
+
const rect = host.getBoundingClientRect();
|
|
16640
|
+
return {
|
|
16641
|
+
x: host.scrollLeft - rect.left - host.clientLeft,
|
|
16642
|
+
y: host.scrollTop - rect.top - host.clientTop,
|
|
16643
|
+
};
|
|
16644
|
+
};
|
|
16645
|
+
const update = (current) => {
|
|
16646
|
+
const box = current.box;
|
|
16647
|
+
if (!box || view.isDestroyed)
|
|
16648
|
+
return;
|
|
16649
|
+
// The press, where the content has carried it since.
|
|
16650
|
+
const scroll = scrollOf(current.scroller);
|
|
16651
|
+
const origin = {
|
|
16652
|
+
x: current.pressed.x - (scroll.x - current.scrolledFrom.x),
|
|
16653
|
+
y: current.pressed.y - (scroll.y - current.scrolledFrom.y),
|
|
16654
|
+
};
|
|
16655
|
+
/** The box from the press to the pointer, clipped to `clip`. */
|
|
16656
|
+
const span = (clip) => {
|
|
16657
|
+
const pointer = {
|
|
16658
|
+
x: clamp(current.pointer.x, clip.left, clip.right),
|
|
16659
|
+
y: clamp(current.pointer.y, clip.top, clip.bottom),
|
|
16660
|
+
};
|
|
16661
|
+
return {
|
|
16662
|
+
pointer,
|
|
16663
|
+
left: Math.min(origin.x, pointer.x),
|
|
16664
|
+
top: Math.min(origin.y, pointer.y),
|
|
16665
|
+
right: Math.max(origin.x, pointer.x),
|
|
16666
|
+
bottom: Math.max(origin.y, pointer.y),
|
|
16667
|
+
};
|
|
16668
|
+
};
|
|
16669
|
+
// Drawn on the canvas, as far as the pointer goes on it…
|
|
16670
|
+
const drawn = span(viewport(canvas));
|
|
16671
|
+
const offset = frame(box);
|
|
16672
|
+
box.style.left = `${drawn.left + offset.x}px`;
|
|
16673
|
+
box.style.top = `${drawn.top + offset.y}px`;
|
|
16674
|
+
box.style.width = `${drawn.right - drawn.left}px`;
|
|
16675
|
+
box.style.height = `${drawn.bottom - drawn.top}px`;
|
|
16676
|
+
// …but it selects what it covers of the surface: the blocks
|
|
16677
|
+
// the editor shows. Past the surface's edge, the scroll brings
|
|
16678
|
+
// the rest in (`autoScroll`).
|
|
16679
|
+
const area = span(viewport(surface));
|
|
16680
|
+
const { pointer } = area;
|
|
16681
|
+
const { doc } = view.state;
|
|
16682
|
+
const hits = [];
|
|
16683
|
+
doc.forEach((_node, pos) => {
|
|
16684
|
+
const dom = view.nodeDOM(pos);
|
|
16685
|
+
if (!(dom instanceof Element))
|
|
16686
|
+
return;
|
|
16687
|
+
const rect = dom.getBoundingClientRect();
|
|
16688
|
+
if (rect.bottom > area.top &&
|
|
16689
|
+
rect.top < area.bottom &&
|
|
16690
|
+
rect.right > area.left &&
|
|
16691
|
+
rect.left < area.right) {
|
|
16692
|
+
hits.push(pos);
|
|
16693
|
+
}
|
|
16694
|
+
});
|
|
16695
|
+
let next = current.before;
|
|
16696
|
+
if (hits.length) {
|
|
16697
|
+
// Dragged upward, the head is the top block: Shift-arrows
|
|
16698
|
+
// then move the end the pointer was at.
|
|
16699
|
+
const upward = pointer.y < origin.y;
|
|
16700
|
+
const first = hits[0];
|
|
16701
|
+
const last = hits[hits.length - 1];
|
|
16702
|
+
next = upward
|
|
16703
|
+
? BlockSelection.create(doc, last, first)
|
|
16704
|
+
: BlockSelection.create(doc, first, last);
|
|
16705
|
+
}
|
|
16706
|
+
else if (current.before.$anchor.doc !== doc) {
|
|
16707
|
+
return;
|
|
16708
|
+
}
|
|
16709
|
+
if (!next.eq(view.state.selection))
|
|
16710
|
+
view.dispatch(view.state.tr.setSelection(next));
|
|
16711
|
+
};
|
|
16712
|
+
/** Scrolls the scroller while the pointer is held at its edge,
|
|
16713
|
+
one step a frame, then hit-tests again. */
|
|
16714
|
+
const autoScroll = (current) => {
|
|
16715
|
+
current.frameRequest = null;
|
|
16716
|
+
const scroller = current.scroller;
|
|
16717
|
+
if (!scroller || gesture !== current || !current.moved)
|
|
16718
|
+
return;
|
|
16719
|
+
const edges = viewport(scroller);
|
|
16720
|
+
const y = current.pointer.y;
|
|
16721
|
+
let step = 0;
|
|
16722
|
+
if (y < edges.top + EDGE)
|
|
16723
|
+
step = -Math.min(MAX_SPEED, (edges.top + EDGE - y) / 2);
|
|
16724
|
+
else if (y > edges.bottom - EDGE) {
|
|
16725
|
+
step = Math.min(MAX_SPEED, (y - (edges.bottom - EDGE)) / 2);
|
|
16726
|
+
}
|
|
16727
|
+
if (!step)
|
|
16728
|
+
return;
|
|
16729
|
+
const before = scroller.scrollTop;
|
|
16730
|
+
scroller.scrollTop += step;
|
|
16731
|
+
if (scroller.scrollTop === before)
|
|
16732
|
+
return;
|
|
16733
|
+
update(current);
|
|
16734
|
+
current.frameRequest = requestAnimationFrame(() => autoScroll(current));
|
|
16735
|
+
};
|
|
16736
|
+
const start = (event, elsewhere) => {
|
|
16737
|
+
if (gesture || !view.editable)
|
|
16738
|
+
return false;
|
|
16739
|
+
if (event.button !== 0 || event.detail > 1)
|
|
16740
|
+
return false;
|
|
16741
|
+
if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
|
|
16742
|
+
return false;
|
|
16743
|
+
// The surface's scrollbar is the scroller's; a start point
|
|
16744
|
+
// elsewhere is the host's to judge.
|
|
16745
|
+
if (!elsewhere && onScrollbar(surface, event))
|
|
16746
|
+
return false;
|
|
16747
|
+
// No caret jump, no native selection, no focus leaving: the
|
|
16748
|
+
// press is the box's (or, released in place, placed below).
|
|
16749
|
+
event.preventDefault();
|
|
16750
|
+
claimPress(event);
|
|
16751
|
+
const pressed = { x: event.clientX, y: event.clientY };
|
|
16752
|
+
const scroller = scrollerOf(surface);
|
|
16753
|
+
const current = {
|
|
16754
|
+
pressed,
|
|
16755
|
+
scrolledFrom: scrollOf(scroller),
|
|
16756
|
+
pointer: pressed,
|
|
16757
|
+
moved: false,
|
|
16758
|
+
box: null,
|
|
16759
|
+
before: view.state.selection,
|
|
16760
|
+
scroller,
|
|
16761
|
+
frameRequest: null,
|
|
16762
|
+
stop: () => { },
|
|
16763
|
+
};
|
|
16764
|
+
const move = (moved) => {
|
|
16765
|
+
if (view.isDestroyed)
|
|
16766
|
+
return finish();
|
|
16767
|
+
current.pointer = { x: moved.clientX, y: moved.clientY };
|
|
16768
|
+
if (!current.moved) {
|
|
16769
|
+
if (Math.abs(current.pointer.x - pressed.x) < threshold &&
|
|
16770
|
+
Math.abs(current.pointer.y - pressed.y) < threshold) {
|
|
16771
|
+
return;
|
|
16772
|
+
}
|
|
16773
|
+
current.moved = true;
|
|
16774
|
+
const box = document.createElement('div');
|
|
16775
|
+
box.className = BLOCK_MARQUEE_CLASS;
|
|
16776
|
+
box.setAttribute('aria-hidden', 'true');
|
|
16777
|
+
box.style.position = 'absolute';
|
|
16778
|
+
box.style.pointerEvents = 'none';
|
|
16779
|
+
// The editor's scheme, wherever the box is drawn: the
|
|
16780
|
+
// canvas may wear another (an app theme round a light
|
|
16781
|
+
// paper), and the wash is a light-dark() pair.
|
|
16782
|
+
box.style.colorScheme = getComputedStyle(view.dom).colorScheme;
|
|
16783
|
+
canvas.appendChild(box);
|
|
16784
|
+
current.box = box;
|
|
16785
|
+
// The keys (Delete, Shift-arrows) go to the selection.
|
|
16786
|
+
view.focus();
|
|
16787
|
+
}
|
|
16788
|
+
update(current);
|
|
16789
|
+
if (current.frameRequest === null) {
|
|
16790
|
+
current.frameRequest = requestAnimationFrame(() => autoScroll(current));
|
|
16791
|
+
}
|
|
16792
|
+
};
|
|
16793
|
+
const scrolled = () => update(current);
|
|
16794
|
+
const up = () => {
|
|
16795
|
+
finish();
|
|
16796
|
+
// A click: the caret at the nearest spot in the text — the
|
|
16797
|
+
// point brought onto the editor's box when it fell outside.
|
|
16798
|
+
// Not from a start point elsewhere: a click beside a form is
|
|
16799
|
+
// no click in the text — it only lets selected blocks go,
|
|
16800
|
+
// as a press anywhere does (`SelectedBlocks`; this one kept
|
|
16801
|
+
// the editor's focus, so it is let go here).
|
|
16802
|
+
if (view.isDestroyed || current.moved)
|
|
16803
|
+
return;
|
|
16804
|
+
if (elsewhere) {
|
|
16805
|
+
releaseBlockSelection(view);
|
|
16806
|
+
return;
|
|
16807
|
+
}
|
|
16808
|
+
const rect = view.dom.getBoundingClientRect();
|
|
16809
|
+
const found = view.posAtCoords({
|
|
16810
|
+
left: clamp(pressed.x, rect.left + 1, rect.right - 1),
|
|
16811
|
+
top: clamp(pressed.y, rect.top + 1, rect.bottom - 1),
|
|
16812
|
+
});
|
|
16813
|
+
const { doc } = view.state;
|
|
16814
|
+
const caret = found
|
|
16815
|
+
? Selection.near(doc.resolve(found.pos))
|
|
16816
|
+
: pressed.y < rect.top
|
|
16817
|
+
? Selection.atStart(doc)
|
|
16818
|
+
: Selection.atEnd(doc);
|
|
16819
|
+
view.focus();
|
|
16820
|
+
if (!caret.eq(view.state.selection)) {
|
|
16821
|
+
view.dispatch(view.state.tr.setSelection(caret));
|
|
16822
|
+
}
|
|
16823
|
+
};
|
|
16824
|
+
const key = (pressedKey) => {
|
|
16825
|
+
if (pressedKey.key !== 'Escape')
|
|
16826
|
+
return;
|
|
16827
|
+
pressedKey.preventDefault();
|
|
16828
|
+
pressedKey.stopPropagation();
|
|
16829
|
+
finish();
|
|
16830
|
+
if (!view.isDestroyed && !current.before.eq(view.state.selection)) {
|
|
16831
|
+
if (current.before.$anchor.doc === view.state.doc) {
|
|
16832
|
+
view.dispatch(view.state.tr.setSelection(current.before));
|
|
16833
|
+
}
|
|
16834
|
+
}
|
|
16835
|
+
};
|
|
16836
|
+
const finish = () => {
|
|
16837
|
+
window.removeEventListener('mousemove', move);
|
|
16838
|
+
window.removeEventListener('mouseup', up);
|
|
16839
|
+
window.removeEventListener('keydown', key, true);
|
|
16840
|
+
window.removeEventListener('scroll', scrolled, true);
|
|
16841
|
+
if (current.frameRequest !== null)
|
|
16842
|
+
cancelAnimationFrame(current.frameRequest);
|
|
16843
|
+
current.frameRequest = null;
|
|
16844
|
+
current.box?.remove();
|
|
16845
|
+
current.box = null;
|
|
16846
|
+
if (gesture === current)
|
|
16847
|
+
gesture = null;
|
|
16848
|
+
};
|
|
16849
|
+
current.stop = finish;
|
|
16850
|
+
window.addEventListener('mousemove', move);
|
|
16851
|
+
window.addEventListener('mouseup', up);
|
|
16852
|
+
window.addEventListener('keydown', key, true);
|
|
16853
|
+
// Any scroll under the drag (the wheel, the autoscroll) moves
|
|
16854
|
+
// the content under a still pointer: the box follows.
|
|
16855
|
+
window.addEventListener('scroll', scrolled, true);
|
|
16856
|
+
gesture = current;
|
|
16857
|
+
return true;
|
|
16858
|
+
};
|
|
16859
|
+
// A press outside the editor: the surface's. The editor's own
|
|
16860
|
+
// presses come through `handleDOMEvents`, ahead of ProseMirror's.
|
|
16861
|
+
const outside = (event) => {
|
|
16862
|
+
const target = event.target;
|
|
16863
|
+
if (!target || view.dom.contains(target))
|
|
16864
|
+
return;
|
|
16865
|
+
const control = target.closest(INTERACTIVE);
|
|
16866
|
+
if (control && surface.contains(control))
|
|
16867
|
+
return;
|
|
16868
|
+
start(event, false);
|
|
16869
|
+
};
|
|
16870
|
+
begin = start;
|
|
16871
|
+
starts.set(view, start);
|
|
16872
|
+
surface.addEventListener('mousedown', outside);
|
|
16873
|
+
return {
|
|
16874
|
+
destroy() {
|
|
16875
|
+
surface.removeEventListener('mousedown', outside);
|
|
16876
|
+
gesture?.stop();
|
|
16877
|
+
starts.delete(view);
|
|
16878
|
+
begin = null;
|
|
16879
|
+
},
|
|
16880
|
+
};
|
|
16881
|
+
},
|
|
16882
|
+
props: {
|
|
16883
|
+
handleDOMEvents: {
|
|
16884
|
+
// The editor's own box — the room between and below its
|
|
16885
|
+
// blocks, never a block — starts the box. Returning true keeps
|
|
16886
|
+
// ProseMirror's own press handling (the caret) out of it.
|
|
16887
|
+
mousedown: (view, event) => event.target === view.dom && !!begin?.(event, false),
|
|
16888
|
+
},
|
|
16889
|
+
},
|
|
16890
|
+
}),
|
|
16891
|
+
];
|
|
16892
|
+
},
|
|
16893
|
+
});
|
|
16894
|
+
};
|
|
16895
|
+
/**
|
|
16896
|
+
* Starts the editor's marquee from a press anywhere else — a start point of
|
|
16897
|
+
* the host's own: the room beside a page's text column, the empty room round
|
|
16898
|
+
* a form — as Notion lets a drag start in a page's margins. The host decides
|
|
16899
|
+
* the press is one (its own empty room, not a control); from there it is the
|
|
16900
|
+
* surface's gesture in every way: the box on the canvas, the blocks it
|
|
16901
|
+
* covers of the surface, the scroll, Escape. Released in place it puts no
|
|
16902
|
+
* caret on a line (only the editor's own room does) — it only lets selected
|
|
16903
|
+
* blocks go, as any press outside the editor does.
|
|
16904
|
+
*
|
|
16905
|
+
* Call it from the start point's `mousedown`. It returns whether the box
|
|
16906
|
+
* started (and then took the press: `preventDefault`); `false` without
|
|
16907
|
+
* {@link createBlockMarquee} in the editor, while one is already drawn, in a
|
|
16908
|
+
* read-only editor, and for a press that is not a plain primary one (another
|
|
16909
|
+
* button, a double click, a modifier held).
|
|
16910
|
+
*/
|
|
16911
|
+
function beginBlockMarquee(view, event) {
|
|
16912
|
+
return starts.get(view)?.(event, true) ?? false;
|
|
16913
|
+
}
|
|
16914
|
+
|
|
15739
16915
|
/** Starts watching for {@link MenuScroll}; returns what stops it. */
|
|
15740
16916
|
function watchMenuScroll(view, scroll, menu) {
|
|
15741
16917
|
const mode = scroll ?? 'close';
|
|
@@ -18793,6 +19969,267 @@ const createSendIntent = (options) => {
|
|
|
18793
19969
|
});
|
|
18794
19970
|
};
|
|
18795
19971
|
|
|
19972
|
+
const placeholderKey = new PluginKey('placeholder');
|
|
19973
|
+
/** Replaces the placeholder's words — every empty line redraws at once.
|
|
19974
|
+
Nothing enters the undo history. */
|
|
19975
|
+
const setPlaceholder = (text) => (state, dispatch) => {
|
|
19976
|
+
if (!placeholderKey.get(state))
|
|
19977
|
+
return false;
|
|
19978
|
+
dispatch?.(state.tr.setMeta(placeholderKey, { text }).setMeta('addToHistory', false));
|
|
19979
|
+
return true;
|
|
19980
|
+
};
|
|
19981
|
+
const isBare = (node) => !!node && node.isTextblock && !node.type.spec.code && node.content.size === 0;
|
|
19982
|
+
/** Whether the document is a single empty line. */
|
|
19983
|
+
const isDocEmpty = (doc) => doc.childCount === 1 && isBare(doc.firstChild);
|
|
19984
|
+
/** An image waiting for its file. */
|
|
19985
|
+
const isEmptyImage = (node) => node.type.name === 'image' && !node.attrs['src'];
|
|
19986
|
+
/** Containers that give the lines in them a kind: a change inside one may
|
|
19987
|
+
re-kind a line it never touched (a line pushed off a list item's head). */
|
|
19988
|
+
const KIND_CONTAINERS = new Set(['blockquote', 'listItem', 'bulletList', 'orderedList']);
|
|
19989
|
+
/** What the line at `$pos` (resolved just before it) is. */
|
|
19990
|
+
const kindAt = (node, $pos) => {
|
|
19991
|
+
const name = node.type.name;
|
|
19992
|
+
if (name === 'heading' || name === 'tableCell')
|
|
19993
|
+
return name;
|
|
19994
|
+
const parent = $pos.parent.type.name;
|
|
19995
|
+
if (parent === 'blockquote')
|
|
19996
|
+
return 'quote';
|
|
19997
|
+
if (parent === 'listItem' && $pos.index() === 0 && $pos.depth > 0) {
|
|
19998
|
+
const list = $pos.node($pos.depth - 1).type.name;
|
|
19999
|
+
if (list === 'bulletList' || list === 'orderedList')
|
|
20000
|
+
return list;
|
|
20001
|
+
}
|
|
20002
|
+
return 'line';
|
|
20003
|
+
};
|
|
20004
|
+
const kindWords = (words, line) => {
|
|
20005
|
+
if (line.kind === 'line')
|
|
20006
|
+
return undefined;
|
|
20007
|
+
if (line.kind !== 'heading')
|
|
20008
|
+
return words[line.kind];
|
|
20009
|
+
const heading = words.heading;
|
|
20010
|
+
if (typeof heading === 'string' || heading === undefined)
|
|
20011
|
+
return heading;
|
|
20012
|
+
const level = line.node.attrs['level'] ?? 1;
|
|
20013
|
+
return heading[Math.min(level, heading.length) - 1];
|
|
20014
|
+
};
|
|
20015
|
+
const lineWords = (text, line, show) => {
|
|
20016
|
+
let always = '';
|
|
20017
|
+
let caret = '';
|
|
20018
|
+
if (typeof text === 'string') {
|
|
20019
|
+
caret = text;
|
|
20020
|
+
}
|
|
20021
|
+
else if (typeof text === 'function') {
|
|
20022
|
+
const words = text(line) ?? '';
|
|
20023
|
+
if (line.kind === 'line')
|
|
20024
|
+
caret = words;
|
|
20025
|
+
else
|
|
20026
|
+
always = words;
|
|
20027
|
+
}
|
|
20028
|
+
else if (text) {
|
|
20029
|
+
const own = kindWords(text, line);
|
|
20030
|
+
if (own !== undefined)
|
|
20031
|
+
always = own;
|
|
20032
|
+
else
|
|
20033
|
+
caret = (line.docEmpty ? text.empty : undefined) ?? text.line ?? '';
|
|
20034
|
+
}
|
|
20035
|
+
if (!always && (show === 'all' || line.docEmpty))
|
|
20036
|
+
[always, caret] = [caret, ''];
|
|
20037
|
+
return { always, caret };
|
|
20038
|
+
};
|
|
20039
|
+
const lineDecoration = (node, pos, words, docEmpty) => Decoration.node(pos, pos + node.nodeSize, {
|
|
20040
|
+
class: docEmpty ? 'aee-placeholder aee-placeholder-doc' : 'aee-placeholder',
|
|
20041
|
+
'data-placeholder': words,
|
|
20042
|
+
},
|
|
20043
|
+
// The words again, for `aria-placeholder`; marks it a line's.
|
|
20044
|
+
{ line: words });
|
|
20045
|
+
/** The decorations that show without the caret, for the nodes overlapping
|
|
20046
|
+
`from`–`to` (the whole document without them). */
|
|
20047
|
+
const scan = (state, text, show, from = 0, to = state.doc.content.size) => {
|
|
20048
|
+
const { doc } = state;
|
|
20049
|
+
const decorations = [];
|
|
20050
|
+
const docEmpty = isDocEmpty(doc);
|
|
20051
|
+
const image = text && typeof text === 'object' ? text.image : undefined;
|
|
20052
|
+
doc.nodesBetween(from, to, (node, pos) => {
|
|
20053
|
+
if (image && isEmptyImage(node)) {
|
|
20054
|
+
// The frame's words, for its node view to read off the decoration.
|
|
20055
|
+
decorations.push(Decoration.node(pos, pos + node.nodeSize, {}, { placeholder: image }));
|
|
20056
|
+
return false;
|
|
20057
|
+
}
|
|
20058
|
+
if (!node.isTextblock)
|
|
20059
|
+
return true;
|
|
20060
|
+
if (!isBare(node))
|
|
20061
|
+
return !!image;
|
|
20062
|
+
const kind = kindAt(node, doc.resolve(pos));
|
|
20063
|
+
const { always } = lineWords(text, { node, pos, kind, docEmpty, state }, show);
|
|
20064
|
+
if (always)
|
|
20065
|
+
decorations.push(lineDecoration(node, pos, always, docEmpty));
|
|
20066
|
+
return false;
|
|
20067
|
+
});
|
|
20068
|
+
return decorations;
|
|
20069
|
+
};
|
|
20070
|
+
/** Where `tr` changed its document, in the new document's positions. */
|
|
20071
|
+
const changedRanges = (tr) => {
|
|
20072
|
+
let ranges = [];
|
|
20073
|
+
for (const map of tr.mapping.maps) {
|
|
20074
|
+
ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]);
|
|
20075
|
+
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => ranges.push([newStart, newEnd]));
|
|
20076
|
+
}
|
|
20077
|
+
return ranges;
|
|
20078
|
+
};
|
|
20079
|
+
/** A changed range grown to every line whose kind it may have changed: the
|
|
20080
|
+
nodes it opens or closes (a list turned numbered changes only its
|
|
20081
|
+
boundaries), and the innermost kind-giving container around it. */
|
|
20082
|
+
const widen = (doc, from, to) => {
|
|
20083
|
+
const after = doc.nodeAt(from);
|
|
20084
|
+
if (after && !after.isText)
|
|
20085
|
+
to = Math.max(to, from + after.nodeSize);
|
|
20086
|
+
const before = doc.resolve(to).nodeBefore;
|
|
20087
|
+
if (before && !before.isText)
|
|
20088
|
+
from = Math.min(from, to - before.nodeSize);
|
|
20089
|
+
const $from = doc.resolve(from);
|
|
20090
|
+
for (let depth = $from.sharedDepth(to); depth > 0; depth--) {
|
|
20091
|
+
if (KIND_CONTAINERS.has($from.node(depth).type.name)) {
|
|
20092
|
+
return [Math.min(from, $from.before(depth)), Math.max(to, $from.after(depth))];
|
|
20093
|
+
}
|
|
20094
|
+
}
|
|
20095
|
+
return [from, to];
|
|
20096
|
+
};
|
|
20097
|
+
/**
|
|
20098
|
+
* A placeholder on empty lines, Notion's way: the line the caret is on says
|
|
20099
|
+
* what can be done there ("Type / for commands") and the hint goes as soon as
|
|
20100
|
+
* a character is typed or the caret moves on, while an empty block of a kind
|
|
20101
|
+
* says what it is ("Heading 1", "List", "Empty quote") wherever the caret is.
|
|
20102
|
+
* Nothing of it is in the document — a node decoration puts a class and the
|
|
20103
|
+
* words on the line:
|
|
20104
|
+
*
|
|
20105
|
+
* <div class="aee-placeholder" data-placeholder="Type / for commands">
|
|
20106
|
+
*
|
|
20107
|
+
* and the host's stylesheet paints them (`content: attr(data-placeholder)`),
|
|
20108
|
+
* so the look stays the host's. An empty body is also marked with
|
|
20109
|
+
* `aee-placeholder-doc`, and the editable element carries the words as
|
|
20110
|
+
* `aria-placeholder`, so a screen reader hears what the field is for. An
|
|
20111
|
+
* image waiting for its file gets its words through a node decoration whose
|
|
20112
|
+
* spec carries `placeholder`, which the image's node view puts in its frame.
|
|
20113
|
+
*
|
|
20114
|
+
* The words are the host's, in its language — the library words nothing.
|
|
20115
|
+
* Hand them in as {@link PlaceholderOptions.text} and replace them with the
|
|
20116
|
+
* `setPlaceholder` command whenever they change:
|
|
20117
|
+
*
|
|
20118
|
+
* effect(() => editor.commands.setPlaceholder(this.placeholders()));
|
|
20119
|
+
*
|
|
20120
|
+
* Typing costs only the lines it touched: the decorations live in the
|
|
20121
|
+
* plugin's state, mapped through each change and redone where it landed;
|
|
20122
|
+
* new words redo them once.
|
|
20123
|
+
*
|
|
20124
|
+
* Code lines never show it: an empty line of source is just that. Neither
|
|
20125
|
+
* does a read-only editor (an image frame keeps its words), nor the line
|
|
20126
|
+
* mid-composition (an IME's text is not in the document yet).
|
|
20127
|
+
*/
|
|
20128
|
+
const createPlaceholder = (options = {}) => {
|
|
20129
|
+
const show = options.show ?? 'caret';
|
|
20130
|
+
return defineExtension({
|
|
20131
|
+
name: 'placeholder',
|
|
20132
|
+
commands: () => ({ setPlaceholder }),
|
|
20133
|
+
plugins: () => {
|
|
20134
|
+
let view;
|
|
20135
|
+
/** A redraw: focus is not in the state, so its change is no transaction. */
|
|
20136
|
+
const redraw = () => {
|
|
20137
|
+
if (view)
|
|
20138
|
+
view.dispatch(view.state.tr.setMeta('addToHistory', false));
|
|
20139
|
+
return false;
|
|
20140
|
+
};
|
|
20141
|
+
return [
|
|
20142
|
+
new Plugin({
|
|
20143
|
+
key: placeholderKey,
|
|
20144
|
+
state: {
|
|
20145
|
+
init: (_config, state) => ({
|
|
20146
|
+
text: options.text,
|
|
20147
|
+
set: DecorationSet.create(state.doc, scan(state, options.text, show)),
|
|
20148
|
+
}),
|
|
20149
|
+
apply: (tr, value, oldState, state) => {
|
|
20150
|
+
const meta = tr.getMeta(placeholderKey);
|
|
20151
|
+
if (meta) {
|
|
20152
|
+
return {
|
|
20153
|
+
text: meta.text,
|
|
20154
|
+
set: DecorationSet.create(tr.doc, scan(state, meta.text, show)),
|
|
20155
|
+
};
|
|
20156
|
+
}
|
|
20157
|
+
if (!tr.docChanged)
|
|
20158
|
+
return value;
|
|
20159
|
+
// Into or out of an empty body every line's class changes: redo all.
|
|
20160
|
+
if (isDocEmpty(tr.doc) || isDocEmpty(oldState.doc)) {
|
|
20161
|
+
return {
|
|
20162
|
+
...value,
|
|
20163
|
+
set: DecorationSet.create(tr.doc, scan(state, value.text, show)),
|
|
20164
|
+
};
|
|
20165
|
+
}
|
|
20166
|
+
let set = value.set.map(tr.mapping, tr.doc);
|
|
20167
|
+
for (const range of changedRanges(tr)) {
|
|
20168
|
+
const [from, to] = widen(tr.doc, ...range);
|
|
20169
|
+
const stale = set.find(from, to).filter((d) => d.from < to && d.to > from);
|
|
20170
|
+
set = set.remove(stale).add(tr.doc, scan(state, value.text, show, from, to));
|
|
20171
|
+
}
|
|
20172
|
+
return { ...value, set };
|
|
20173
|
+
},
|
|
20174
|
+
},
|
|
20175
|
+
props: {
|
|
20176
|
+
decorations(state) {
|
|
20177
|
+
const plugin = placeholderKey.getState(state);
|
|
20178
|
+
if (!plugin)
|
|
20179
|
+
return null;
|
|
20180
|
+
const { doc, selection } = state;
|
|
20181
|
+
if (view && !view.editable) {
|
|
20182
|
+
const frames = plugin.set.find(undefined, undefined, (spec) => !spec['line']);
|
|
20183
|
+
return frames.length ? DecorationSet.create(doc, frames) : null;
|
|
20184
|
+
}
|
|
20185
|
+
const { $from } = selection;
|
|
20186
|
+
const line = $from.parent;
|
|
20187
|
+
if (!isBare(line))
|
|
20188
|
+
return plugin.set;
|
|
20189
|
+
const pos = $from.before();
|
|
20190
|
+
// `find` also hands back the line before, which ends where this starts.
|
|
20191
|
+
const own = plugin.set
|
|
20192
|
+
.find(pos, pos + line.nodeSize, (spec) => !!spec['line'])
|
|
20193
|
+
.filter((decoration) => decoration.from === pos);
|
|
20194
|
+
// Mid-composition the caret's line is not empty, only not yet
|
|
20195
|
+
// in the document.
|
|
20196
|
+
if (view?.composing)
|
|
20197
|
+
return own.length ? plugin.set.remove(own) : plugin.set;
|
|
20198
|
+
if (own.length || show === 'all' || !selection.empty || !view?.hasFocus()) {
|
|
20199
|
+
return plugin.set;
|
|
20200
|
+
}
|
|
20201
|
+
const docEmpty = isDocEmpty(doc);
|
|
20202
|
+
const kind = kindAt(line, doc.resolve(pos));
|
|
20203
|
+
const { caret } = lineWords(plugin.text, { node: line, pos, kind, docEmpty, state }, show);
|
|
20204
|
+
return caret
|
|
20205
|
+
? plugin.set.add(doc, [lineDecoration(line, pos, caret, docEmpty)])
|
|
20206
|
+
: plugin.set;
|
|
20207
|
+
},
|
|
20208
|
+
attributes(state) {
|
|
20209
|
+
if (!isDocEmpty(state.doc))
|
|
20210
|
+
return {};
|
|
20211
|
+
const line = placeholderKey
|
|
20212
|
+
.getState(state)
|
|
20213
|
+
?.set.find(0, state.doc.firstChild.nodeSize, (spec) => !!spec['line'])[0];
|
|
20214
|
+
const words = line?.spec['line'];
|
|
20215
|
+
return words ? { 'aria-placeholder': words } : {};
|
|
20216
|
+
},
|
|
20217
|
+
handleDOMEvents: { focus: redraw, blur: redraw },
|
|
20218
|
+
},
|
|
20219
|
+
view: (editorView) => {
|
|
20220
|
+
view = editorView;
|
|
20221
|
+
return {
|
|
20222
|
+
destroy: () => {
|
|
20223
|
+
view = undefined;
|
|
20224
|
+
},
|
|
20225
|
+
};
|
|
20226
|
+
},
|
|
20227
|
+
}),
|
|
20228
|
+
];
|
|
20229
|
+
},
|
|
20230
|
+
});
|
|
20231
|
+
};
|
|
20232
|
+
|
|
18796
20233
|
/** The expressions a string holds, trimmed — as the host is asked. */
|
|
18797
20234
|
const expressionsIn = (text) => scanMergeTags(text).map((tag) => text.slice(tag.expr[0], tag.expr[1]).trim());
|
|
18798
20235
|
/** The attributes that can hold a token: strings — never the authored
|
|
@@ -19084,5 +20521,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
|
|
|
19084
20521
|
* Generated bundle index. Do not edit.
|
|
19085
20522
|
*/
|
|
19086
20523
|
|
|
19087
|
-
export { AUTHORED_ATTR, AUTHORED_SPEC_KEY, AUTHORED_STYLE_DEFAULTS_KEY, AuthoredEnter, BLOCKQUOTE_STYLE, BLOCK_MENU_FOCUS_KEY, BUTTON_FILL, BUTTON_STYLE, BaseKeymap, Blockquote, Bold, BulletList, Button, CARET_ROOM, CLIENT_LABELS, COLUMN_STARTING_LINES, COMMENT_TAG, CONTENT_ATTRS, CSS_SUPPORT, ClearFormatting, CodeLine, CodeLineIndent, Column, ColumnResize, Columns, ColumnsResize, DOCUMENT_CLASS, DOCUMENT_TAGS, DUAL_BACKGROUND_MIN_RATIO, DUAL_CONTRAST_DARK, DUAL_CONTRAST_LIGHT, DUAL_CONTRAST_MIN_RATIO, Divider, Document$1 as Document, DocumentStyles, EMAIL_ATTRIBUTES, EMAIL_CSS_PROPERTIES, EMAIL_SAFE_STYLE_PROPERTIES, EMAIL_SAFE_TAGS, EMAIL_TAGS, EMAIL_TAG_ATTRIBUTES, EmailParagraph, FILE_CHOOSER_POINTER_WAIT, FILL_TEXT_COLOR, FILL_TEXT_COLOR_LIGHT, FILL_TEXT_COLOR_RGB, FOLD_WIDTH, FORMAT_WIDTH, GMAIL_CLIP_BYTES, GUIDES_ACTIVE_CLASS, GUIDES_PEEK_CLASS, GUIDES_PEEK_DELAY_MS, Gapcursor, HEADING_SIZES, HardBreak, Heading, History, HtmlComment, HtmlCommentInline, HtmlElement, HtmlInline, HtmlLanguage, HtmlRaw, HtmlTextElement, HtmlVoid, HtmlVoidBlock, IMPORT_VIEWPORT_WIDTH, INDENT_MAX, INDENT_STEP, INLINE_TAGS, INLINE_VOID_TAGS, IN_SELECTION_CLASS, Image, InlineAtomSelection, InlineImageStore, InlineImages, Italic,
|
|
20524
|
+
export { AUTHORED_ATTR, AUTHORED_SPEC_KEY, AUTHORED_STYLE_DEFAULTS_KEY, AuthoredEnter, BLOCKQUOTE_STYLE, BLOCK_MARQUEE_CLASS, BLOCK_MENU_FOCUS_KEY, BLOCK_RELEASING_CLASS, BLOCK_SELECTED_CLASS, BUTTON_FILL, BUTTON_STYLE, BaseKeymap, BlockSelection, Blockquote, Bold, BulletList, Button, CARET_ROOM, CLIENT_LABELS, COLOR_SCHEME_META, COLOR_SCHEME_RULE, COLUMN_STARTING_LINES, COMMENT_TAG, CONTENT_ATTRS, CSS_SUPPORT, ClearFormatting, CodeLine, CodeLineIndent, Column, ColumnResize, Columns, ColumnsResize, DARK_FILL_CLASS, DARK_MODE_SURFACES, DARK_TEXT_CLASS, DOCUMENT_CLASS, DOCUMENT_TAGS, DUAL_BACKGROUND_MIN_RATIO, DUAL_CONTRAST_DARK, DUAL_CONTRAST_LIGHT, DUAL_CONTRAST_MIN_RATIO, Divider, Document$1 as Document, DocumentStyles, EMAIL_ATTRIBUTES, EMAIL_CSS_PROPERTIES, EMAIL_SAFE_STYLE_PROPERTIES, EMAIL_SAFE_TAGS, EMAIL_TAGS, EMAIL_TAG_ATTRIBUTES, EmailParagraph, FILE_CHOOSER_POINTER_WAIT, FILL_TEXT_COLOR, FILL_TEXT_COLOR_LIGHT, FILL_TEXT_COLOR_RGB, FOLD_WIDTH, FORMAT_WIDTH, GMAIL_CLIP_BYTES, GUIDES_ACTIVE_CLASS, GUIDES_PEEK_CLASS, GUIDES_PEEK_DELAY_MS, Gapcursor, HEADING_LEVELS, HEADING_SIZES, HEADING_SPACE_ABOVE, HEADING_SPACE_BELOW, HardBreak, Heading, History, HtmlComment, HtmlCommentInline, HtmlElement, HtmlInline, HtmlLanguage, HtmlRaw, HtmlTextElement, HtmlVoid, HtmlVoidBlock, IMPORT_VIEWPORT_WIDTH, INDENT_MAX, INDENT_STEP, INLINE_TAGS, INLINE_VOID_TAGS, IN_SELECTION_CLASS, Image, InlineAtomSelection, InlineImageStore, InlineImages, Italic, LIST_SPACE, LayoutGuides, Link, ListItem, MAX_COLUMNS, MAX_IMAGE_WIDTH, MAX_MERGE_TAG_LENGTH, MAX_UNBROKEN_RUN, MIN_COLUMN_CAP, MIN_COLUMN_PCT, MIN_FONT_SIZE, MIN_IMAGE_WIDTH, MIN_TABLE_PCT, MJML_WORLDLY_HTML, MergeTag, NoTextDrag, OrderedList, PARSE_SCOPE_KEY, PLACEHOLDER_WIDTH, Paragraph, PasteHygiene, QuotedHistory, REFUSED_TAGS, RENDERING_CLIENTS, SECTION_CONTENT_MAX, SECTION_INSET, SECTION_PADDING, STACK_CLASS, STACK_RULE, Section, SelectedBlocks, SplitKeepingMarks, TABLE_BLOCK_SPACING, TABLE_BORDER_COLOR, TABLE_CELL_PADDING, TABLE_SECTION_TAGS, TYPING_REST, Table, TableCell, TableRow, Text, TextStyle, UNSET_BUTTON_HREF, Underline, VOID_TAGS, acceptProposal, addColumn, addColumnAtEnd, addRowAtEnd, analyzeAngularExpression, angularDialect, angularRequiredFields, appendColumn, applyAuthoredAttributes, applyEmailVocabulary, attributeString, authoredAttribute, authoredMarkupOf, authoredViewAttributes, bareButtons, beginBlockMarquee, buttonStyle, canonicalHead, canonicalMergeTagInner, canonicalRender, caretCellPos, caretInsideMergeTag, claimedImageFiles, clearCells, clientList, columnCaps, columnWidths, completionContextAt, containerStyle, contrastRatio, correctStoredMistakes, createAngularExpressions, createBlockMarquee, createBlockMenu, createBubbleMenu, createButtonEdit, createContentProposal, createContentStream, createDOMParser, createEditor, createHtmlAutocomplete, createHtmlLanguage, createImageDrag, createInlineImages, createMergeTagResolution, createOffsetMapper, createPlaceholder, createQuotedHistory, createSchema, createSendIntent, createSourceMarks, createSuggestionMenu, createTableHandles, cssColorToHex, darkModeEditorCss, declarationProperty, declaredBackground, decodeDataUrl, defaultBubbleShouldShow, defineExtension, defineMark, defineNode, deleteColumns, deleteFullySelected, discardProposal, distributePadding, docText, dropDeadOfficeClasses, dropHidden, dropMediaQueries, dropZeroSizedWhitespace, duplicateColumn, duplicateRow, editQuotedHistory, effectiveWidths, emailBackgroundPalette, emailDarkModePalette, emailDocument, emailExtensions, emailPlainText, emailSizeBudget, emailTextPalette, embedsAsLinks, entitySpans, escapeAttribute$2 as escapeAttribute, escapeText$1 as escapeText, expressionDialect, expressionDialectKey, extensionActions, extensionSuggestions, fillTextColor, filledPlaceholderAttrs, filterEmailStyle, findColumnContext, findColumnsContext, findCssIssues, findListDepth, findSectionContext, findTableContext, fitImageWidth, foldHTML, formatHTML, formatPct, forwardDocument, forwardQuote, hasBlockChildren$1 as hasBlockChildren, hasDocumentEnvelope, headingStyle, holdsBlock, holdsColumn, hrefProblem, htmlSourceExtensions, imageDropTarget, importLoss, importedDocument, inheritTextStyles, inheritedMarkup, inlineBootstrapTableBorders, inlineImageCids, inlineImageRegistry, inlineImagesKey, inlineStyles, insertHTML, insertMergeTag, isActionEnabled, isBlock$1 as isBlock, isBlockLevel, isBlockish, isBooleanAttribute, isEmailAttribute, isEmailCssProperty, isEmailTag, isFillTextColor, isInlineAtom, isMarkActive, isMergeTagExpression, isMergeTagPath, isNodeActive, isPadded, isProposing, isQuotedHistoryFolded, isSafeColor, isSafeFontFamily, isSafeFontSize, isSafeUrl$1 as isSafeUrl, isSectionImageUrl, isStreaming, italicStarInputRegex, italicStarPasteRegex, italicUnderscoreInputRegex, italicUnderscorePasteRegex, keepGmailRules, layoutBlockAt, linkClickPlugin, linkPastePlugin, linkRangeAt, lintHTML, listStyle, lowerInlinesAroundBlocks, markAttrsOf, markDarkMode, markStackingColumns, marksAcrossBreak, materializeComments, measureMenuPlacement, mediaMatches, mergeTagAt, mergeTagExpressions, mergeTagFields, mergeTagRanges, mergeTagResolutionPending, moveColumn, moveRow, normalizeCommentText, normalizeCss, normalizeHref, normalizeMergeTagText, noteOwnWidths, openFileChooser, openTag$1 as openTag, openTags, outlookColumns, paddingOf, pagedSource, parseAngularExpression, parseDocument, parseFontFamily, parseFontSize, parseHTML, parsePadding, parseRules, parseTextTransform, passesDualBackground, passesDualContrast, placeholderKey, preserveExtensions, preservedAttributes, promoteInlineImages, promoteMergeTags, proposalRange, proposeContent, quotedHistory, readEnvelope, readImageFile, realizeComments, relativeLuminance, releaseBlockSelection, removeColumn, removeLastColumn, removeQuotedHistory, renderForClient, renderedPath, repairLinkSchemes, repairTables, replaceHTML, replyDocument, replyEnvelope, replyQuote, resolveColorScheme, reviseProposal, rewriteInlineImageSources, richTextExtensions, ruleForScope, ruleScope, sanitizePastedHTML, scanHTML, scanMergeTags, scopeOf$1 as scopeOf, scopedDocumentCss, selectColumn, selectRow, selectedButton, selectedImage, selectedImageAlt, selectedMarkAtom, selectionInsideMergeTag, serializeDocument, serializeToHTML, setCellBackground, setColumnBackground, setColumnBoundary, setColumnsBoundary, setPlaceholder, setQuotedHistory, setSectionBackground, setSectionImage, setTableBox, setTableWidth, soleInlineAtom, splitDeclarations$2 as splitDeclarations, splitListItemKeepMarks, starInputRegex, starPasteRegex, stopProposal, streamContent, streamedRange, streamingRange, suggestionSectionTitles, tableStyle, textOffsetAt, textRegions, toEmailSafeColor, toInboundMessage, toggleLayoutList, toggleList, trimInlineBoxes, underscoreInputRegex, underscorePasteRegex, unwrapLayoutTables, unwrapOfficeParagraphMarks, verbatimElement, videoPage, watchMenuScroll, withAttribute, withAuthoredMarkup, withQuoted, withQuotedHistory, withStyleDeclaration, withoutAttribute, withoutStyleDeclaration };
|
|
19088
20525
|
//# sourceMappingURL=h-k-dev-angular-email-editor.mjs.map
|