@h-k-dev/angular-email-editor 0.0.6 → 0.0.8

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 toHex(raw) {
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 = toHex(background);
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
- * Black and white are the two exceptions (no neutral near either end passes:
1388
- * the band runs from #616161 to #949494), offered because a writer needs
1389
- * them — black to take coloured text back to plain, white for text on a dark
1390
- * fill.
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
- exception: 'Vanishes on a dark client that keeps explicit colours; inverters flip it.',
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: '#8d6e63' },
1405
- { name: 'Red', value: '#c5221f' },
1406
- { name: 'Orange', value: '#c2410c' },
1407
- { name: 'Amber', value: '#a05a00' },
1408
- { name: 'Olive', value: '#808000' },
1409
- { name: 'Green', value: '#188038' },
1410
- { name: 'Teal', value: '#0f766e' },
1411
- { name: 'Cyan', value: '#0e7490' },
1412
- { name: 'Blue', value: '#1a73e8' },
1413
- { name: 'Indigo', value: '#6366f1' },
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$3(index, 0, rect.map.height));
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$3(index, 0, rect.map.width));
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$3 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
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;
@@ -7039,7 +7073,7 @@ function canonicalHead(head) {
7039
7073
  if (node.nodeType === Node.TEXT_NODE) {
7040
7074
  const text = (node.nodeValue ?? '').replace(/\s+/g, ' ').trim();
7041
7075
  if (text)
7042
- lines.push(escapeText$1(text));
7076
+ lines.push(escapeText$2(text));
7043
7077
  continue;
7044
7078
  }
7045
7079
  if (!(node instanceof Element))
@@ -7056,7 +7090,7 @@ function canonicalHead(head) {
7056
7090
  }
7057
7091
  else {
7058
7092
  const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
7059
- lines.push(`${open}${escapeText$1(text)}</${tag}>`);
7093
+ lines.push(`${open}${escapeText$2(text)}</${tag}>`);
7060
7094
  }
7061
7095
  }
7062
7096
  return lines.join('\n');
@@ -7118,7 +7152,7 @@ const BOOLEAN_ATTRIBUTES = new Set([
7118
7152
  'reversed',
7119
7153
  'selected',
7120
7154
  ]);
