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

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