7121
- function escapeText$1(text) {
7155
+ function escapeText$2(text) {
7122
7156
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7123
7157
  }
7124
7158
  function escapeAttribute$2(value) {
@@ -8493,7 +8527,7 @@ function emailSizeBudget(html) {
8493
8527
  }
8494
8528
 
8495
8529
  /** Escapes text for an HTML text position. */
8496
- function escapeText(text) {
8530
+ function escapeText$1(text) {
8497
8531
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
8498
8532
  }
8499
8533
  /** Escapes a value for a double-quoted HTML attribute. */
@@ -9593,7 +9627,204 @@ const trimWhitespace = (text) => text.replace(/^ | $/g, '');
9593
9627
  /** Text for the source pane: a Unicode space shows as its reference
9594
9628
  (`&nbsp;`, `&#8202;`) — the raw character is invisible there and reads
9595
9629
  as a plain space, or as nothing. */
9596
- const sourceText = (text) => escapeText(text).replace(/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, (ch) => (ch === '\u00a0' ? '&nbsp;' : `&#${ch.charCodeAt(0)};`));
9630
+ const sourceText = (text) => escapeText$1(text).replace(/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, (ch) => (ch === '\u00a0' ? '&nbsp;' : `&#${ch.charCodeAt(0)};`));
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
+ }
9597
9828
 
9598
9829
  const RENDERING_CLIENTS = [
9599
9830
  'apple-mail',
@@ -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). Its dark mode inverts the colours.
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 source = client === 'outlook-desktop' ? openOutlookConditionals(html) : html;
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: Gmail (and Outlook's app) invert the message,
9683
- keeping the hue; Apple Mail and Outlook on Windows turn the surface dark
9684
- and leave the message's own colours to it. */
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 { background: #292929; } body { background: #292929; color: #ffffff; }
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) {
@@ -9815,6 +10090,27 @@ function emailPlainText(html) {
9815
10090
  renderChildren(body, lines, '');
9816
10091
  return lines.join('\n');
9817
10092
  }
10093
+ /**
10094
+ * Text as a plain-text message's document — the way back from
10095
+ * {@link emailPlainText}: one email line (`<div>`) per line of text, an
10096
+ * empty line as `<div><br></div>`, every character escaped. What an editor
10097
+ * on `plainTextExtensions` takes when a message turns to plain text:
10098
+ *
10099
+ * ```ts
10100
+ * editor.setContent(plainTextHTML(emailPlainText(html)));
10101
+ * ```
10102
+ *
10103
+ * and `emailPlainText` reads it back as the same lines.
10104
+ */
10105
+ function plainTextHTML(text) {
10106
+ return text
10107
+ .split(/\r\n?|\n/)
10108
+ .map((line) => (line ? `<div>${escapeText(line)}</div>` : '<div><br></div>'))
10109
+ .join('');
10110
+ }
10111
+ function escapeText(text) {
10112
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
10113
+ }
9818
10114
  function renderChildren(parent, lines, prefix) {
9819
10115
  for (const child of Array.from(parent.children))
9820
10116
  renderBlock(child, lines, prefix);
@@ -9920,12 +10216,23 @@ function imageText(element) {
9920
10216
  * keeps the cap — narrower, still whole — and loses nothing else, which is
9921
10217
  * the same rule the canonical HTML already keeps.
9922
10218
  *
10219
+ * Dark mode is the other enhancement (dark-mode.ts): the document says it
10220
+ * is drawn for both schemes, and the palette's colours in it wear a class
10221
+ * that their dark variant hangs on, in a `<style>` of its own — a client
10222
+ * that reads none of it (Gmail, classic Outlook) inverts the light colours,
10223
+ * which are chosen to read inverted.
10224
+ *
9923
10225
  * The result is for the transport, not for the editor: parse and
9924
10226
  * `emailPlainText` take the fragment `serializeToHTML` returns (the preview
9925
10227
  * text would otherwise read as body text).
9926
10228
  */
9927
10229
  function emailDocument(html, options = {}) {
9928
- const { lang, dir = 'auto', title, previewText } = options;
10230
+ const { lang, dir = 'auto', title, previewText, darkMode = emailDarkModePalette } = options;
10231
+ // Columns first: the stacking class goes in by pattern, on the markup as
10232
+ // the editor wrote it; the dark-mode classes join it on the element.
10233
+ const stacked = markStackingColumns(html);
10234
+ const dark = darkMode ? markDarkMode(stacked, darkMode) : null;
10235
+ const darkStyle = dark ? [COLOR_SCHEME_RULE, dark.css].filter(Boolean).join(' ') : '';
9929
10236
  const langAttr = lang ? ` lang="${escapeAttribute$1(lang)}"` : '';
9930
10237
  const dirAttr = ` dir="${dir}"`;
9931
10238
  return ('<!doctype html>' +
@@ -9935,14 +10242,16 @@ function emailDocument(html, options = {}) {
9935
10242
  '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' +
9936
10243
  '<meta name="viewport" content="width=device-width, initial-scale=1">' +
9937
10244
  '<meta name="x-apple-disable-message-reformatting">' +
9938
- `<title>${escapeText(title ?? '')}</title>` +
10245
+ (dark ? COLOR_SCHEME_META : '') +
10246
+ `<title>${escapeText$1(title ?? '')}</title>` +
9939
10247
  OUTLOOK_SETTINGS +
9940
10248
  `<style type="text/css">${STACK_RULE}</style>` +
10249
+ (dark ? `<style type="text/css">${darkStyle}</style>` : '') +
9941
10250
  '</head>' +
9942
10251
  `<body style="${BODY_STYLE}">` +
9943
10252
  `<div role="article" aria-roledescription="email"${langAttr}${dirAttr}>` +
9944
10253
  (previewText ? previewBlock(previewText) : '') +
9945
- markStackingColumns(html) +
10254
+ (dark ? dark.html : stacked) +
9946
10255
  '</div>' +
9947
10256
  '</body>' +
9948
10257
  '</html>');
@@ -9961,7 +10270,7 @@ const PREVIEW_STYLE = 'display: none; max-height: 0; overflow: hidden; mso-hide:
9961
10270
  space per step — none renders, each counts as a character. */
9962
10271
  const PREVIEW_FILLER = '&#847;&zwnj;&nbsp;'.repeat(90);
9963
10272
  function previewBlock(text) {
9964
- return `<div style="${PREVIEW_STYLE}">${escapeText(text)}${PREVIEW_FILLER}</div>`;
10273
+ return `<div style="${PREVIEW_STYLE}">${escapeText$1(text)}${PREVIEW_FILLER}</div>`;
9965
10274
  }
9966
10275
 
9967
10276
  const word = (label) => (typeof label === 'function' ? label() : label);
@@ -10440,11 +10749,32 @@ const toggleList = (listType, itemType) => (state, dispatch, view) => {
10440
10749
  const depth = findListDepth($from);
10441
10750
  if (depth === null)
10442
10751
  return wrapInList(listType)(state, dispatch);
10443
- if ($from.node(depth).type === listType)
10752
+ const list = $from.node(depth);
10753
+ if (list.type === listType)
10444
10754
  return liftListItem(itemType)(state, dispatch, view);
10445
- dispatch?.(state.tr.setNodeMarkup($from.before(depth), listType));
10755
+ // The other flavour, in place: a layout list stays one.
10756
+ dispatch?.(state.tr.setNodeMarkup($from.before(depth), listType, { layout: list.attrs['layout'] }));
10446
10757
  return true;
10447
10758
  };
10759
+ /**
10760
+ * The layout list's toggle: wraps the selection in a layout list, makes the
10761
+ * list it is in one (either flavour, kept flush), or lifts out of it when it
10762
+ * is one already.
10763
+ */
10764
+ const toggleLayoutList = (listType, itemType) => (state, dispatch, view) => {
10765
+ const { $from } = state.selection;
10766
+ const depth = findListDepth($from);
10767
+ if (depth === null)
10768
+ return wrapInList(listType, { layout: true })(state, dispatch);
10769
+ const list = $from.node(depth);
10770
+ if (list.type === listType && list.attrs['layout']) {
10771
+ return liftListItem(itemType)(state, dispatch, view);
10772
+ }
10773
+ dispatch?.(state.tr.setNodeMarkup($from.before(depth), listType, { ...list.attrs, layout: true }));
10774
+ return true;
10775
+ };
10776
+ /** Whether the selection is in a list of `type`, a layout one or not. */
10777
+ const inList = (state, type, layout) => isNodeActive(state, type, { layout });
10448
10778
  /**
10449
10779
  * Tiptap-inspired helper: Intercepts the default splitListItem transaction
10450
10780
  * and ensures marks (bold, italic, etc.) are carried over to the new list item.
@@ -10458,10 +10788,63 @@ const splitListItemKeepMarks = (itemType) => (state, dispatch, view) => {
10458
10788
  dispatch?.(tr);
10459
10789
  }, view);
10460
10790
  };
10461
- /** The ledger's answer to the 40px-per-level default indent that eats a
10462
- third of a phone screen: a small explicit indent, no margins (lines carry
10463
- none). Nested lists compound 24px per level, readable at 320px. */
10464
- const LIST_STYLE = 'margin: 0px; padding-left: 24px;';
10791
+ /** The space above and below a list: Gmail's own. */
10792
+ const LIST_SPACE = 12;
10793
+ /**
10794
+ * A list's canonical inline style. The indent is the ledger's answer to the
10795
+ * 40px-per-level default that eats a third of a phone screen: 24px, nested
10796
+ * lists compounding it, readable at 320px. A list keeps 12px above and below
10797
+ * — Gmail's spacing, the margins the schema writes besides a heading's
10798
+ * (positive px margins render in Gmail, Outlook's Word engine and Apple Mail
10799
+ * alike; where one meets a heading's or another list's, Outlook may add the
10800
+ * two, up to 12px past what a browser shows). A **layout** list sits flush,
10801
+ * `margin: 0`, spacing left to the layout around it — and so does every
10802
+ * nested list, which belongs to its item's line (see {@link listRepair}).
10803
+ */
10804
+ const listStyle = (layout = false) => `margin: ${layout ? '0px' : `${LIST_SPACE}px 0px`}; padding-left: 24px;`;
10805
+ /** Whether a parsed list is a layout list: written flush (the composer's own
10806
+ before lists were spaced, a builder's reset), or nested in another. */
10807
+ const parsedLayout = (dom) => declaresFlushTop(dom) || !!dom.parentElement?.closest('li, ul, ol');
10808
+ /** Ranges a run of transactions wrote, in the last document's positions. */
10809
+ const writtenRanges = (trs) => {
10810
+ let ranges = [];
10811
+ for (const tr of trs) {
10812
+ for (const map of tr.mapping.maps) {
10813
+ ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]);
10814
+ map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
10815
+ if (newEnd > newStart)
10816
+ ranges.push([newStart, newEnd]);
10817
+ });
10818
+ }
10819
+ }
10820
+ return ranges;
10821
+ };
10822
+ /**
10823
+ * Keeps a nested list flush: Tab makes one with the default attrs, and its
10824
+ * margin would push the sub-items off their parent's line. Only what the
10825
+ * transactions wrote is looked at; a nested list is made layout, never back.
10826
+ */
10827
+ const listRepair = () => new Plugin({
10828
+ key: new PluginKey('listRepair'),
10829
+ appendTransaction: (transactions, _old, state) => {
10830
+ if (!transactions.some((tr) => tr.docChanged))
10831
+ return undefined;
10832
+ const tr = state.tr;
10833
+ const end = state.doc.content.size;
10834
+ for (const [from, to] of writtenRanges(transactions)) {
10835
+ state.doc.nodesBetween(Math.min(from, end), Math.min(to, end), (node, pos, parent) => {
10836
+ if (node.isTextblock)
10837
+ return false;
10838
+ const nested = parent?.type.name === 'listItem';
10839
+ if (nested && node.type.spec.group?.includes('list') && !node.attrs['layout']) {
10840
+ tr.setNodeAttribute(pos, 'layout', true);
10841
+ }
10842
+ return true;
10843
+ });
10844
+ }
10845
+ return tr.docChanged ? tr : undefined;
10846
+ },
10847
+ });
10465
10848
  const ListItem = defineNode({
10466
10849
  name: 'listItem',
10467
10850
  spec: {
@@ -10481,11 +10864,16 @@ const ListItem = defineNode({
10481
10864
  const BulletList = defineNode({
10482
10865
  name: 'bulletList',
10483
10866
  spec: {
10867
+ attrs: {
10868
+ /** Flush, without the space around it — see {@link listStyle}. */
10869
+ layout: { default: false },
10870
+ },
10484
10871
  content: 'listItem+',
10485
10872
  group: 'block list',
10486
- parseDOM: [{ tag: 'ul' }],
10487
- toDOM: () => ['ul', { style: LIST_STYLE }, 0],
10873
+ parseDOM: [{ tag: 'ul', getAttrs: (dom) => ({ layout: parsedLayout(dom) }) }],
10874
+ toDOM: (node) => ['ul', { style: listStyle(node.attrs['layout']) }, 0],
10488
10875
  },
10876
+ plugins: () => [listRepair()],
10489
10877
  commands: ({ schema }) => ({
10490
10878
  toggleBulletList: () => toggleList(schema.nodes['bulletList'], schema.nodes['listItem']),
10491
10879
  }),
@@ -10504,14 +10892,27 @@ const BulletList = defineNode({
10504
10892
  keywords: ['ul', 'unordered', 'list'],
10505
10893
  icon: 'format_list_bulleted',
10506
10894
  command: toggleList(schema.nodes['bulletList'], schema.nodes['listItem']),
10507
- isActive: (state) => isNodeActive(state, schema.nodes['bulletList']),
10895
+ isActive: (state) => inList(state, schema.nodes['bulletList'], false),
10896
+ },
10897
+ {
10898
+ id: 'bulleted-list-layout',
10899
+ section: 'blocks',
10900
+ title: 'Bulleted list (layout)',
10901
+ keywords: ['ul', 'unordered', 'list', 'layout', 'flush'],
10902
+ icon: 'format_list_bulleted',
10903
+ command: toggleLayoutList(schema.nodes['bulletList'], schema.nodes['listItem']),
10904
+ isActive: (state) => inList(state, schema.nodes['bulletList'], true),
10508
10905
  },
10509
10906
  ],
10510
10907
  });
10511
10908
  const OrderedList = defineNode({
10512
10909
  name: 'orderedList',
10513
10910
  spec: {
10514
- attrs: { order: { default: 1 } },
10911
+ attrs: {
10912
+ order: { default: 1 },
10913
+ /** Flush, without the space around it — see {@link listStyle}. */
10914
+ layout: { default: false },
10915
+ },
10515
10916
  content: 'listItem+',
10516
10917
  group: 'block list',
10517
10918
  parseDOM: [
@@ -10519,12 +10920,16 @@ const OrderedList = defineNode({
10519
10920
  tag: 'ol',
10520
10921
  getAttrs: (node) => ({
10521
10922
  order: node.hasAttribute('start') ? +node.getAttribute('start') : 1,
10923
+ layout: parsedLayout(node),
10522
10924
  }),
10523
10925
  },
10524
10926
  ],
10525
- toDOM: (node) => node.attrs['order'] === 1
10526
- ? ['ol', { style: LIST_STYLE }, 0]
10527
- : ['ol', { start: node.attrs['order'], style: LIST_STYLE }, 0],
10927
+ toDOM: (node) => {
10928
+ const style = listStyle(node.attrs['layout']);
10929
+ return node.attrs['order'] === 1
10930
+ ? ['ol', { style }, 0]
10931
+ : ['ol', { start: node.attrs['order'], style }, 0];
10932
+ },
10528
10933
  },
10529
10934
  commands: ({ schema }) => ({
10530
10935
  toggleOrderedList: () => toggleList(schema.nodes['orderedList'], schema.nodes['listItem']),
@@ -10544,7 +10949,16 @@ const OrderedList = defineNode({
10544
10949
  keywords: ['ol', 'ordered', 'list'],
10545
10950
  icon: 'format_list_numbered',
10546
10951
  command: toggleList(schema.nodes['orderedList'], schema.nodes['listItem']),
10547
- isActive: (state) => isNodeActive(state, schema.nodes['orderedList']),
10952
+ isActive: (state) => inList(state, schema.nodes['orderedList'], false),
10953
+ },
10954
+ {
10955
+ id: 'numbered-list-layout',
10956
+ section: 'blocks',
10957
+ title: 'Numbered list (layout)',
10958
+ keywords: ['ol', 'ordered', 'list', 'layout', 'flush'],
10959
+ icon: 'format_list_numbered',
10960
+ command: toggleLayoutList(schema.nodes['orderedList'], schema.nodes['listItem']),
10961
+ isActive: (state) => inList(state, schema.nodes['orderedList'], true),
10548
10962
  },
10549
10963
  ],
10550
10964
  });
@@ -11612,50 +12026,101 @@ const HardBreak = defineNode({
11612
12026
  }),
11613
12027
  });
11614
12028
 
11615
- const LEVELS = [1, 2, 3, 4, 5, 6];
12029
+ /** The levels the schema knows. No h5 or h6: an imported one is a plain
12030
+ line, and `##### ` stays the text it is. */
12031
+ const HEADING_LEVELS = [1, 2, 3, 4];
11616
12032
  /** 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, 5: 16, 6: 16 };
11618
- /** A heading's canonical inline style. No margins — lines carry none
11619
- (principle 3), spacing is an empty line; no `line-height` — Outlook
11620
- substitutes its own (the client-support module says so) and our output
11621
- stays lint-clean. Incoming heading styles never survive: the scale is the
11622
- schema's, not the author's. */
11623
- const headingStyle = (level) => `margin: 0px; font-size: ${HEADING_SIZES[level] ?? 16}px;`;
12033
+ const HEADING_SIZES = { 1: 24, 2: 20, 3: 18, 4: 16 };
12034
+ /** The space above a heading, by level: its size less {@link HEADING_SPACE_BELOW}. */
12035
+ const HEADING_SPACE_ABOVE = { 1: 20, 2: 16, 3: 14, 4: 12 };
12036
+ /** The space below every heading. */
12037
+ const HEADING_SPACE_BELOW = 4;
12038
+ /**
12039
+ * A heading's canonical inline style. A heading sits nearer the words it
12040
+ * opens than the words before it (proximity): a margin above, a small one
12041
+ * below — an exception to principle 3, with a list's (lists.ts). Positive px
12042
+ * margins render in Gmail, Outlook's Word engine and Apple Mail alike
12043
+ * (caniemail, 2026-09). Body lines carry none, so two margins meet only
12044
+ * between headings and lists, where a browser collapses the pair and Word
12045
+ * may add it: the space below is one constant 4px, and the space above is
12046
+ * the size less it, so heading after heading Outlook's worst case lands on
12047
+ * the size itself (24/20/18/16), never more than 4px off. A
12048
+ * **layout** heading — a band's title, a column's — sits flush: `margin: 0`,
12049
+ * spacing left to the layout around it. No `line-height` — Outlook
12050
+ * substitutes its own (the client-support module says so) and our output
12051
+ * stays lint-clean. Incoming heading styles never survive: the scale is the
12052
+ * schema's, not the author's.
12053
+ */
12054
+ const headingStyle = (level, layout = false) => {
12055
+ const size = HEADING_SIZES[level] ?? 16;
12056
+ const above = HEADING_SPACE_ABOVE[level] ?? size - HEADING_SPACE_BELOW;
12057
+ const margin = layout ? '0px' : `${above}px 0px ${HEADING_SPACE_BELOW}px`;
12058
+ return `margin: ${margin}; font-size: ${size}px;`;
12059
+ };
12060
+ /** Makes the line a heading of `level`; a layout heading when `layout`. */
12061
+ const toHeading = (type, level, layout = false) => setBlockType(type, { level, layout });
12062
+ /** Makes the line a layout heading, at its level when it is a heading
12063
+ already, else at 1. */
12064
+ const toLayoutHeading = (type) => (state, dispatch, view) => {
12065
+ const { $from } = state.selection;
12066
+ const level = $from.parent.type === type ? $from.parent.attrs['level'] : 1;
12067
+ return toHeading(type, level, true)(state, dispatch, view);
12068
+ };
11624
12069
  const Heading = defineNode({
11625
12070
  name: 'heading',
11626
12071
  spec: {
11627
- attrs: { level: { default: 1 } },
12072
+ attrs: {
12073
+ level: { default: 1 },
12074
+ /** Flush, without the space above — see {@link headingStyle}. */
12075
+ layout: { default: false },
12076
+ },
11628
12077
  content: 'inline*',
11629
12078
  group: 'block',
11630
12079
  defining: true,
11631
- parseDOM: LEVELS.map((level) => ({ tag: `h${level}`, attrs: { level } })),
12080
+ parseDOM: HEADING_LEVELS.map((level) => ({
12081
+ tag: `h${level}`,
12082
+ // Written flush — the composer's own before headings were spaced, a
12083
+ // builder's reset title — it stays flush.
12084
+ getAttrs: (dom) => ({ level, layout: declaresFlushTop(dom) }),
12085
+ })),
11632
12086
  toDOM: (node) => [
11633
12087
  `h${node.attrs['level']}`,
11634
- { style: headingStyle(node.attrs['level']) },
12088
+ { style: headingStyle(node.attrs['level'], node.attrs['layout']) },
11635
12089
  0,
11636
12090
  ],
11637
12091
  },
11638
12092
  commands: ({ schema }) => ({
11639
- setHeading: (level) => setBlockType(schema.nodes['heading'], { level }),
12093
+ setHeading: (level, layout = false) => toHeading(schema.nodes['heading'], level, layout),
12094
+ setLayoutHeading: () => toLayoutHeading(schema.nodes['heading']),
11640
12095
  }),
11641
- keymap: ({ schema }) => Object.fromEntries(LEVELS.map((level) => [
12096
+ keymap: ({ schema }) => Object.fromEntries(HEADING_LEVELS.map((level) => [
11642
12097
  `Shift-Ctrl-${level}`,
11643
- setBlockType(schema.nodes['heading'], { level }),
12098
+ toHeading(schema.nodes['heading'], level),
11644
12099
  ])),
11645
12100
  inputRules: ({ schema }) => [
11646
- // `## ` at the start of a block becomes an h2, etc.
11647
- textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes['heading'], (match) => ({
12101
+ // `## ` at the start of a block becomes an h2, up to `#### `.
12102
+ textblockTypeInputRule(/^(#{1,4})\s$/, schema.nodes['heading'], (match) => ({
11648
12103
  level: match[1].length,
11649
12104
  })),
11650
12105
  ],
11651
- actions: ({ schema }) => [1, 2, 3].map((level) => ({
11652
- id: `heading-${level}`,
11653
- section: 'blocks',
11654
- title: `Heading ${level}`,
11655
- keywords: [`h${level}`, 'title', 'heading'],
11656
- icon: `format_h${level}`,
11657
- command: setBlockType(schema.nodes['heading'], { level }),
11658
- })),
12106
+ actions: ({ schema }) => [
12107
+ ...[1, 2, 3].map((level) => ({
12108
+ id: `heading-${level}`,
12109
+ section: 'blocks',
12110
+ title: `Heading ${level}`,
12111
+ keywords: [`h${level}`, 'title', 'heading'],
12112
+ icon: `format_h${level}`,
12113
+ command: toHeading(schema.nodes['heading'], level),
12114
+ })),
12115
+ {
12116
+ id: 'heading-layout',
12117
+ section: 'blocks',
12118
+ title: 'Heading (layout)',
12119
+ keywords: ['title', 'heading', 'layout', 'flush', 'banner'],
12120
+ icon: 'title',
12121
+ command: toLayoutHeading(schema.nodes['heading']),
12122
+ },
12123
+ ],
11659
12124
  });
11660
12125
 
11661
12126
  /** The ledger's answer to reply chains whose margins stack until text is one
@@ -12259,7 +12724,7 @@ function pickAndInsertImages(view, schema) {
12259
12724
  }
12260
12725
  /** Floor for a resized image: below this a pad has nothing left to grab. */
12261
12726
  const MIN_IMAGE_WIDTH = 40;
12262
- const clamp$2 = (value, min, max) => Math.min(Math.max(value, min), max);
12727
+ const clamp$3 = (value, min, max) => Math.min(Math.max(value, min), max);
12263
12728
  /** Room an image leaves at the end of its line for the caret (decided
12264
12729
  2026-09-24): the widest an image gets in a line is the line less this,
12265
12730
  so the caret can always stand to its right — on a drop, and at the top
@@ -12307,6 +12772,9 @@ function filledPlaceholderAttrs(placeholder, picked) {
12307
12772
  width: placeholder.width ?? picked.width ?? null,
12308
12773
  };
12309
12774
  }
12775
+ /** The words a decoration hands the frame — the placeholder's `image`, in
12776
+ the host's language — or the English default. */
12777
+ const frameWords = (decorations) => decorations.find((decoration) => typeof decoration.spec['placeholder'] === 'string')?.spec['placeholder'] ?? 'Choose an image';
12310
12778
  /** A frame standing in for the image: the placeholder awaiting its file, or
12311
12779
  a `cid:` whose part the registry does not hold. The app owns the pixels. */
12312
12780
  function imageFrame(className, label) {
@@ -12346,12 +12814,14 @@ function imageFrame(className, label) {
12346
12814
  * pads: size the frame first, then click it to pick the file that fills it
12347
12815
  * ({@link filledPlaceholderAttrs} keeps the frame's width). The slide-deck
12348
12816
  * model — Keynote's media placeholder — and the image counterpart of the
12349
- * merge tag.
12817
+ * merge tag. Its words are the placeholder's `image` (a node decoration
12818
+ * whose spec carries `placeholder`), "Choose an image" without one.
12350
12819
  */
12351
12820
  class ImageView {
12352
12821
  node;
12353
12822
  view;
12354
12823
  getPos;
12824
+ decorations;
12355
12825
  dom;
12356
12826
  img;
12357
12827
  frame;
@@ -12359,10 +12829,11 @@ class ImageView {
12359
12829
  pad released over the wrapper, and that click must not open the
12360
12830
  picker. */
12361
12831
  resized = false;
12362
- constructor(node, view, getPos) {
12832
+ constructor(node, view, getPos, decorations = []) {
12363
12833
  this.node = node;
12364
12834
  this.view = view;
12365
12835
  this.getPos = getPos;
12836
+ this.decorations = decorations;
12366
12837
  this.dom = document.createElement('span');
12367
12838
  this.dom.className = 'aee-image';
12368
12839
  this.apply(node);
@@ -12382,7 +12853,7 @@ class ImageView {
12382
12853
  render(node) {
12383
12854
  const src = node.attrs['src'];
12384
12855
  if (!src)
12385
- return imageFrame('aee-image__placeholder', 'Choose an image');
12856
+ return imageFrame('aee-image__placeholder', frameWords(this.decorations));
12386
12857
  const img = DOMSerializer.renderSpec(document, node.type.spec.toDOM(node)).dom;
12387
12858
  const cid = /^\s*cid:(.+)$/i.exec(src)?.[1]?.trim();
12388
12859
  if (cid) {
@@ -12393,10 +12864,11 @@ class ImageView {
12393
12864
  }
12394
12865
  return img;
12395
12866
  }
12396
- update(node) {
12867
+ update(node, decorations) {
12397
12868
  if (node.type !== this.node.type)
12398
12869
  return false;
12399
12870
  this.node = node;
12871
+ this.decorations = decorations;
12400
12872
  const img = this.render(node);
12401
12873
  this.img.replaceWith(img);
12402
12874
  this.img = img;
@@ -12477,7 +12949,7 @@ class ImageView {
12477
12949
  this.resized = true;
12478
12950
  const preview = (ev) => {
12479
12951
  const delta = ev.clientX - startX;
12480
- width = clamp$2(Math.round(rect.width + (side === 'right' ? delta : -delta)), MIN_IMAGE_WIDTH, max);
12952
+ width = clamp$3(Math.round(rect.width + (side === 'right' ? delta : -delta)), MIN_IMAGE_WIDTH, max);
12481
12953
  this.frame.style.width = `${width}px`;
12482
12954
  this.frame.style.height = `${Math.round(width * ratio)}px`;
12483
12955
  };
@@ -12734,7 +13206,11 @@ const Image = defineNode({
12734
13206
  }),
12735
13207
  new Plugin({
12736
13208
  key: new PluginKey('imageView'),
12737
- props: { nodeViews: { image: (node, view, getPos) => new ImageView(node, view, getPos) } },
13209
+ props: {
13210
+ nodeViews: {
13211
+ image: (node, view, getPos, decorations) => new ImageView(node, view, getPos, decorations),
13212
+ },
13213
+ },
12738
13214
  }),
12739
13215
  ],
12740
13216
  });
@@ -12785,6 +13261,350 @@ const Divider = defineNode({
12785
13261
  ],
12786
13262
  });
12787
13263
 
13264
+ /** The class every block of a {@link BlockSelection} wears — the wash the
13265
+ app paints over it, since the selection itself draws nothing. */
13266
+ const BLOCK_SELECTED_CLASS = 'aee-block--selected';
13267
+ /** The class the blocks of a {@link BlockSelection} just let go of wear, for
13268
+ as long as an animation the app gives it runs — the wash fading out
13269
+ instead of vanishing. Gone once the animation ends, at once with none. */
13270
+ const BLOCK_RELEASING_CLASS = 'aee-block--releasing';
13271
+ /** One node decoration per block of `selection`, wearing `className`. */
13272
+ function blockDecorations(doc, selection, className) {
13273
+ return DecorationSet.create(doc, selection.blocks.map(({ node, pos }) => Decoration.node(pos, pos + node.nodeSize, { class: className })));
13274
+ }
13275
+ /** A selection of `anchor`..`head` when both are seams between top-level
13276
+ blocks and it holds at least one, else the nearest valid selection at
13277
+ `head` — what a mapped or restored block selection falls back to once
13278
+ its blocks are gone. */
13279
+ function blocksOr(doc, anchor, head) {
13280
+ const $anchor = doc.resolve(anchor);
13281
+ const $head = doc.resolve(head);
13282
+ if (anchor !== head && $anchor.depth === 0 && $head.depth === 0) {
13283
+ return new BlockSelection($anchor, $head);
13284
+ }
13285
+ return Selection.near($head);
13286
+ }
13287
+ /** Where top-level block `index` starts. */
13288
+ function blockStart(doc, index) {
13289
+ let pos = 0;
13290
+ for (let i = 0; i < index; i++)
13291
+ pos += doc.child(i).nodeSize;
13292
+ return pos;
13293
+ }
13294
+ class BlockBookmark {
13295
+ anchor;
13296
+ head;
13297
+ constructor(anchor, head) {
13298
+ this.anchor = anchor;
13299
+ this.head = head;
13300
+ }
13301
+ map(mapping) {
13302
+ const forward = this.head > this.anchor;
13303
+ return new BlockBookmark(mapping.map(this.anchor, forward ? 1 : -1), mapping.map(this.head, forward ? -1 : 1));
13304
+ }
13305
+ resolve(doc) {
13306
+ return blocksOr(doc, this.anchor, this.head);
13307
+ }
13308
+ }
13309
+ /**
13310
+ * Whole top-level blocks selected as blocks — Notion's block selection: the
13311
+ * marquee's ({@link createBlockMarquee}) and the second `Mod-a`'s.
13312
+ *
13313
+ * Its ends are seams between top-level blocks (depth 0), so it always holds
13314
+ * whole blocks, one or more, in a run: an email is one column of them, and
13315
+ * a box dragged down it covers a run. A columns block or a table is one
13316
+ * block — selected whole, never cell by cell (that is `CellSelection`).
13317
+ *
13318
+ * It copies, cuts, deletes and is pasted over as the blocks (ProseMirror's
13319
+ * own handling, through its range), and draws nothing itself: `visible` is
13320
+ * false, so the browser's highlight is hidden, and every block in it wears
13321
+ * {@link BLOCK_SELECTED_CLASS} for the app to paint (see {@link SelectedBlocks}).
13322
+ */
13323
+ class BlockSelection extends Selection {
13324
+ /** The blocks from `anchorBlock` to `headBlock`, both the positions where
13325
+ a top-level block starts; the head is the end Shift-arrows move. */
13326
+ static create(doc, anchorBlock, headBlock = anchorBlock) {
13327
+ const $anchor = doc.resolve(anchorBlock);
13328
+ const $head = doc.resolve(headBlock);
13329
+ const anchorNode = $anchor.nodeAfter;
13330
+ const headNode = $head.nodeAfter;
13331
+ if ($anchor.depth !== 0 || $head.depth !== 0 || !anchorNode || !headNode) {
13332
+ throw new RangeError('BlockSelection.create takes the starts of top-level blocks');
13333
+ }
13334
+ return headBlock >= anchorBlock
13335
+ ? new BlockSelection($anchor, doc.resolve(headBlock + headNode.nodeSize))
13336
+ : new BlockSelection(doc.resolve(anchorBlock + anchorNode.nodeSize), $head);
13337
+ }
13338
+ /** Every block of the document. */
13339
+ static all(doc) {
13340
+ return new BlockSelection(doc.resolve(0), doc.resolve(doc.content.size));
13341
+ }
13342
+ constructor($anchor, $head) {
13343
+ super($anchor, $head);
13344
+ }
13345
+ /** The selected blocks, top to bottom. */
13346
+ get blocks() {
13347
+ const blocks = [];
13348
+ this.$from.doc.nodesBetween(this.from, this.to, (node, pos) => {
13349
+ blocks.push({ node, pos });
13350
+ return false;
13351
+ });
13352
+ return blocks;
13353
+ }
13354
+ /** The first block's index and the last's among the document's blocks. */
13355
+ get indices() {
13356
+ return { first: this.$from.index(0), last: this.$to.index(0) - 1 };
13357
+ }
13358
+ /** The block at the head's end: the one Shift-arrows grow or shrink from. */
13359
+ get headIndex() {
13360
+ const { first, last } = this.indices;
13361
+ return this.head > this.anchor ? last : first;
13362
+ }
13363
+ /** The block at the anchor's end, which stays while Shift-arrows move. */
13364
+ get anchorIndex() {
13365
+ const { first, last } = this.indices;
13366
+ return this.head > this.anchor ? first : last;
13367
+ }
13368
+ // Each end keeps to its blocks: a block inserted at a seam of the
13369
+ // selection lands outside it.
13370
+ map(doc, mapping) {
13371
+ return new BlockBookmark(this.anchor, this.head).map(mapping).resolve(doc);
13372
+ }
13373
+ eq(other) {
13374
+ return (other instanceof BlockSelection && other.anchor === this.anchor && other.head === this.head);
13375
+ }
13376
+ toJSON() {
13377
+ return { type: 'aeeBlocks', anchor: this.anchor, head: this.head };
13378
+ }
13379
+ static fromJSON(doc, json) {
13380
+ if (typeof json.anchor !== 'number' || typeof json.head !== 'number') {
13381
+ throw new RangeError('Invalid input for BlockSelection.fromJSON');
13382
+ }
13383
+ const selection = blocksOr(doc, json.anchor, json.head);
13384
+ if (!(selection instanceof BlockSelection)) {
13385
+ throw new RangeError('BlockSelection.fromJSON: not a run of top-level blocks');
13386
+ }
13387
+ return selection;
13388
+ }
13389
+ getBookmark() {
13390
+ return new BlockBookmark(this.anchor, this.head);
13391
+ }
13392
+ }
13393
+ // Drawn by the app, never by the browser (`ProseMirror-hideselection`).
13394
+ BlockSelection.prototype.visible = false;
13395
+ Selection.jsonID('aeeBlocks', BlockSelection);
13396
+ /** The one block above or below the head (ArrowUp / ArrowDown), as Notion
13397
+ walks a block selection; at the document's edge it stays. */
13398
+ const stepBlocks = (dir) => (state, dispatch) => {
13399
+ const { selection, doc } = state;
13400
+ if (!(selection instanceof BlockSelection))
13401
+ return false;
13402
+ const index = Math.min(Math.max(selection.headIndex + dir, 0), doc.childCount - 1);
13403
+ const next = BlockSelection.create(doc, blockStart(doc, index));
13404
+ if (!next.eq(selection))
13405
+ dispatch?.(state.tr.setSelection(next).scrollIntoView());
13406
+ return true;
13407
+ };
13408
+ /** The head moved by one block, the anchor kept (Shift-ArrowUp / -Down):
13409
+ grows the run, or shrinks it back toward the anchor. */
13410
+ const extendBlocks = (dir) => (state, dispatch) => {
13411
+ const { selection, doc } = state;
13412
+ if (!(selection instanceof BlockSelection))
13413
+ return false;
13414
+ const head = Math.min(Math.max(selection.headIndex + dir, 0), doc.childCount - 1);
13415
+ const next = BlockSelection.create(doc, blockStart(doc, selection.anchorIndex), blockStart(doc, head));
13416
+ if (!next.eq(selection))
13417
+ dispatch?.(state.tr.setSelection(next).scrollIntoView());
13418
+ return true;
13419
+ };
13420
+ /** The caret a block selection leaves to: at the start of the first block
13421
+ (`'start'`), the end of the last (`'end'`), or the head's end of its block
13422
+ (`'head'` — the block you were last on). */
13423
+ function caretOf(selection, to) {
13424
+ const doc = selection.$from.doc;
13425
+ const backward = selection.head < selection.anchor;
13426
+ const atStart = to === 'start' || (to === 'head' && backward);
13427
+ return atStart
13428
+ ? Selection.near(doc.resolve(selection.from), 1)
13429
+ : Selection.near(doc.resolve(selection.to), -1);
13430
+ }
13431
+ /**
13432
+ * Lets a block selection go — the caret at the head's end of its block, as
13433
+ * Escape leaves it, but not scrolled to: the press that let go is elsewhere.
13434
+ * Whether the view holds one or not, nothing else changes; `true` if it did.
13435
+ */
13436
+ function releaseBlockSelection(view) {
13437
+ const { selection } = view.state;
13438
+ if (!(selection instanceof BlockSelection))
13439
+ return false;
13440
+ view.dispatch(view.state.tr.setSelection(caretOf(selection, 'head')));
13441
+ return true;
13442
+ }
13443
+ /** Back to text (ArrowLeft / ArrowRight, Enter and Escape), scrolled to. */
13444
+ const leaveBlocks = (to) => (state, dispatch) => {
13445
+ const { selection } = state;
13446
+ if (!(selection instanceof BlockSelection))
13447
+ return false;
13448
+ dispatch?.(state.tr.setSelection(caretOf(selection, to)).scrollIntoView());
13449
+ return true;
13450
+ };
13451
+ /** The blocks removed — Backspace and Delete alike. */
13452
+ const deleteBlocks = (state, dispatch) => {
13453
+ if (!(state.selection instanceof BlockSelection))
13454
+ return false;
13455
+ dispatch?.(state.tr.deleteSelection().scrollIntoView());
13456
+ return true;
13457
+ };
13458
+ /** `Mod-a` again: the whole document, already selected as text, selected as
13459
+ blocks. The first `Mod-a` stays the text's own select-all. */
13460
+ const selectAllBlocks = (state, dispatch) => {
13461
+ if (!(state.selection instanceof AllSelection))
13462
+ return false;
13463
+ dispatch?.(state.tr.setSelection(BlockSelection.all(state.doc)));
13464
+ return true;
13465
+ };
13466
+ /** Input that would type into a block selection: typing does nothing to
13467
+ selected blocks (Notion's rule) — a paste or a drop replaces them. */
13468
+ const TYPING = /^insert(?!FromPaste|FromDrop)/;
13469
+ /**
13470
+ * What a {@link BlockSelection} does once there is one: every block in it
13471
+ * lit ({@link BLOCK_SELECTED_CLASS}), and Notion's keys — ArrowUp / ArrowDown
13472
+ * walk to the block above or below, Shift-ArrowUp / -Down grow or shrink the
13473
+ * run, ArrowLeft / ArrowRight leave to the caret at its start or end, Enter
13474
+ * and Escape to the caret in the head's block, Backspace / Delete remove the
13475
+ * blocks; a second `Mod-a` selects every block. Typing does nothing; a
13476
+ * paste replaces the blocks.
13477
+ *
13478
+ * The next press anywhere lets the blocks go, as Notion's do — in the editor
13479
+ * the press is ProseMirror's (or the marquee's) and replaces the selection
13480
+ * itself; outside it, a press that takes focus from the editor (another
13481
+ * field, a title bar, the page) leaves the caret at the head's block
13482
+ * ({@link releaseBlockSelection}). A control that keeps the editor focused —
13483
+ * a formatting toolbar acting on the selection — keeps the blocks. However
13484
+ * they go, the blocks let go wear {@link BLOCK_RELEASING_CLASS} while the
13485
+ * app's fade runs, so the wash eases out rather than vanishing.
13486
+ *
13487
+ * In the kits: the selection is reachable by keyboard alone. The pointer's
13488
+ * way in, the marquee, is the host's to opt into ({@link createBlockMarquee}),
13489
+ * since it needs a surface around the editor to start from.
13490
+ */
13491
+ const SelectedBlocks = defineExtension({
13492
+ name: 'selectedBlocks',
13493
+ keymap: () => ({
13494
+ ArrowUp: stepBlocks(-1),
13495
+ ArrowDown: stepBlocks(1),
13496
+ 'Shift-ArrowUp': extendBlocks(-1),
13497
+ 'Shift-ArrowDown': extendBlocks(1),
13498
+ ArrowLeft: leaveBlocks('start'),
13499
+ ArrowRight: leaveBlocks('end'),
13500
+ Enter: leaveBlocks('head'),
13501
+ Escape: leaveBlocks('head'),
13502
+ Backspace: deleteBlocks,
13503
+ Delete: deleteBlocks,
13504
+ 'Mod-a': selectAllBlocks,
13505
+ }),
13506
+ plugins: () => {
13507
+ const key = new PluginKey('selectedBlocks');
13508
+ let released = 0;
13509
+ return [
13510
+ new Plugin({
13511
+ key,
13512
+ // The blocks just let go, while their wash fades: the selection that
13513
+ // held them is gone, so they are kept here — mapped through every
13514
+ // change, dropped with any block deleted — until the fade is over.
13515
+ state: {
13516
+ init: () => null,
13517
+ apply(tr, releasing, before, after) {
13518
+ const done = tr.getMeta(key);
13519
+ if (after.selection instanceof BlockSelection)
13520
+ return null;
13521
+ if (before.selection instanceof BlockSelection) {
13522
+ const set = blockDecorations(before.doc, before.selection, BLOCK_RELEASING_CLASS);
13523
+ return { id: ++released, set: set.map(tr.mapping, tr.doc) };
13524
+ }
13525
+ if (!releasing || done === releasing.id)
13526
+ return null;
13527
+ return { id: releasing.id, set: releasing.set.map(tr.mapping, tr.doc) };
13528
+ },
13529
+ },
13530
+ view: (view) => {
13531
+ const doc = view.dom.ownerDocument;
13532
+ // A press outside the editor, while blocks are selected: once it
13533
+ // has done what it does (moved focus, or kept it), the blocks go
13534
+ // unless the editor still has focus.
13535
+ const press = (event) => {
13536
+ if (!(view.state.selection instanceof BlockSelection))
13537
+ return;
13538
+ if (view.dom.contains(event.target))
13539
+ return;
13540
+ setTimeout(() => {
13541
+ if (!view.isDestroyed && !view.hasFocus())
13542
+ releaseBlockSelection(view);
13543
+ });
13544
+ };
13545
+ doc.addEventListener('mousedown', press, true);
13546
+ // A release begins: the releasing class goes as soon as the
13547
+ // animations the host's stylesheet gave it end — at once, with
13548
+ // none (no fade, reduced motion, a browser without the API).
13549
+ let watched = 0;
13550
+ const fadeOut = (id) => {
13551
+ const animations = (doc.getAnimations?.() ?? []).filter((animation) => {
13552
+ const target = animation.effect?.target;
13553
+ return !!target && view.dom.contains(target) && target.classList.contains(BLOCK_RELEASING_CLASS);
13554
+ });
13555
+ void Promise.all(animations.map((animation) => animation.finished.catch(() => { }))).then(() => {
13556
+ if (!view.isDestroyed && key.getState(view.state)?.id === id) {
13557
+ view.dispatch(view.state.tr.setMeta(key, id).setMeta('addToHistory', false));
13558
+ }
13559
+ });
13560
+ };
13561
+ return {
13562
+ update: () => {
13563
+ const releasing = key.getState(view.state);
13564
+ if (!releasing || releasing.id === watched)
13565
+ return;
13566
+ watched = releasing.id;
13567
+ fadeOut(releasing.id);
13568
+ },
13569
+ destroy: () => doc.removeEventListener('mousedown', press, true),
13570
+ };
13571
+ },
13572
+ props: {
13573
+ decorations(state) {
13574
+ const { selection } = state;
13575
+ if (selection instanceof BlockSelection) {
13576
+ return blockDecorations(state.doc, selection, BLOCK_SELECTED_CLASS);
13577
+ }
13578
+ return key.getState(state)?.set ?? null;
13579
+ },
13580
+ handleTextInput: (view) => view.state.selection instanceof BlockSelection,
13581
+ handleDOMEvents: {
13582
+ beforeinput(view, event) {
13583
+ if (!(view.state.selection instanceof BlockSelection))
13584
+ return false;
13585
+ if (!TYPING.test(event.inputType))
13586
+ return false;
13587
+ event.preventDefault();
13588
+ return true;
13589
+ },
13590
+ },
13591
+ },
13592
+ }),
13593
+ ];
13594
+ },
13595
+ });
13596
+
13597
+ /**
13598
+ * Presses a gesture has taken for itself — the block marquee's — so the
13599
+ * editor's other press listeners (the inline atoms' drag) leave them alone.
13600
+ * Internal: not part of the library's API.
13601
+ */
13602
+ const claimed = new WeakSet();
13603
+ const claimPress = (event) => {
13604
+ claimed.add(event);
13605
+ };
13606
+ const isClaimedPress = (event) => claimed.has(event);
13607
+
12788
13608
  /** The class a text range puts on every inline atom it covers — an image,
12789
13609
  a button: the highlight the app draws, since the browser draws none. */
12790
13610
  const IN_SELECTION_CLASS = 'aee-atom--in-selection';
@@ -12976,6 +13796,9 @@ const InlineAtomSelection = defineExtension({
12976
13796
  const down = (event) => {
12977
13797
  if (!view.editable || event.button !== 0 || event.shiftKey || event.detail > 1)
12978
13798
  return;
13799
+ // The block marquee's press: a box, not a range.
13800
+ if (isClaimedPress(event))
13801
+ return;
12979
13802
  const atom = atomAt(view, event.target);
12980
13803
  const { selection } = view.state;
12981
13804
  // The selected atom's press is ProseMirror's: its drag moves it.
@@ -13064,8 +13887,12 @@ const InlineAtomSelection = defineExtension({
13064
13887
  },
13065
13888
  decorations(state) {
13066
13889
  const { selection } = state;
13067
- if (selection.empty || selection instanceof NodeSelection)
13890
+ // Selected blocks are lit whole (`SelectedBlocks`), atoms and all.
13891
+ if (selection.empty ||
13892
+ selection instanceof NodeSelection ||
13893
+ selection instanceof BlockSelection) {
13068
13894
  return null;
13895
+ }
13069
13896
  const decorations = [];
13070
13897
  state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
13071
13898
  if (isInlineAtom(node)) {
@@ -13526,7 +14353,7 @@ const ColumnResize = defineExtension({
13526
14353
  }),
13527
14354
  ],
13528
14355
  });
13529
- const clamp$1 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
14356
+ const clamp$2 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
13530
14357
  const round1 = (n) => Math.round(n * 10) / 10;
13531
14358
  /** The declared width of each grid column (`null` = unspecified, shares the
13532
14359
  leftover) — read from the first cell in the column that declares one. */
@@ -13587,7 +14414,7 @@ function setColumnBoundary(tablePos, boundary, leftPct) {
13587
14414
  const pair = effective[boundary] + effective[boundary + 1];
13588
14415
  if (pair < MIN_COLUMN_PCT * 2)
13589
14416
  return false;
13590
- const left = round1(clamp$1(leftPct, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT));
14417
+ const left = round1(clamp$2(leftPct, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT));
13591
14418
  const right = round1(pair - left);
13592
14419
  if (dispatch) {
13593
14420
  const tr = state.tr;
@@ -13640,15 +14467,15 @@ function setTableBox(tablePos, offsetPct, widthPct, absorb) {
13640
14467
  return false;
13641
14468
  const map = TableMap.get(table);
13642
14469
  const oldWidth = table.attrs['width'];
13643
- const offset = round1(clamp$1(offsetPct, 0, 100 - MIN_TABLE_PCT));
13644
- let width = round1(clamp$1(widthPct, MIN_TABLE_PCT, 100 - offset));
14470
+ const offset = round1(clamp$2(offsetPct, 0, 100 - MIN_TABLE_PCT));
14471
+ let width = round1(clamp$2(widthPct, MIN_TABLE_PCT, 100 - offset));
13645
14472
  if (dispatch) {
13646
14473
  const tr = state.tr;
13647
14474
  if (absorb && map.width > 1) {
13648
14475
  const effective = effectiveWidths(table);
13649
14476
  const absorbIndex = absorb === 'first' ? 0 : map.width - 1;
13650
14477
  const others = 100 - effective[absorbIndex];
13651
- width = round1(clamp$1(width, minTableWidth(oldWidth, others), 100 - offset));
14478
+ width = round1(clamp$2(width, minTableWidth(oldWidth, others), 100 - offset));
13652
14479
  const factor = oldWidth / width;
13653
14480
  const pcts = effective.map((pct, index) => index === absorbIndex ? 0 : round1(pct * factor));
13654
14481
  pcts[absorbIndex] = round1(100 - pcts.reduce((sum, pct) => sum + pct, 0));
@@ -14023,7 +14850,7 @@ class TableView {
14023
14850
  return;
14024
14851
  const startX = event.clientX;
14025
14852
  const startLeft = effective.slice(0, boundary + 1).reduce((sum, width) => sum + width, 0);
14026
- const leftAt = (ev) => clamp$1(effective[boundary] + ((ev.clientX - startX) / tableWidth) * 100, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT);
14853
+ const leftAt = (ev) => clamp$2(effective[boundary] + ((ev.clientX - startX) / tableWidth) * 100, MIN_COLUMN_PCT, pair - MIN_COLUMN_PCT);
14027
14854
  // Deferred commit: mid-drag ONLY the guide line moves, at full float
14028
14855
  // precision. The table itself must not reflow per pointermove — its text
14029
14856
  // rewrapping every frame reads as the grid flying apart (tried, reverted).
@@ -14079,8 +14906,8 @@ class TableView {
14079
14906
  const minWidth = minTableWidth(oldWidth, others);
14080
14907
  const pctAt = (ev) => ((ev.clientX - wrapperLeft) / wrapperWidth) * 100;
14081
14908
  const edgeAt = (ev) => side === 'left'
14082
- ? clamp$1(pctAt(ev), 0, right - minWidth)
14083
- : clamp$1(pctAt(ev), offset + minWidth, 100);
14909
+ ? clamp$2(pctAt(ev), 0, right - minWidth)
14910
+ : clamp$2(pctAt(ev), offset + minWidth, 100);
14084
14911
  const preview = (ev) => {
14085
14912
  const edge = edgeAt(ev);
14086
14913
  handle.style.left = `${edge}%`;
@@ -14230,7 +15057,7 @@ const ColumnsResize = defineExtension({
14230
15057
  }),
14231
15058
  ],
14232
15059
  });
14233
- const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
15060
+ const clamp$1 = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
14234
15061
  /**
14235
15062
  * `div.aee-columns-wrap (editorial gutter) > div.aee-columns-box (the email's
14236
15063
  * container style) > div.aee-columns (contentDOM) + div.aee-col-lines`.
@@ -14353,7 +15180,7 @@ class ColumnsView {
14353
15180
  const startX = event.clientX;
14354
15181
  const startLeft = caps.slice(0, boundary + 1).reduce((sum, cap) => sum + cap, 0);
14355
15182
  // 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);
15183
+ const leftAt = (ev) => clamp$1(caps[boundary] + (ev.clientX - startX), MIN_COLUMN_CAP, pair - MIN_COLUMN_CAP);
14357
15184
  const preview = (ev) => {
14358
15185
  line.style.left = `${startLeft - caps[boundary] + leftAt(ev)}px`;
14359
15186
  };
@@ -15082,6 +15909,10 @@ const withParagraph = (paragraph) => [
15082
15909
  Button,
15083
15910
  // Images and buttons selected like characters: a drag covers them.
15084
15911
  InlineAtomSelection,
15912
+ // Whole blocks selected as blocks (Notion's): lit, and walked by its
15913
+ // keys. Before the tables and gap-cursor, whose arrows it outranks while
15914
+ // there is one.
15915
+ SelectedBlocks,
15085
15916
  Table,
15086
15917
  TableRow,
15087
15918
  TableCell,
@@ -15138,6 +15969,29 @@ const emailExtensions = [
15138
15969
  // language swaps it for its own `createQuotedHistory({ labels })`.
15139
15970
  QuotedHistory,
15140
15971
  ];
15972
+ /**
15973
+ * Plain text — a mail client's plain-text mode: the message as lines and
15974
+ * nothing else. No marks, no blocks, no images, no tokens in the schema, so
15975
+ * neither a shortcut (Mod-B has nothing to toggle) nor a paste (HTML is
15976
+ * read down to its text) can bring formatting in. Lines are the email
15977
+ * kit's `<div>` lines, Shift-Enter a `<br>` inside one, so what the editor
15978
+ * publishes is still email HTML — `emailPlainText` reads it back as exactly
15979
+ * the text written, the `text/plain` a plain-text message sends. The
15980
+ * quoted history is kept beside it, as in the email kit.
15981
+ *
15982
+ * A host switches a message between the two by mounting a new editor on
15983
+ * the other kit (a schema is fixed for an editor's life) with the content
15984
+ * carried over: `plainTextHTML(emailPlainText(html))` on the way in.
15985
+ */
15986
+ const plainTextExtensions = [
15987
+ Document$1,
15988
+ EmailParagraph,
15989
+ Text,
15990
+ HardBreak,
15991
+ History,
15992
+ QuotedHistory,
15993
+ BaseKeymap,
15994
+ ];
15141
15995
  /**
15142
15996
  * The parallel source-editor kit: the document is HTML source text, one
15143
15997
  * `codeLine` per line, with highlighting, linting and formatting instead of
@@ -15736,6 +16590,372 @@ function createAutocompletePlugin(options) {
15736
16590
  });
15737
16591
  }
15738
16592
 
16593
+ /** The class of the box a drag draws — the host paints it, in the same wash
16594
+ as the blocks it lights ({@link BLOCK_SELECTED_CLASS}). */
16595
+ const BLOCK_MARQUEE_CLASS = 'aee-marquee';
16596
+ /** What a press on the surface may not start: its own controls. */
16597
+ const INTERACTIVE = 'button, a[href], input, textarea, select, label, summary, [role="button"], [contenteditable]';
16598
+ /** How close to the scroller's edge, in px, a held drag scrolls it — and
16599
+ the fastest it goes, in px per frame, at (or past) the edge. */
16600
+ const EDGE = 40;
16601
+ const MAX_SPEED = 20;
16602
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
16603
+ /** The client box of what an element shows of its content: inside its
16604
+ borders, without its scrollbars. */
16605
+ function viewport(element) {
16606
+ const rect = element.getBoundingClientRect();
16607
+ const left = rect.left + element.clientLeft;
16608
+ const top = rect.top + element.clientTop;
16609
+ return { left, top, right: left + element.clientWidth, bottom: top + element.clientHeight };
16610
+ }
16611
+ /** Whether a press landed on the element's scrollbar (outside its client
16612
+ box but inside its border box). */
16613
+ function onScrollbar(element, event) {
16614
+ const inner = viewport(element);
16615
+ return event.clientX >= inner.right || event.clientY >= inner.bottom;
16616
+ }
16617
+ /** The nearest ancestor (or the element) that scrolls vertically. */
16618
+ function scrollerOf(element) {
16619
+ for (let node = element; node; node = node.parentElement) {
16620
+ const { overflowY } = getComputedStyle(node);
16621
+ if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
16622
+ return node;
16623
+ }
16624
+ }
16625
+ return null;
16626
+ }
16627
+ /** How far `scroller` (or, with none, the window) has scrolled. */
16628
+ function scrollOf(scroller) {
16629
+ return scroller
16630
+ ? { x: scroller.scrollLeft, y: scroller.scrollTop }
16631
+ : { x: window.scrollX, y: window.scrollY };
16632
+ }
16633
+ /** Each marquee editor's start, for {@link beginBlockMarquee}. */
16634
+ const starts = new WeakMap();
16635
+ /**
16636
+ * Notion's marquee: a drag from the empty room around the text draws a box,
16637
+ * and every top-level block it touches is selected — a {@link BlockSelection},
16638
+ * lit block by block in the box's own wash. Opt-in, with
16639
+ * {@link SelectedBlocks} in the kit (which this needs for the keys and the
16640
+ * lit blocks).
16641
+ *
16642
+ * **Where it starts.** A press on the surface outside the editor (its
16643
+ * margin), or on the editor's own box between and below its blocks — never
16644
+ * on a block: a press on text is the text's, and a drag across lines stays a
16645
+ * text selection, as email users expect. Nor on a control of the surface's,
16646
+ * a scrollbar, or with a modifier held. The press is kept from the browser
16647
+ * and ProseMirror alike (no caret jump, no native selection), so a press
16648
+ * released in place is placed here: the caret at the nearest spot in the
16649
+ * text, as a click in the margin of a page puts it in Google Docs.
16650
+ *
16651
+ * **While it drags.** The box is a `div` ({@link BLOCK_MARQUEE_CLASS}) in the
16652
+ * canvas — the surface, unless the host gives a larger one — against the
16653
+ * pointer, clipped to the canvas's viewport, so it may reach over whatever
16654
+ * frames the editor; held near the scroller's edge (or past it) it scrolls,
16655
+ * and the box keeps its origin in the content. Each move hit-tests the
16656
+ * top-level blocks' boxes (the one measured thing — a box drawn by a pointer
16657
+ * has no other geometry) against what the box covers of the surface, and
16658
+ * selects the run it touches; touching none, the selection the press found
16659
+ * comes back. Only blocks are ever selected, wherever the box reaches.
16660
+ * Escape cancels the drag.
16661
+ */
16662
+ const createBlockMarquee = (options = {}) => {
16663
+ const threshold = options.threshold ?? 4;
16664
+ return defineExtension({
16665
+ name: 'blockMarquee',
16666
+ plugins: () => {
16667
+ // The press handler of this editor's view (one per `plugins` call).
16668
+ let begin = null;
16669
+ return [
16670
+ new Plugin({
16671
+ key: new PluginKey('blockMarquee'),
16672
+ view: (view) => {
16673
+ const surface = options.surface?.(view) ?? view.dom.parentElement ?? view.dom;
16674
+ const canvas = options.canvas?.(view) ?? surface;
16675
+ let gesture = null;
16676
+ /** The containing block the box is positioned against, and how
16677
+ its frame (the content's, scrolled) maps from the client. */
16678
+ const frame = (box) => {
16679
+ const host = box.offsetParent;
16680
+ if (!host || host === document.body || host === document.documentElement) {
16681
+ return { x: window.scrollX, y: window.scrollY };
16682
+ }
16683
+ const rect = host.getBoundingClientRect();
16684
+ return {
16685
+ x: host.scrollLeft - rect.left - host.clientLeft,
16686
+ y: host.scrollTop - rect.top - host.clientTop,
16687
+ };
16688
+ };
16689
+ const update = (current) => {
16690
+ const box = current.box;
16691
+ if (!box || view.isDestroyed)
16692
+ return;
16693
+ // The press, where the content has carried it since.
16694
+ const scroll = scrollOf(current.scroller);
16695
+ const origin = {
16696
+ x: current.pressed.x - (scroll.x - current.scrolledFrom.x),
16697
+ y: current.pressed.y - (scroll.y - current.scrolledFrom.y),
16698
+ };
16699
+ /** The box from the press to the pointer, clipped to `clip`. */
16700
+ const span = (clip) => {
16701
+ const pointer = {
16702
+ x: clamp(current.pointer.x, clip.left, clip.right),
16703
+ y: clamp(current.pointer.y, clip.top, clip.bottom),
16704
+ };
16705
+ return {
16706
+ pointer,
16707
+ left: Math.min(origin.x, pointer.x),
16708
+ top: Math.min(origin.y, pointer.y),
16709
+ right: Math.max(origin.x, pointer.x),
16710
+ bottom: Math.max(origin.y, pointer.y),
16711
+ };
16712
+ };
16713
+ // Drawn on the canvas, as far as the pointer goes on it…
16714
+ const drawn = span(viewport(canvas));
16715
+ const offset = frame(box);
16716
+ box.style.left = `${drawn.left + offset.x}px`;
16717
+ box.style.top = `${drawn.top + offset.y}px`;
16718
+ box.style.width = `${drawn.right - drawn.left}px`;
16719
+ box.style.height = `${drawn.bottom - drawn.top}px`;
16720
+ // …but it selects what it covers of the surface: the blocks
16721
+ // the editor shows. Past the surface's edge, the scroll brings
16722
+ // the rest in (`autoScroll`).
16723
+ const area = span(viewport(surface));
16724
+ const { pointer } = area;
16725
+ const { doc } = view.state;
16726
+ const hits = [];
16727
+ doc.forEach((_node, pos) => {
16728
+ const dom = view.nodeDOM(pos);
16729
+ if (!(dom instanceof Element))
16730
+ return;
16731
+ const rect = dom.getBoundingClientRect();
16732
+ if (rect.bottom > area.top &&
16733
+ rect.top < area.bottom &&
16734
+ rect.right > area.left &&
16735
+ rect.left < area.right) {
16736
+ hits.push(pos);
16737
+ }
16738
+ });
16739
+ let next = current.before;
16740
+ if (hits.length) {
16741
+ // Dragged upward, the head is the top block: Shift-arrows
16742
+ // then move the end the pointer was at.
16743
+ const upward = pointer.y < origin.y;
16744
+ const first = hits[0];
16745
+ const last = hits[hits.length - 1];
16746
+ next = upward
16747
+ ? BlockSelection.create(doc, last, first)
16748
+ : BlockSelection.create(doc, first, last);
16749
+ }
16750
+ else if (current.before.$anchor.doc !== doc) {
16751
+ return;
16752
+ }
16753
+ if (!next.eq(view.state.selection))
16754
+ view.dispatch(view.state.tr.setSelection(next));
16755
+ };
16756
+ /** Scrolls the scroller while the pointer is held at its edge,
16757
+ one step a frame, then hit-tests again. */
16758
+ const autoScroll = (current) => {
16759
+ current.frameRequest = null;
16760
+ const scroller = current.scroller;
16761
+ if (!scroller || gesture !== current || !current.moved)
16762
+ return;
16763
+ const edges = viewport(scroller);
16764
+ const y = current.pointer.y;
16765
+ let step = 0;
16766
+ if (y < edges.top + EDGE)
16767
+ step = -Math.min(MAX_SPEED, (edges.top + EDGE - y) / 2);
16768
+ else if (y > edges.bottom - EDGE) {
16769
+ step = Math.min(MAX_SPEED, (y - (edges.bottom - EDGE)) / 2);
16770
+ }
16771
+ if (!step)
16772
+ return;
16773
+ const before = scroller.scrollTop;
16774
+ scroller.scrollTop += step;
16775
+ if (scroller.scrollTop === before)
16776
+ return;
16777
+ update(current);
16778
+ current.frameRequest = requestAnimationFrame(() => autoScroll(current));
16779
+ };
16780
+ const start = (event, elsewhere) => {
16781
+ if (gesture || !view.editable)
16782
+ return false;
16783
+ if (event.button !== 0 || event.detail > 1)
16784
+ return false;
16785
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
16786
+ return false;
16787
+ // The surface's scrollbar is the scroller's; a start point
16788
+ // elsewhere is the host's to judge.
16789
+ if (!elsewhere && onScrollbar(surface, event))
16790
+ return false;
16791
+ // No caret jump, no native selection, no focus leaving: the
16792
+ // press is the box's (or, released in place, placed below).
16793
+ event.preventDefault();
16794
+ claimPress(event);
16795
+ const pressed = { x: event.clientX, y: event.clientY };
16796
+ const scroller = scrollerOf(surface);
16797
+ const current = {
16798
+ pressed,
16799
+ scrolledFrom: scrollOf(scroller),
16800
+ pointer: pressed,
16801
+ moved: false,
16802
+ box: null,
16803
+ before: view.state.selection,
16804
+ scroller,
16805
+ frameRequest: null,
16806
+ stop: () => { },
16807
+ };
16808
+ const move = (moved) => {
16809
+ if (view.isDestroyed)
16810
+ return finish();
16811
+ current.pointer = { x: moved.clientX, y: moved.clientY };
16812
+ if (!current.moved) {
16813
+ if (Math.abs(current.pointer.x - pressed.x) < threshold &&
16814
+ Math.abs(current.pointer.y - pressed.y) < threshold) {
16815
+ return;
16816
+ }
16817
+ current.moved = true;
16818
+ const box = document.createElement('div');
16819
+ box.className = BLOCK_MARQUEE_CLASS;
16820
+ box.setAttribute('aria-hidden', 'true');
16821
+ box.style.position = 'absolute';
16822
+ box.style.pointerEvents = 'none';
16823
+ // The editor's scheme, wherever the box is drawn: the
16824
+ // canvas may wear another (an app theme round a light
16825
+ // paper), and the wash is a light-dark() pair.
16826
+ box.style.colorScheme = getComputedStyle(view.dom).colorScheme;
16827
+ canvas.appendChild(box);
16828
+ current.box = box;
16829
+ // The keys (Delete, Shift-arrows) go to the selection.
16830
+ view.focus();
16831
+ }
16832
+ update(current);
16833
+ if (current.frameRequest === null) {
16834
+ current.frameRequest = requestAnimationFrame(() => autoScroll(current));
16835
+ }
16836
+ };
16837
+ const scrolled = () => update(current);
16838
+ const up = () => {
16839
+ finish();
16840
+ // A click: the caret at the nearest spot in the text — the
16841
+ // point brought onto the editor's box when it fell outside.
16842
+ // Not from a start point elsewhere: a click beside a form is
16843
+ // no click in the text — it only lets selected blocks go,
16844
+ // as a press anywhere does (`SelectedBlocks`; this one kept
16845
+ // the editor's focus, so it is let go here).
16846
+ if (view.isDestroyed || current.moved)
16847
+ return;
16848
+ if (elsewhere) {
16849
+ releaseBlockSelection(view);
16850
+ return;
16851
+ }
16852
+ const rect = view.dom.getBoundingClientRect();
16853
+ const found = view.posAtCoords({
16854
+ left: clamp(pressed.x, rect.left + 1, rect.right - 1),
16855
+ top: clamp(pressed.y, rect.top + 1, rect.bottom - 1),
16856
+ });
16857
+ const { doc } = view.state;
16858
+ const caret = found
16859
+ ? Selection.near(doc.resolve(found.pos))
16860
+ : pressed.y < rect.top
16861
+ ? Selection.atStart(doc)
16862
+ : Selection.atEnd(doc);
16863
+ view.focus();
16864
+ if (!caret.eq(view.state.selection)) {
16865
+ view.dispatch(view.state.tr.setSelection(caret));
16866
+ }
16867
+ };
16868
+ const key = (pressedKey) => {
16869
+ if (pressedKey.key !== 'Escape')
16870
+ return;
16871
+ pressedKey.preventDefault();
16872
+ pressedKey.stopPropagation();
16873
+ finish();
16874
+ if (!view.isDestroyed && !current.before.eq(view.state.selection)) {
16875
+ if (current.before.$anchor.doc === view.state.doc) {
16876
+ view.dispatch(view.state.tr.setSelection(current.before));
16877
+ }
16878
+ }
16879
+ };
16880
+ const finish = () => {
16881
+ window.removeEventListener('mousemove', move);
16882
+ window.removeEventListener('mouseup', up);
16883
+ window.removeEventListener('keydown', key, true);
16884
+ window.removeEventListener('scroll', scrolled, true);
16885
+ if (current.frameRequest !== null)
16886
+ cancelAnimationFrame(current.frameRequest);
16887
+ current.frameRequest = null;
16888
+ current.box?.remove();
16889
+ current.box = null;
16890
+ if (gesture === current)
16891
+ gesture = null;
16892
+ };
16893
+ current.stop = finish;
16894
+ window.addEventListener('mousemove', move);
16895
+ window.addEventListener('mouseup', up);
16896
+ window.addEventListener('keydown', key, true);
16897
+ // Any scroll under the drag (the wheel, the autoscroll) moves
16898
+ // the content under a still pointer: the box follows.
16899
+ window.addEventListener('scroll', scrolled, true);
16900
+ gesture = current;
16901
+ return true;
16902
+ };
16903
+ // A press outside the editor: the surface's. The editor's own
16904
+ // presses come through `handleDOMEvents`, ahead of ProseMirror's.
16905
+ const outside = (event) => {
16906
+ const target = event.target;
16907
+ if (!target || view.dom.contains(target))
16908
+ return;
16909
+ const control = target.closest(INTERACTIVE);
16910
+ if (control && surface.contains(control))
16911
+ return;
16912
+ start(event, false);
16913
+ };
16914
+ begin = start;
16915
+ starts.set(view, start);
16916
+ surface.addEventListener('mousedown', outside);
16917
+ return {
16918
+ destroy() {
16919
+ surface.removeEventListener('mousedown', outside);
16920
+ gesture?.stop();
16921
+ starts.delete(view);
16922
+ begin = null;
16923
+ },
16924
+ };
16925
+ },
16926
+ props: {
16927
+ handleDOMEvents: {
16928
+ // The editor's own box — the room between and below its
16929
+ // blocks, never a block — starts the box. Returning true keeps
16930
+ // ProseMirror's own press handling (the caret) out of it.
16931
+ mousedown: (view, event) => event.target === view.dom && !!begin?.(event, false),
16932
+ },
16933
+ },
16934
+ }),
16935
+ ];
16936
+ },
16937
+ });
16938
+ };
16939
+ /**
16940
+ * Starts the editor's marquee from a press anywhere else — a start point of
16941
+ * the host's own: the room beside a page's text column, the empty room round
16942
+ * a form — as Notion lets a drag start in a page's margins. The host decides
16943
+ * the press is one (its own empty room, not a control); from there it is the
16944
+ * surface's gesture in every way: the box on the canvas, the blocks it
16945
+ * covers of the surface, the scroll, Escape. Released in place it puts no
16946
+ * caret on a line (only the editor's own room does) — it only lets selected
16947
+ * blocks go, as any press outside the editor does.
16948
+ *
16949
+ * Call it from the start point's `mousedown`. It returns whether the box
16950
+ * started (and then took the press: `preventDefault`); `false` without
16951
+ * {@link createBlockMarquee} in the editor, while one is already drawn, in a
16952
+ * read-only editor, and for a press that is not a plain primary one (another
16953
+ * button, a double click, a modifier held).
16954
+ */
16955
+ function beginBlockMarquee(view, event) {
16956
+ return starts.get(view)?.(event, true) ?? false;
16957
+ }
16958
+
15739
16959
  /** Starts watching for {@link MenuScroll}; returns what stops it. */
15740
16960
  function watchMenuScroll(view, scroll, menu) {
15741
16961
  const mode = scroll ?? 'close';
@@ -18793,6 +20013,267 @@ const createSendIntent = (options) => {
18793
20013
  });
18794
20014
  };
18795
20015
 
20016
+ const placeholderKey = new PluginKey('placeholder');
20017
+ /** Replaces the placeholder's words — every empty line redraws at once.
20018
+ Nothing enters the undo history. */
20019
+ const setPlaceholder = (text) => (state, dispatch) => {
20020
+ if (!placeholderKey.get(state))
20021
+ return false;
20022
+ dispatch?.(state.tr.setMeta(placeholderKey, { text }).setMeta('addToHistory', false));
20023
+ return true;
20024
+ };
20025
+ const isBare = (node) => !!node && node.isTextblock && !node.type.spec.code && node.content.size === 0;
20026
+ /** Whether the document is a single empty line. */
20027
+ const isDocEmpty = (doc) => doc.childCount === 1 && isBare(doc.firstChild);
20028
+ /** An image waiting for its file. */
20029
+ const isEmptyImage = (node) => node.type.name === 'image' && !node.attrs['src'];
20030
+ /** Containers that give the lines in them a kind: a change inside one may
20031
+ re-kind a line it never touched (a line pushed off a list item's head). */
20032
+ const KIND_CONTAINERS = new Set(['blockquote', 'listItem', 'bulletList', 'orderedList']);
20033
+ /** What the line at `$pos` (resolved just before it) is. */
20034
+ const kindAt = (node, $pos) => {
20035
+ const name = node.type.name;
20036
+ if (name === 'heading' || name === 'tableCell')
20037
+ return name;
20038
+ const parent = $pos.parent.type.name;
20039
+ if (parent === 'blockquote')
20040
+ return 'quote';
20041
+ if (parent === 'listItem' && $pos.index() === 0 && $pos.depth > 0) {
20042
+ const list = $pos.node($pos.depth - 1).type.name;
20043
+ if (list === 'bulletList' || list === 'orderedList')
20044
+ return list;
20045
+ }
20046
+ return 'line';
20047
+ };
20048
+ const kindWords = (words, line) => {
20049
+ if (line.kind === 'line')
20050
+ return undefined;
20051
+ if (line.kind !== 'heading')
20052
+ return words[line.kind];
20053
+ const heading = words.heading;
20054
+ if (typeof heading === 'string' || heading === undefined)
20055
+ return heading;
20056
+ const level = line.node.attrs['level'] ?? 1;
20057
+ return heading[Math.min(level, heading.length) - 1];
20058
+ };
20059
+ const lineWords = (text, line, show) => {
20060
+ let always = '';
20061
+ let caret = '';
20062
+ if (typeof text === 'string') {
20063
+ caret = text;
20064
+ }
20065
+ else if (typeof text === 'function') {
20066
+ const words = text(line) ?? '';
20067
+ if (line.kind === 'line')
20068
+ caret = words;
20069
+ else
20070
+ always = words;
20071
+ }
20072
+ else if (text) {
20073
+ const own = kindWords(text, line);
20074
+ if (own !== undefined)
20075
+ always = own;
20076
+ else
20077
+ caret = (line.docEmpty ? text.empty : undefined) ?? text.line ?? '';
20078
+ }
20079
+ if (!always && (show === 'all' || line.docEmpty))
20080
+ [always, caret] = [caret, ''];
20081
+ return { always, caret };
20082
+ };
20083
+ const lineDecoration = (node, pos, words, docEmpty) => Decoration.node(pos, pos + node.nodeSize, {
20084
+ class: docEmpty ? 'aee-placeholder aee-placeholder-doc' : 'aee-placeholder',
20085
+ 'data-placeholder': words,
20086
+ },
20087
+ // The words again, for `aria-placeholder`; marks it a line's.
20088
+ { line: words });
20089
+ /** The decorations that show without the caret, for the nodes overlapping
20090
+ `from`–`to` (the whole document without them). */
20091
+ const scan = (state, text, show, from = 0, to = state.doc.content.size) => {
20092
+ const { doc } = state;
20093
+ const decorations = [];
20094
+ const docEmpty = isDocEmpty(doc);
20095
+ const image = text && typeof text === 'object' ? text.image : undefined;
20096
+ doc.nodesBetween(from, to, (node, pos) => {
20097
+ if (image && isEmptyImage(node)) {
20098
+ // The frame's words, for its node view to read off the decoration.
20099
+ decorations.push(Decoration.node(pos, pos + node.nodeSize, {}, { placeholder: image }));
20100
+ return false;
20101
+ }
20102
+ if (!node.isTextblock)
20103
+ return true;
20104
+ if (!isBare(node))
20105
+ return !!image;
20106
+ const kind = kindAt(node, doc.resolve(pos));
20107
+ const { always } = lineWords(text, { node, pos, kind, docEmpty, state }, show);
20108
+ if (always)
20109
+ decorations.push(lineDecoration(node, pos, always, docEmpty));
20110
+ return false;
20111
+ });
20112
+ return decorations;
20113
+ };
20114
+ /** Where `tr` changed its document, in the new document's positions. */
20115
+ const changedRanges = (tr) => {
20116
+ let ranges = [];
20117
+ for (const map of tr.mapping.maps) {
20118
+ ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]);
20119
+ map.forEach((_oldStart, _oldEnd, newStart, newEnd) => ranges.push([newStart, newEnd]));
20120
+ }
20121
+ return ranges;
20122
+ };
20123
+ /** A changed range grown to every line whose kind it may have changed: the
20124
+ nodes it opens or closes (a list turned numbered changes only its
20125
+ boundaries), and the innermost kind-giving container around it. */
20126
+ const widen = (doc, from, to) => {
20127
+ const after = doc.nodeAt(from);
20128
+ if (after && !after.isText)
20129
+ to = Math.max(to, from + after.nodeSize);
20130
+ const before = doc.resolve(to).nodeBefore;
20131
+ if (before && !before.isText)
20132
+ from = Math.min(from, to - before.nodeSize);
20133
+ const $from = doc.resolve(from);
20134
+ for (let depth = $from.sharedDepth(to); depth > 0; depth--) {
20135
+ if (KIND_CONTAINERS.has($from.node(depth).type.name)) {
20136
+ return [Math.min(from, $from.before(depth)), Math.max(to, $from.after(depth))];
20137
+ }
20138
+ }
20139
+ return [from, to];
20140
+ };
20141
+ /**
20142
+ * A placeholder on empty lines, Notion's way: the line the caret is on says
20143
+ * what can be done there ("Type / for commands") and the hint goes as soon as
20144
+ * a character is typed or the caret moves on, while an empty block of a kind
20145
+ * says what it is ("Heading 1", "List", "Empty quote") wherever the caret is.
20146
+ * Nothing of it is in the document — a node decoration puts a class and the
20147
+ * words on the line:
20148
+ *
20149
+ * <div class="aee-placeholder" data-placeholder="Type / for commands">
20150
+ *
20151
+ * and the host's stylesheet paints them (`content: attr(data-placeholder)`),
20152
+ * so the look stays the host's. An empty body is also marked with
20153
+ * `aee-placeholder-doc`, and the editable element carries the words as
20154
+ * `aria-placeholder`, so a screen reader hears what the field is for. An
20155
+ * image waiting for its file gets its words through a node decoration whose
20156
+ * spec carries `placeholder`, which the image's node view puts in its frame.
20157
+ *
20158
+ * The words are the host's, in its language — the library words nothing.
20159
+ * Hand them in as {@link PlaceholderOptions.text} and replace them with the
20160
+ * `setPlaceholder` command whenever they change:
20161
+ *
20162
+ * effect(() => editor.commands.setPlaceholder(this.placeholders()));
20163
+ *
20164
+ * Typing costs only the lines it touched: the decorations live in the
20165
+ * plugin's state, mapped through each change and redone where it landed;
20166
+ * new words redo them once.
20167
+ *
20168
+ * Code lines never show it: an empty line of source is just that. Neither
20169
+ * does a read-only editor (an image frame keeps its words), nor the line
20170
+ * mid-composition (an IME's text is not in the document yet).
20171
+ */
20172
+ const createPlaceholder = (options = {}) => {
20173
+ const show = options.show ?? 'caret';
20174
+ return defineExtension({
20175
+ name: 'placeholder',
20176
+ commands: () => ({ setPlaceholder }),
20177
+ plugins: () => {
20178
+ let view;
20179
+ /** A redraw: focus is not in the state, so its change is no transaction. */
20180
+ const redraw = () => {
20181
+ if (view)
20182
+ view.dispatch(view.state.tr.setMeta('addToHistory', false));
20183
+ return false;
20184
+ };
20185
+ return [
20186
+ new Plugin({
20187
+ key: placeholderKey,
20188
+ state: {
20189
+ init: (_config, state) => ({
20190
+ text: options.text,
20191
+ set: DecorationSet.create(state.doc, scan(state, options.text, show)),
20192
+ }),
20193
+ apply: (tr, value, oldState, state) => {
20194
+ const meta = tr.getMeta(placeholderKey);
20195
+ if (meta) {
20196
+ return {
20197
+ text: meta.text,
20198
+ set: DecorationSet.create(tr.doc, scan(state, meta.text, show)),
20199
+ };
20200
+ }
20201
+ if (!tr.docChanged)
20202
+ return value;
20203
+ // Into or out of an empty body every line's class changes: redo all.
20204
+ if (isDocEmpty(tr.doc) || isDocEmpty(oldState.doc)) {
20205
+ return {
20206
+ ...value,
20207
+ set: DecorationSet.create(tr.doc, scan(state, value.text, show)),
20208
+ };
20209
+ }
20210
+ let set = value.set.map(tr.mapping, tr.doc);
20211
+ for (const range of changedRanges(tr)) {
20212
+ const [from, to] = widen(tr.doc, ...range);
20213
+ const stale = set.find(from, to).filter((d) => d.from < to && d.to > from);
20214
+ set = set.remove(stale).add(tr.doc, scan(state, value.text, show, from, to));
20215
+ }
20216
+ return { ...value, set };
20217
+ },
20218
+ },
20219
+ props: {
20220
+ decorations(state) {
20221
+ const plugin = placeholderKey.getState(state);
20222
+ if (!plugin)
20223
+ return null;
20224
+ const { doc, selection } = state;
20225
+ if (view && !view.editable) {
20226
+ const frames = plugin.set.find(undefined, undefined, (spec) => !spec['line']);
20227
+ return frames.length ? DecorationSet.create(doc, frames) : null;
20228
+ }
20229
+ const { $from } = selection;
20230
+ const line = $from.parent;
20231
+ if (!isBare(line))
20232
+ return plugin.set;
20233
+ const pos = $from.before();
20234
+ // `find` also hands back the line before, which ends where this starts.
20235
+ const own = plugin.set
20236
+ .find(pos, pos + line.nodeSize, (spec) => !!spec['line'])
20237
+ .filter((decoration) => decoration.from === pos);
20238
+ // Mid-composition the caret's line is not empty, only not yet
20239
+ // in the document.
20240
+ if (view?.composing)
20241
+ return own.length ? plugin.set.remove(own) : plugin.set;
20242
+ if (own.length || show === 'all' || !selection.empty || !view?.hasFocus()) {
20243
+ return plugin.set;
20244
+ }
20245
+ const docEmpty = isDocEmpty(doc);
20246
+ const kind = kindAt(line, doc.resolve(pos));
20247
+ const { caret } = lineWords(plugin.text, { node: line, pos, kind, docEmpty, state }, show);
20248
+ return caret
20249
+ ? plugin.set.add(doc, [lineDecoration(line, pos, caret, docEmpty)])
20250
+ : plugin.set;
20251
+ },
20252
+ attributes(state) {
20253
+ if (!isDocEmpty(state.doc))
20254
+ return {};
20255
+ const line = placeholderKey
20256
+ .getState(state)
20257
+ ?.set.find(0, state.doc.firstChild.nodeSize, (spec) => !!spec['line'])[0];
20258
+ const words = line?.spec['line'];
20259
+ return words ? { 'aria-placeholder': words } : {};
20260
+ },
20261
+ handleDOMEvents: { focus: redraw, blur: redraw },
20262
+ },
20263
+ view: (editorView) => {
20264
+ view = editorView;
20265
+ return {
20266
+ destroy: () => {
20267
+ view = undefined;
20268
+ },
20269
+ };
20270
+ },
20271
+ }),
20272
+ ];
20273
+ },
20274
+ });
20275
+ };
20276
+
18796
20277
  /** The expressions a string holds, trimmed — as the host is asked. */
18797
20278
  const expressionsIn = (text) => scanMergeTags(text).map((tag) => text.slice(tag.expr[0], tag.expr[1]).trim());
18798
20279
  /** The attributes that can hold a token: strings — never the authored
@@ -19084,5 +20565,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
19084
20565
  * Generated bundle index. Do not edit.
19085
20566
  */
19086
20567
 
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, LIST_STYLE, 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, 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, buttonStyle, canonicalHead, canonicalMergeTagInner, canonicalRender, caretCellPos, caretInsideMergeTag, claimedImageFiles, clearCells, clientList, columnCaps, columnWidths, completionContextAt, containerStyle, contrastRatio, correctStoredMistakes, createAngularExpressions, createBlockMenu, createBubbleMenu, createButtonEdit, createContentProposal, createContentStream, createDOMParser, createEditor, createHtmlAutocomplete, createHtmlLanguage, createImageDrag, createInlineImages, createMergeTagResolution, createOffsetMapper, createQuotedHistory, createSchema, createSendIntent, createSourceMarks, createSuggestionMenu, createTableHandles, declarationProperty, declaredBackground, decodeDataUrl, defaultBubbleShouldShow, defineExtension, defineMark, defineNode, deleteColumns, deleteFullySelected, discardProposal, distributePadding, docText, dropDeadOfficeClasses, dropHidden, dropMediaQueries, dropZeroSizedWhitespace, duplicateColumn, duplicateRow, editQuotedHistory, effectiveWidths, emailBackgroundPalette, 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, lowerInlinesAroundBlocks, markAttrsOf, 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, preserveExtensions, preservedAttributes, promoteInlineImages, promoteMergeTags, proposalRange, proposeContent, quotedHistory, readEnvelope, readImageFile, realizeComments, relativeLuminance, removeColumn, removeLastColumn, removeQuotedHistory, renderForClient, renderedPath, repairLinkSchemes, repairTables, replaceHTML, replyDocument, replyEnvelope, replyQuote, 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, setQuotedHistory, setSectionBackground, setSectionImage, setTableBox, setTableWidth, soleInlineAtom, splitDeclarations$2 as splitDeclarations, splitListItemKeepMarks, starInputRegex, starPasteRegex, stopProposal, streamContent, streamedRange, streamingRange, suggestionSectionTitles, tableStyle, textOffsetAt, textRegions, toEmailSafeColor, toInboundMessage, toggleList, trimInlineBoxes, underscoreInputRegex, underscorePasteRegex, unwrapLayoutTables, unwrapOfficeParagraphMarks, verbatimElement, videoPage, watchMenuScroll, withAttribute, withAuthoredMarkup, withQuoted, withQuotedHistory, withStyleDeclaration, withoutAttribute, withoutStyleDeclaration };
20568
+ 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$2 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, plainTextExtensions, plainTextHTML, 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
20569
  //# sourceMappingURL=h-k-dev-angular-email-editor.mjs.map