@h-k-dev/angular-email-editor 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/h-k-dev-angular-email-editor-fonts.mjs +144 -0
- package/fesm2022/h-k-dev-angular-email-editor-fonts.mjs.map +1 -0
- package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs +20 -1
- package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs.map +1 -1
- package/fesm2022/h-k-dev-angular-email-editor.mjs +343 -160
- package/fesm2022/h-k-dev-angular-email-editor.mjs.map +1 -1
- package/package.json +5 -1
- package/types/h-k-dev-angular-email-editor-fonts.d.ts +110 -0
- package/types/h-k-dev-angular-email-editor.d.ts +72 -54
|
@@ -3,6 +3,8 @@ import { NodeSelection, TextSelection, Plugin, PluginKey, EditorState, Selection
|
|
|
3
3
|
import { keymap } from 'prosemirror-keymap';
|
|
4
4
|
import { splitBlockAs, chainCommands, newlineInCode, createParagraphNear, liftEmptyBlock, setBlockType, exitCode, lift, wrapIn, toggleMark as toggleMark$1, baseKeymap } from 'prosemirror-commands';
|
|
5
5
|
import { tableEditing, fixTables, splitCell, mergeCells, deleteTable, deleteColumn, deleteRow, addRowAfter, addRowBefore, setCellAttr, removeColumn as removeColumn$1, removeRow, addColumn as addColumn$1, addRow, addColumnAfter, addColumnBefore, TableMap, isInTable, selectedRect, CellSelection, nextCell, goToNextCell } from 'prosemirror-tables';
|
|
6
|
+
import { allowedFontSizes, isEmailFontStack, matchEmailFont } from '@h-k-dev/angular-email-editor/fonts';
|
|
7
|
+
export * from '@h-k-dev/angular-email-editor/fonts';
|
|
6
8
|
import { AddMarkStep, RemoveMarkStep, Transform } from 'prosemirror-transform';
|
|
7
9
|
import { closeHistory, redo, undo, history } from 'prosemirror-history';
|
|
8
10
|
import { InputRule, inputRules, wrappingInputRule, textblockTypeInputRule } from 'prosemirror-inputrules';
|
|
@@ -1686,9 +1688,8 @@ function isUppercase(state, schema) {
|
|
|
1686
1688
|
});
|
|
1687
1689
|
return found;
|
|
1688
1690
|
}
|
|
1689
|
-
const ALLOWED_SIZES = [10, 12, 14, 16, 18, 24, 32];
|
|
1690
1691
|
function isSafeFontSize(value) {
|
|
1691
|
-
return
|
|
1692
|
+
return allowedFontSizes.includes(value);
|
|
1692
1693
|
}
|
|
1693
1694
|
/**
|
|
1694
1695
|
* Parse a font-size value from the DOM into one of our allowed sizes.
|
|
@@ -1705,8 +1706,8 @@ function parseFontSize(raw) {
|
|
|
1705
1706
|
// A size off the ladder — a builder's 11px, 13px, 15px — snaps to the
|
|
1706
1707
|
// nearest rung, upward on a tie: read as the size it is nearest to, not
|
|
1707
1708
|
// dropped to the default. (The schema still emits only its own rungs.)
|
|
1708
|
-
let nearest =
|
|
1709
|
-
for (const size of
|
|
1709
|
+
let nearest = allowedFontSizes[0];
|
|
1710
|
+
for (const size of allowedFontSizes) {
|
|
1710
1711
|
const better = Math.abs(size - n) < Math.abs(nearest - n) ||
|
|
1711
1712
|
(Math.abs(size - n) === Math.abs(nearest - n) && size > nearest);
|
|
1712
1713
|
if (better)
|
|
@@ -1714,79 +1715,19 @@ function parseFontSize(raw) {
|
|
|
1714
1715
|
}
|
|
1715
1716
|
return nearest;
|
|
1716
1717
|
}
|
|
1717
|
-
/**
|
|
1718
|
-
* The sizes the toolbar picker offers: the phone-safe subset (≥14px). Below
|
|
1719
|
-
* ~13px iOS auto-inflates text and reflows the layout (responsiveness ledger,
|
|
1720
|
-
* principle 8), so we don't *offer* those — but {@link parseFontSize} still
|
|
1721
|
-
* accepts the full {@link ALLOWED_SIZES} range, because a hand-typed size in
|
|
1722
|
-
* the HTML source pane is the author's own responsibility, exactly like a
|
|
1723
|
-
* hand-typed hex colour.
|
|
1724
|
-
*/
|
|
1725
|
-
const emailFontSizes = [14, 16, 18, 24, 32];
|
|
1726
|
-
/**
|
|
1727
|
-
* The curated font stacks — the picker offers only these, no free-form fonts
|
|
1728
|
-
* (principle 7: if a mainstream client can't render it, we don't emit it).
|
|
1729
|
-
*
|
|
1730
|
-
* Deliberately built from *single-word* family identifiers plus a generic
|
|
1731
|
-
* fallback: a stack like `Courier New` would round-trip through the CSSOM
|
|
1732
|
-
* (serialization builds real elements and re-reads them) and come back quoted
|
|
1733
|
-
* as `"Courier New"` in Chrome but unquoted in jsdom — a byte-instability that
|
|
1734
|
-
* breaks canonical determinism and makes tests disagree with the runtime (the
|
|
1735
|
-
* same trap the longhand/`rgb()` rule guards against). Bare identifiers and
|
|
1736
|
-
* generic keywords serialize identically everywhere.
|
|
1737
|
-
*/
|
|
1738
|
-
const emailFontFamilies = [
|
|
1739
|
-
{ name: 'Sans-serif', stack: 'Arial, Helvetica, sans-serif' },
|
|
1740
|
-
{ name: 'Serif', stack: 'Georgia, Times, serif' },
|
|
1741
|
-
{ name: 'Monospace', stack: 'Courier, monospace' },
|
|
1742
|
-
{ name: 'System', stack: 'system-ui, sans-serif' },
|
|
1743
|
-
];
|
|
1744
|
-
/** Normalise a `font-family` value to a comparison key: lower-cased, quotes
|
|
1745
|
-
dropped, whitespace around commas collapsed. Lets a hand-typed or CSSOM-
|
|
1746
|
-
reserialized stack match a curated one regardless of cosmetic differences. */
|
|
1747
|
-
function fontFamilyKey(raw) {
|
|
1748
|
-
return raw
|
|
1749
|
-
.toLowerCase()
|
|
1750
|
-
.replace(/["']/g, '')
|
|
1751
|
-
.replace(/\s*,\s*/g, ',')
|
|
1752
|
-
.trim();
|
|
1753
|
-
}
|
|
1754
|
-
const FONT_STACK_BY_KEY = new Map(emailFontFamilies.map((f) => [fontFamilyKey(f.stack), f.stack]));
|
|
1755
|
-
/** Each family a curated stack names — a face or its generic — to the first
|
|
1756
|
-
stack naming it: `helvetica` and `sans-serif` to Sans-serif, `times` and
|
|
1757
|
-
`serif` to Serif. */
|
|
1758
|
-
const FONT_STACK_BY_FAMILY = new Map();
|
|
1759
|
-
for (const { stack } of emailFontFamilies) {
|
|
1760
|
-
for (const family of fontFamilyKey(stack).split(',')) {
|
|
1761
|
-
if (!FONT_STACK_BY_FAMILY.has(family))
|
|
1762
|
-
FONT_STACK_BY_FAMILY.set(family, stack);
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1718
|
+
/** Whether `value` is one of the curated stacks (`@h-k-dev/angular-email-editor/fonts`). */
|
|
1765
1719
|
function isSafeFontFamily(value) {
|
|
1766
|
-
return
|
|
1720
|
+
return isEmailFontStack(value);
|
|
1767
1721
|
}
|
|
1768
1722
|
/**
|
|
1769
|
-
* Parse a font-family value from the DOM into one of
|
|
1723
|
+
* Parse a font-family value from the DOM into one of the curated stacks,
|
|
1770
1724
|
* returning the *canonical* stack string (so a cosmetically different but
|
|
1771
1725
|
* equivalent input normalises to the exact bytes we emit). A stack of
|
|
1772
|
-
* someone else's reads as the curated one it falls back to
|
|
1773
|
-
*
|
|
1774
|
-
* font (`Ubuntu, Helvetica, Arial, sans-serif`) is Sans-serif, the face a
|
|
1775
|
-
* client without that font draws anyway. Nothing to fall back to, no font.
|
|
1726
|
+
* someone else's reads as the curated one it falls back to
|
|
1727
|
+
* ({@link matchEmailFont}). Nothing to fall back to, no font.
|
|
1776
1728
|
*/
|
|
1777
1729
|
function parseFontFamily(raw) {
|
|
1778
|
-
|
|
1779
|
-
return null;
|
|
1780
|
-
const key = fontFamilyKey(raw);
|
|
1781
|
-
const exact = FONT_STACK_BY_KEY.get(key);
|
|
1782
|
-
if (exact)
|
|
1783
|
-
return exact;
|
|
1784
|
-
for (const family of key.split(',')) {
|
|
1785
|
-
const stack = FONT_STACK_BY_FAMILY.get(family.trim());
|
|
1786
|
-
if (stack)
|
|
1787
|
-
return stack;
|
|
1788
|
-
}
|
|
1789
|
-
return null;
|
|
1730
|
+
return matchEmailFont(raw)?.stack ?? null;
|
|
1790
1731
|
}
|
|
1791
1732
|
/**
|
|
1792
1733
|
* TipTap-style `textStyle` mark: a `<span style="…">` container holding inline
|
|
@@ -2290,11 +2231,12 @@ function tokens(value) {
|
|
|
2290
2231
|
* engine handles both — so merged cells are a real feature here, and imported
|
|
2291
2232
|
* mail keeps its shape instead of being flattened.
|
|
2292
2233
|
*/
|
|
2293
|
-
// The
|
|
2294
|
-
//
|
|
2295
|
-
//
|
|
2296
|
-
//
|
|
2297
|
-
//
|
|
2234
|
+
// The table element itself carries no border or padding — those are per cell
|
|
2235
|
+
// (`border` and `padding` attrs, which is what tells the three `TableKind`s
|
|
2236
|
+
// apart). The faint grid shown while editing is an editor-only aid (the
|
|
2237
|
+
// layout-guide rules in the app's global styles), not part of the email.
|
|
2238
|
+
// Preset padding is a fixed, modest value (horizontal padding eats a phone's
|
|
2239
|
+
// width).
|
|
2298
2240
|
// `table-layout: fixed` is the difference between a table you can type in and
|
|
2299
2241
|
// one that jumps: without it, column widths are computed from *content*, so
|
|
2300
2242
|
// every keystroke re-lays out the whole grid and the column you are typing in
|
|
@@ -2312,19 +2254,23 @@ function tokens(value) {
|
|
|
2312
2254
|
40% of the container, in every client and in the editor alike). The
|
|
2313
2255
|
offset serializes as `margin-left` — inline margins on tables are what
|
|
2314
2256
|
Outlook's own Word composer emits, so its engine reads them, and a client
|
|
2315
|
-
that strips them degrades the table gracefully to left-aligned.
|
|
2316
|
-
|
|
2257
|
+
that strips them degrades the table gracefully to left-aligned.
|
|
2258
|
+
`spacing` (px, or null for none) is the room above and below the table —
|
|
2259
|
+
`margin-top`/`margin-bottom`, block only, never the sides: real space
|
|
2260
|
+
between the table and the lines of text around it, which a client that
|
|
2261
|
+
strips it degrades to a table flush against its neighbours. */
|
|
2262
|
+
const tableStyle = (width, offset, spacing = null) => `width: ${formatPct(width)};` +
|
|
2317
2263
|
(offset > 0 ? ` margin-left: ${formatPct(offset)};` : '') +
|
|
2264
|
+
(spacing ? ` margin-top: ${spacing}px; margin-bottom: ${spacing}px;` : '') +
|
|
2318
2265
|
' table-layout: fixed; border-collapse: collapse;';
|
|
2319
2266
|
// Fixed layout means a long unbroken word can no longer widen its column, so it
|
|
2320
2267
|
// would spill out of the cell instead. The editor never showed that (the
|
|
2321
2268
|
// editable root sets `word-wrap` for its own reasons, and it inherits), which
|
|
2322
2269
|
// is exactly why it has to be said out loud here — otherwise the overflow shows
|
|
2323
2270
|
// up only in the recipient's client.
|
|
2324
|
-
// No padding here: the email carries only
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
// sees — so it can also reserve the room the block affordances live in.
|
|
2271
|
+
// No padding here: the email carries only the cell's own `padding` attr
|
|
2272
|
+
// (authored, or a bordered/borderless preset's), and the editor draws exactly
|
|
2273
|
+
// that — it adds no cell spacing of its own.
|
|
2328
2274
|
const cellStyle = (valign) => `vertical-align: ${valign}; overflow-wrap: break-word;`;
|
|
2329
2275
|
/** A cell's horizontal alignment off parsed markup: the inline style first,
|
|
2330
2276
|
then the legacy `align` attribute — the pair real mail is written with,
|
|
@@ -2369,13 +2315,15 @@ function parsePadding(dom) {
|
|
|
2369
2315
|
`<td>` is the bulletproof border in email — Outlook's Word engine renders
|
|
2370
2316
|
it), with `border-collapse` on the table keeping the grid single-lined. */
|
|
2371
2317
|
const TABLE_BORDER_COLOR = 'rgb(208, 215, 229)';
|
|
2372
|
-
/** The
|
|
2373
|
-
the rendered email is readable
|
|
2374
|
-
|
|
2375
|
-
table td` rule): a bordered and a plain table feel identical while
|
|
2376
|
-
composing; only what the recipient gets differs — the preset ships its
|
|
2377
|
-
padding, the plain table ships none unless authored. */
|
|
2318
|
+
/** The *real* cell padding a bordered or borderless table ships —
|
|
2319
|
+
Excel-style breathing room, so the rendered email is readable. A layout
|
|
2320
|
+
table ships none unless authored, and is drawn flush in the editor too. */
|
|
2378
2321
|
const TABLE_CELL_PADDING = '8px 12px';
|
|
2322
|
+
/** The room a bordered or borderless table keeps above and below itself, in
|
|
2323
|
+
px — real (`margin-top`/`margin-bottom` on the table, see `tableStyle`),
|
|
2324
|
+
so the email sets the table off from the text around it exactly as the
|
|
2325
|
+
editor does. Block only: the sides stay on the text column. */
|
|
2326
|
+
const TABLE_BLOCK_SPACING = 16;
|
|
2379
2327
|
/** Canonical percentage: one decimal at most, no trailing zero (25%, 33.3%). */
|
|
2380
2328
|
const formatPct = (n) => `${Math.round(n * 10) / 10}%`;
|
|
2381
2329
|
/** No column below 10%: at 320px that is ~32px — the width where a column
|
|
@@ -2400,7 +2348,18 @@ function pctOf(raw) {
|
|
|
2400
2348
|
function parseTableBox(dom) {
|
|
2401
2349
|
const width = Math.min(Math.max(pctOf(dom.style?.width || dom.getAttribute('width') || '') ?? 100, MIN_TABLE_PCT), 100);
|
|
2402
2350
|
const offset = Math.min(Math.max(pctOf(dom.style?.marginLeft || '') ?? 0, 0), 100 - width);
|
|
2403
|
-
return { width, offset };
|
|
2351
|
+
return { width, offset, spacing: parseBlockSpacing(dom) };
|
|
2352
|
+
}
|
|
2353
|
+
/** The room above and below a table off parsed markup: px, the same above as
|
|
2354
|
+
below — the one shape `tableStyle` writes. Anything else (one side only,
|
|
2355
|
+
em, %, `auto`) is not a look this schema sells, and repairs to none. */
|
|
2356
|
+
function parseBlockSpacing(dom) {
|
|
2357
|
+
const top = (dom.style?.marginTop || '').trim();
|
|
2358
|
+
if (top !== (dom.style?.marginBottom || '').trim())
|
|
2359
|
+
return null;
|
|
2360
|
+
const match = /^(\d+(?:\.\d+)?)px$/.exec(top);
|
|
2361
|
+
const spacing = match ? Number.parseFloat(match[1]) : 0;
|
|
2362
|
+
return spacing > 0 ? spacing : null;
|
|
2404
2363
|
}
|
|
2405
2364
|
/** A `colspan`/`rowspan` off parsed markup: 1 when absent, malformed, zero or
|
|
2406
2365
|
negative — real mail carries all of those, and the grid must stay sane. */
|
|
@@ -2513,12 +2472,14 @@ const TableCell = defineNode({
|
|
|
2513
2472
|
// Same attr name the library's commands expect, different unit on
|
|
2514
2473
|
// purpose; pixel widths never parse in (see the node docs).
|
|
2515
2474
|
colwidth: { default: null },
|
|
2516
|
-
//
|
|
2517
|
-
//
|
|
2475
|
+
// Real padding (px, via `parsePadding`): authored, or the bordered and
|
|
2476
|
+
// borderless presets' `TABLE_CELL_PADDING`. `null` — the default, and a
|
|
2477
|
+
// layout table's — serializes none, and the editor draws none. Cells added to a uniformly padded table inherit
|
|
2478
|
+
// it (see `unifyCellPresets`).
|
|
2518
2479
|
padding: { default: null },
|
|
2519
2480
|
// The cell's grid-line colour (`border: 1px solid <color>`), or null
|
|
2520
|
-
// for
|
|
2521
|
-
//
|
|
2481
|
+
// for no line. Set for every cell of a bordered insert (`/table`); kept
|
|
2482
|
+
// uniform by the repair plugin (see `unifyCellPresets`).
|
|
2522
2483
|
border: { default: null },
|
|
2523
2484
|
// A fill colour on the cell (the most bulletproof background in email —
|
|
2524
2485
|
// `background-color` on `<td>` renders even in Outlook). From the curated
|
|
@@ -2569,7 +2530,9 @@ const Table = defineNode({
|
|
|
2569
2530
|
allowGapCursor: false,
|
|
2570
2531
|
// Width and left offset in percent of the container; the defaults
|
|
2571
2532
|
// (100, 0) serialize identically to the pre-attr canonical form.
|
|
2572
|
-
|
|
2533
|
+
// `spacing`: px of room above and below (`TABLE_BLOCK_SPACING` for the
|
|
2534
|
+
// bordered and borderless kinds), null — the default — for none.
|
|
2535
|
+
attrs: { width: { default: 100 }, offset: { default: 0 }, spacing: { default: null } },
|
|
2573
2536
|
parseDOM: [{ tag: 'table', getAttrs: (dom) => parseTableBox(dom) }],
|
|
2574
2537
|
// <tbody> wrapper matches what mail clients expect and what the HTML
|
|
2575
2538
|
// parser re-injects, so serialize → parse → serialize is a fixpoint.
|
|
@@ -2580,14 +2543,15 @@ const Table = defineNode({
|
|
|
2580
2543
|
'table',
|
|
2581
2544
|
{
|
|
2582
2545
|
role: 'presentation',
|
|
2583
|
-
style: tableStyle(node.attrs['width'], node.attrs['offset']),
|
|
2546
|
+
style: tableStyle(node.attrs['width'], node.attrs['offset'], node.attrs['spacing']),
|
|
2584
2547
|
},
|
|
2585
2548
|
['tbody', 0],
|
|
2586
2549
|
],
|
|
2587
2550
|
},
|
|
2588
2551
|
commands: ({ schema }) => ({
|
|
2589
|
-
/** The default shape
|
|
2590
|
-
|
|
2552
|
+
/** The default shape and kind are the ones /table inserts — three rows
|
|
2553
|
+
by two, bordered (see `TableKind`). */
|
|
2554
|
+
insertTable: (rows = 3, cols = 2, kind = 'bordered') => insertTableFocused(schema, rows, cols, kind),
|
|
2591
2555
|
// Selection-relative structure edits, straight from the library: each one
|
|
2592
2556
|
// understands merged cells and multi-cell selections for free. The
|
|
2593
2557
|
// add-column pair is wrapped: on a table with declared widths, the library
|
|
@@ -2727,15 +2691,15 @@ const Table = defineNode({
|
|
|
2727
2691
|
// a rectangle. Covers everything that reaches the editor as a transaction
|
|
2728
2692
|
// — paste, drops, `setContent`, an import landing on the html signal —
|
|
2729
2693
|
// while `repairTables` covers the pure parse path (see `html.ts`).
|
|
2730
|
-
// `
|
|
2731
|
-
// arrive with
|
|
2694
|
+
// `unifyCellPresets` rides the same pass: cells that structural edits add
|
|
2695
|
+
// arrive with a null border and padding and inherit a uniform table's.
|
|
2732
2696
|
new Plugin({
|
|
2733
2697
|
key: new PluginKey('tableRepair'),
|
|
2734
2698
|
appendTransaction: (transactions, oldState, newState) => {
|
|
2735
2699
|
if (!transactions.some((tr) => tr.docChanged))
|
|
2736
2700
|
return undefined;
|
|
2737
2701
|
const tr = fixTables(newState, oldState) ?? newState.tr;
|
|
2738
|
-
|
|
2702
|
+
unifyCellPresets(tr, insertedRanges([...transactions, tr]));
|
|
2739
2703
|
return tr.docChanged ? tr : undefined;
|
|
2740
2704
|
},
|
|
2741
2705
|
}),
|
|
@@ -2746,23 +2710,32 @@ const Table = defineNode({
|
|
|
2746
2710
|
// Three rows by two: a table asked for by name starts with somewhere to
|
|
2747
2711
|
// write a heading line and two entries under it, which is what an email
|
|
2748
2712
|
// table almost always turns out to be. A picker (the app's grid) says its
|
|
2749
|
-
// own size; this is only what `/table` means on its own.
|
|
2713
|
+
// own size; this is only what `/table` means on its own. One item per
|
|
2714
|
+
// `TableKind`, the bordered default first.
|
|
2750
2715
|
actions: ({ schema }) => [
|
|
2751
2716
|
{
|
|
2752
2717
|
id: 'table',
|
|
2753
2718
|
section: 'layout',
|
|
2754
2719
|
title: 'Table',
|
|
2755
|
-
keywords: ['table', 'grid', 'rows', 'columns'],
|
|
2756
|
-
icon: '
|
|
2757
|
-
command: insertTableFocused(schema, 3, 2),
|
|
2720
|
+
keywords: ['table', 'bordered', 'grid', 'rows', 'columns', 'excel', 'lines'],
|
|
2721
|
+
icon: 'grid_on',
|
|
2722
|
+
command: insertTableFocused(schema, 3, 2, 'bordered'),
|
|
2758
2723
|
},
|
|
2759
2724
|
{
|
|
2760
|
-
id: '
|
|
2725
|
+
id: 'borderless-table',
|
|
2761
2726
|
section: 'layout',
|
|
2762
|
-
title: '
|
|
2763
|
-
keywords: ['
|
|
2764
|
-
icon: '
|
|
2765
|
-
command: insertTableFocused(schema, 3, 2,
|
|
2727
|
+
title: 'Borderless table',
|
|
2728
|
+
keywords: ['borderless-table', 'table', 'borderless', 'no lines', 'padding'],
|
|
2729
|
+
icon: 'border_clear',
|
|
2730
|
+
command: insertTableFocused(schema, 3, 2, 'borderless'),
|
|
2731
|
+
},
|
|
2732
|
+
{
|
|
2733
|
+
id: 'layout-table',
|
|
2734
|
+
section: 'layout',
|
|
2735
|
+
title: 'Layout table',
|
|
2736
|
+
keywords: ['layout-table', 'table', 'layout', 'invisible', 'side by side'],
|
|
2737
|
+
icon: 'view_quilt',
|
|
2738
|
+
command: insertTableFocused(schema, 3, 2, 'layout'),
|
|
2766
2739
|
},
|
|
2767
2740
|
],
|
|
2768
2741
|
});
|
|
@@ -2788,13 +2761,13 @@ function setCellBackground(color) {
|
|
|
2788
2761
|
/** Inserts a table and drops the cursor into its first cell. The table is
|
|
2789
2762
|
located after insertion (rather than by fragile nodeSize math) and
|
|
2790
2763
|
`cellStart` resolves the exact text position inside cell (0, 0). */
|
|
2791
|
-
function insertTableFocused(schema, rows, cols,
|
|
2764
|
+
function insertTableFocused(schema, rows, cols, kind) {
|
|
2792
2765
|
return (state, dispatch) => {
|
|
2793
2766
|
// Asked, not told: answer before building anything — a toolbar asks on
|
|
2794
2767
|
// every transaction.
|
|
2795
2768
|
if (!dispatch)
|
|
2796
2769
|
return true;
|
|
2797
|
-
const table = buildTable(schema, rows, cols,
|
|
2770
|
+
const table = buildTable(schema, rows, cols, kind);
|
|
2798
2771
|
const from = state.selection.from;
|
|
2799
2772
|
const tr = state.tr.replaceSelectionWith(table);
|
|
2800
2773
|
let tablePos = -1;
|
|
@@ -3130,49 +3103,90 @@ function isColumnBoundaryClean(map, index) {
|
|
|
3130
3103
|
}
|
|
3131
3104
|
return true;
|
|
3132
3105
|
}
|
|
3133
|
-
function buildTable(schema, rows, cols,
|
|
3106
|
+
function buildTable(schema, rows, cols, kind) {
|
|
3134
3107
|
const cellType = schema.nodes['tableCell'];
|
|
3135
3108
|
const rowType = schema.nodes['tableRow'];
|
|
3136
3109
|
const tableType = schema.nodes['table'];
|
|
3137
|
-
//
|
|
3138
|
-
//
|
|
3139
|
-
const attrs = {
|
|
3110
|
+
// Bordered and borderless ship real padding (TABLE_CELL_PADDING); a layout
|
|
3111
|
+
// table ships none — its spacing is the editor's alone.
|
|
3112
|
+
const attrs = {
|
|
3113
|
+
border: kind === 'bordered' ? TABLE_BORDER_COLOR : null,
|
|
3114
|
+
padding: kind === 'layout' ? null : TABLE_CELL_PADDING,
|
|
3115
|
+
};
|
|
3140
3116
|
const makeRow = () => rowType.create(null, Array.from({ length: cols }, () => cellType.createAndFill(attrs)));
|
|
3141
|
-
return tableType.create(null, Array.from({ length: rows }, makeRow));
|
|
3117
|
+
return tableType.create({ spacing: kind === 'layout' ? null : TABLE_BLOCK_SPACING }, Array.from({ length: rows }, makeRow));
|
|
3118
|
+
}
|
|
3119
|
+
/** The ranges of the final document that the steps of `trs` (applied one
|
|
3120
|
+
after another) inserted — what is *new* in it. Earlier ranges are mapped
|
|
3121
|
+
through every later step, so all of them speak the last doc's positions. */
|
|
3122
|
+
function insertedRanges(trs) {
|
|
3123
|
+
let ranges = [];
|
|
3124
|
+
for (const tr of trs) {
|
|
3125
|
+
for (const map of tr.mapping.maps) {
|
|
3126
|
+
ranges = ranges.map(([from, to]) => [map.map(from, -1), map.map(to, 1)]);
|
|
3127
|
+
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
|
|
3128
|
+
if (newEnd > newStart)
|
|
3129
|
+
ranges.push([newStart, newEnd]);
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
return ranges;
|
|
3142
3134
|
}
|
|
3143
3135
|
/**
|
|
3144
|
-
* Keeps a
|
|
3145
|
-
*
|
|
3146
|
-
*
|
|
3147
|
-
*
|
|
3148
|
-
*
|
|
3149
|
-
*
|
|
3150
|
-
*
|
|
3136
|
+
* Keeps a preset table uniform: `addRow`/`addColumn`/the pills/Tab past the
|
|
3137
|
+
* end all create cells with a null `border` and `padding`, which would leave
|
|
3138
|
+
* a bordered or borderless table with cells the email draws differently from
|
|
3139
|
+
* their neighbours.
|
|
3140
|
+
*
|
|
3141
|
+
* - **Border**: when every bordered cell wears the same colour, the bare
|
|
3142
|
+
* cells inherit it. Mixed colours (authored, imported) stay as written.
|
|
3143
|
+
* - **Padding**: stricter — only cells *this transaction added* to a table
|
|
3144
|
+
* that was *already there* inherit a uniform padding. Real mail pads a
|
|
3145
|
+
* spacer cell here and there, and an import must not spread that over
|
|
3146
|
+
* every cell: repair, not opinion.
|
|
3147
|
+
*
|
|
3148
|
+
* `setNodeMarkup` never changes node sizes, so positions collected up front
|
|
3149
|
+
* stay valid as the jobs apply.
|
|
3151
3150
|
*/
|
|
3152
|
-
function
|
|
3151
|
+
function unifyCellPresets(tr, inserted) {
|
|
3152
|
+
const isNew = (from, to) => inserted.some(([f, t]) => f <= from && to <= t);
|
|
3153
3153
|
const jobs = [];
|
|
3154
3154
|
tr.doc.descendants((node, pos) => {
|
|
3155
3155
|
if (node.type.name !== 'table')
|
|
3156
3156
|
return true;
|
|
3157
|
-
const
|
|
3158
|
-
|
|
3157
|
+
const borders = new Set();
|
|
3158
|
+
const paddings = new Set();
|
|
3159
|
+
let unbordered = 0;
|
|
3159
3160
|
node.descendants((cell) => {
|
|
3160
3161
|
if (cell.type.name !== 'tableCell')
|
|
3161
3162
|
return true;
|
|
3162
3163
|
const border = cell.attrs['border'];
|
|
3164
|
+
const padding = cell.attrs['padding'];
|
|
3163
3165
|
if (border)
|
|
3164
|
-
|
|
3166
|
+
borders.add(border);
|
|
3165
3167
|
else
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
+
unbordered++;
|
|
3169
|
+
if (padding)
|
|
3170
|
+
paddings.add(padding);
|
|
3171
|
+
return false;
|
|
3168
3172
|
});
|
|
3169
|
-
|
|
3170
|
-
|
|
3173
|
+
const border = borders.size === 1 && unbordered > 0 ? [...borders][0] : null;
|
|
3174
|
+
const padding = paddings.size === 1 && !isNew(pos, pos + node.nodeSize) ? [...paddings][0] : null;
|
|
3175
|
+
if (border || padding) {
|
|
3171
3176
|
node.descendants((cell, rel) => {
|
|
3172
|
-
if (cell.type.name
|
|
3173
|
-
|
|
3177
|
+
if (cell.type.name !== 'tableCell')
|
|
3178
|
+
return true;
|
|
3179
|
+
const at = pos + 1 + rel;
|
|
3180
|
+
const attrs = { ...cell.attrs };
|
|
3181
|
+
if (border && !attrs['border'])
|
|
3182
|
+
attrs['border'] = border;
|
|
3183
|
+
if (padding && !attrs['padding'] && isNew(at, at + cell.nodeSize)) {
|
|
3184
|
+
attrs['padding'] = padding;
|
|
3174
3185
|
}
|
|
3175
|
-
|
|
3186
|
+
if (attrs['border'] !== cell.attrs['border'] || attrs['padding'] !== cell.attrs['padding']) {
|
|
3187
|
+
jobs.push({ pos: at, attrs });
|
|
3188
|
+
}
|
|
3189
|
+
return false;
|
|
3176
3190
|
});
|
|
3177
3191
|
}
|
|
3178
3192
|
return false; // cells handled; nothing tabular nests deeper
|
|
@@ -5607,7 +5621,9 @@ const Columns = defineNode({
|
|
|
5607
5621
|
insertColumns: (count = 2) => insertColumns(schema, count),
|
|
5608
5622
|
/** Fill the column the cursor is in (or clear it with `null`). */
|
|
5609
5623
|
setColumnBackground: (color) => setColumnBackground(color),
|
|
5610
|
-
//
|
|
5624
|
+
// Caret-relative structural edits, for a host's own controls (the
|
|
5625
|
+
// block's pills use the position-addressed `appendColumn` /
|
|
5626
|
+
// `removeLastColumn`). Named apart from the table's
|
|
5611
5627
|
// `addColumnAfter`/`deleteColumn` — commands merge into one flat record.
|
|
5612
5628
|
addColumn: () => addColumn,
|
|
5613
5629
|
removeColumn: () => removeColumn,
|
|
@@ -5627,12 +5643,17 @@ const Columns = defineNode({
|
|
|
5627
5643
|
// column's edge hops straight into the neighbouring column — the layout
|
|
5628
5644
|
// twin of the table's cell-to-cell arrows. Mod-A is the table's scoped
|
|
5629
5645
|
// select-all applied here: column content first, block from an empty
|
|
5630
|
-
// column, the stock whole-document one from the block selection on.
|
|
5646
|
+
// column, the stock whole-document one from the block selection on. And
|
|
5647
|
+
// Delete over whole columns removes them — the block's only delete.
|
|
5631
5648
|
plugins: () => [
|
|
5632
5649
|
keymap({
|
|
5633
5650
|
ArrowLeft: columnArrow(-1),
|
|
5634
5651
|
ArrowRight: columnArrow(1),
|
|
5635
5652
|
'Mod-a': selectColumnContent,
|
|
5653
|
+
Backspace: deleteFullySelectedColumns,
|
|
5654
|
+
'Mod-Backspace': deleteFullySelectedColumns,
|
|
5655
|
+
Delete: deleteFullySelectedColumns,
|
|
5656
|
+
'Mod-Delete': deleteFullySelectedColumns,
|
|
5636
5657
|
}),
|
|
5637
5658
|
],
|
|
5638
5659
|
actions: ({ schema }) => [
|
|
@@ -5663,6 +5684,16 @@ function hasColumnChildren(dom) {
|
|
|
5663
5684
|
}
|
|
5664
5685
|
return false;
|
|
5665
5686
|
}
|
|
5687
|
+
/** How many empty lines a fresh column starts with. Two, not one: the block
|
|
5688
|
+
has no padding of the editor's own, so a one-line column is a sliver the
|
|
5689
|
+
eye reads as a stray rule rather than a place to write — two lines give
|
|
5690
|
+
it the shape of a column, the way /table starts with rows to fill. */
|
|
5691
|
+
const COLUMN_STARTING_LINES = 2;
|
|
5692
|
+
/** An empty column, {@link COLUMN_STARTING_LINES} lines tall. */
|
|
5693
|
+
function freshColumn(schema, attrs = null) {
|
|
5694
|
+
const lines = Array.from({ length: COLUMN_STARTING_LINES }, () => schema.nodes['paragraph'].createAndFill());
|
|
5695
|
+
return schema.nodes['column'].create(attrs, lines);
|
|
5696
|
+
}
|
|
5666
5697
|
/** Inserts an n-column block and drops the cursor into the first column. */
|
|
5667
5698
|
function insertColumns(schema, count) {
|
|
5668
5699
|
return (state, dispatch) => {
|
|
@@ -5670,10 +5701,9 @@ function insertColumns(schema, count) {
|
|
|
5670
5701
|
// every transaction.
|
|
5671
5702
|
if (!dispatch)
|
|
5672
5703
|
return true;
|
|
5673
|
-
const colType = schema.nodes['column'];
|
|
5674
5704
|
const columnsType = schema.nodes['columns'];
|
|
5675
5705
|
const maxWidth = columnMaxWidth(count);
|
|
5676
|
-
const columns = Array.from({ length: count }, () =>
|
|
5706
|
+
const columns = Array.from({ length: count }, () => freshColumn(schema, { maxWidth }));
|
|
5677
5707
|
const node = columnsType.create(null, columns);
|
|
5678
5708
|
const from = state.selection.from;
|
|
5679
5709
|
const tr = state.tr.replaceSelectionWith(node);
|
|
@@ -5780,9 +5810,7 @@ const addColumn = (state, dispatch) => {
|
|
|
5780
5810
|
const existing = [];
|
|
5781
5811
|
ctx.node.forEach((col) => existing.push(col));
|
|
5782
5812
|
const columns = resplitColumns(existing, count);
|
|
5783
|
-
const fresh = state.schema
|
|
5784
|
-
maxWidth: columnMaxWidth(count),
|
|
5785
|
-
});
|
|
5813
|
+
const fresh = freshColumn(state.schema, { maxWidth: columnMaxWidth(count) });
|
|
5786
5814
|
columns.splice(ctx.index + 1, 0, fresh);
|
|
5787
5815
|
const tr = state.tr.replaceWith(ctx.pos, ctx.pos + ctx.node.nodeSize, ctx.node.type.create(ctx.node.attrs, columns));
|
|
5788
5816
|
// The fresh column starts after the block's opening token (+1) and every
|
|
@@ -5823,6 +5851,107 @@ const deleteColumns = (state, dispatch) => {
|
|
|
5823
5851
|
dispatch?.(state.tr.delete(ctx.pos, ctx.pos + ctx.node.nodeSize).scrollIntoView());
|
|
5824
5852
|
return true;
|
|
5825
5853
|
};
|
|
5854
|
+
/** Restructures the columns block at `pos` in place: drops the columns
|
|
5855
|
+
`keep` refuses, optionally appends an empty one, and re-splits every cap
|
|
5856
|
+
evenly. Step by step — never a whole-block replace — so the selection
|
|
5857
|
+
maps through untouched: a caret in a kept column stays exactly where it
|
|
5858
|
+
was, one in a removed column lands at the nearest valid spot. The pills'
|
|
5859
|
+
and the select-to-delete gesture's shared tail. */
|
|
5860
|
+
function restructureColumns(state, pos, block, keep, append = false) {
|
|
5861
|
+
const tr = state.tr;
|
|
5862
|
+
const starts = [];
|
|
5863
|
+
let at = pos + 1;
|
|
5864
|
+
block.forEach((col) => {
|
|
5865
|
+
starts.push(at);
|
|
5866
|
+
at += col.nodeSize;
|
|
5867
|
+
});
|
|
5868
|
+
// Back to front, so the earlier starts stay valid.
|
|
5869
|
+
for (let index = block.childCount - 1; index >= 0; index--) {
|
|
5870
|
+
if (!keep(index))
|
|
5871
|
+
tr.delete(starts[index], starts[index] + block.child(index).nodeSize);
|
|
5872
|
+
}
|
|
5873
|
+
if (append) {
|
|
5874
|
+
const end = tr.mapping.map(pos + block.nodeSize - 1, -1);
|
|
5875
|
+
tr.insert(end, freshColumn(state.schema));
|
|
5876
|
+
}
|
|
5877
|
+
const rebuilt = tr.doc.nodeAt(pos);
|
|
5878
|
+
const maxWidth = columnMaxWidth(rebuilt.childCount);
|
|
5879
|
+
rebuilt.forEach((col, offset) => {
|
|
5880
|
+
tr.setNodeMarkup(pos + 1 + offset, undefined, { ...col.attrs, maxWidth });
|
|
5881
|
+
});
|
|
5882
|
+
return tr;
|
|
5883
|
+
}
|
|
5884
|
+
/** The `+` pill on the block's right flank: an empty column after the last,
|
|
5885
|
+
up to {@link MAX_COLUMNS}; every cap re-splits evenly. Addressed by the
|
|
5886
|
+
block's position, not the caret — the pill acts on the block it is on. */
|
|
5887
|
+
function appendColumn(pos) {
|
|
5888
|
+
return (state, dispatch) => {
|
|
5889
|
+
const block = state.doc.nodeAt(pos);
|
|
5890
|
+
if (block?.type.name !== 'columns' || block.childCount >= MAX_COLUMNS)
|
|
5891
|
+
return false;
|
|
5892
|
+
dispatch?.(restructureColumns(state, pos, block, () => true, true));
|
|
5893
|
+
return true;
|
|
5894
|
+
};
|
|
5895
|
+
}
|
|
5896
|
+
/** The `−` pill on the block's left flank: the last column goes — the `+`'s
|
|
5897
|
+
exact inverse — and the rest re-split evenly. Refuses on the last one
|
|
5898
|
+
left: the block itself goes by selecting it whole and pressing Delete. */
|
|
5899
|
+
function removeLastColumn(pos) {
|
|
5900
|
+
return (state, dispatch) => {
|
|
5901
|
+
const block = state.doc.nodeAt(pos);
|
|
5902
|
+
if (block?.type.name !== 'columns' || block.childCount <= 1)
|
|
5903
|
+
return false;
|
|
5904
|
+
dispatch?.(restructureColumns(state, pos, block, (index) => index < block.childCount - 1));
|
|
5905
|
+
return true;
|
|
5906
|
+
};
|
|
5907
|
+
}
|
|
5908
|
+
/**
|
|
5909
|
+
* Backspace/Delete with whole columns selected — the table's gesture on the
|
|
5910
|
+
* layout block: select a column's content end to end (Mod-A in it, or a
|
|
5911
|
+
* drag) and the column goes; select several whole columns and they go;
|
|
5912
|
+
* select every column and the block goes. Anything less falls through to
|
|
5913
|
+
* the ordinary delete, which clears the selected text.
|
|
5914
|
+
*/
|
|
5915
|
+
const deleteFullySelectedColumns = (state, dispatch) => {
|
|
5916
|
+
const selection = state.selection;
|
|
5917
|
+
if (!(selection instanceof TextSelection) || selection.empty)
|
|
5918
|
+
return false;
|
|
5919
|
+
const { $from, $to } = selection;
|
|
5920
|
+
let depth = -1;
|
|
5921
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
5922
|
+
if ($from.node(d).type.name === 'columns') {
|
|
5923
|
+
depth = d;
|
|
5924
|
+
break;
|
|
5925
|
+
}
|
|
5926
|
+
}
|
|
5927
|
+
if (depth < 0 || $to.depth < depth || $to.before(depth) !== $from.before(depth))
|
|
5928
|
+
return false;
|
|
5929
|
+
const block = $from.node(depth);
|
|
5930
|
+
const pos = $from.before(depth);
|
|
5931
|
+
const first = $from.index(depth);
|
|
5932
|
+
const last = $to.index(depth);
|
|
5933
|
+
// Whole columns: the selection starts where the first column's text starts
|
|
5934
|
+
// and ends where the last column's text ends.
|
|
5935
|
+
const start = (index) => {
|
|
5936
|
+
let at = pos + 1;
|
|
5937
|
+
for (let i = 0; i < index; i++)
|
|
5938
|
+
at += block.child(i).nodeSize;
|
|
5939
|
+
return at;
|
|
5940
|
+
};
|
|
5941
|
+
const textStart = Selection.findFrom(state.doc.resolve(start(first) + 1), 1, true);
|
|
5942
|
+
const textEnd = Selection.findFrom(state.doc.resolve(start(last) + block.child(last).nodeSize - 1), -1, true);
|
|
5943
|
+
if (!textStart || !textEnd || $from.pos > textStart.from || $to.pos < textEnd.to)
|
|
5944
|
+
return false;
|
|
5945
|
+
if (!dispatch)
|
|
5946
|
+
return true;
|
|
5947
|
+
if (first === 0 && last === block.childCount - 1) {
|
|
5948
|
+
dispatch(state.tr.delete(pos, pos + block.nodeSize).scrollIntoView());
|
|
5949
|
+
return true;
|
|
5950
|
+
}
|
|
5951
|
+
const keep = (index) => index < first || index > last;
|
|
5952
|
+
dispatch(restructureColumns(state, pos, block, keep).scrollIntoView());
|
|
5953
|
+
return true;
|
|
5954
|
+
};
|
|
5826
5955
|
/**
|
|
5827
5956
|
* Tab / Shift-Tab inside a columns block: move to the next / previous column,
|
|
5828
5957
|
* and past the block's ends hand off to the surrounding blocks. Always claims
|
|
@@ -5921,7 +6050,12 @@ const selectColumnContent = (state, dispatch) => {
|
|
|
5921
6050
|
if (depth < 0)
|
|
5922
6051
|
return false;
|
|
5923
6052
|
const column = $from.node(depth);
|
|
5924
|
-
|
|
6053
|
+
// Empty: nothing but blank lines (a fresh column has two).
|
|
6054
|
+
let empty = true;
|
|
6055
|
+
column.forEach((block) => {
|
|
6056
|
+
if (!block.isTextblock || block.childCount > 0)
|
|
6057
|
+
empty = false;
|
|
6058
|
+
});
|
|
5925
6059
|
if (dispatch) {
|
|
5926
6060
|
if (empty) {
|
|
5927
6061
|
dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $from.before(depth - 1))));
|
|
@@ -13681,7 +13815,7 @@ class TableView {
|
|
|
13681
13815
|
this.#box = this.dom.appendChild(document.createElement('div'));
|
|
13682
13816
|
this.#box.className = 'aee-table-box';
|
|
13683
13817
|
this.#table = this.#box.appendChild(document.createElement('table'));
|
|
13684
|
-
this.#table.setAttribute('style', tableStyle(node.attrs['width'], node.attrs['offset']));
|
|
13818
|
+
this.#table.setAttribute('style', tableStyle(node.attrs['width'], node.attrs['offset'], node.attrs['spacing']));
|
|
13685
13819
|
this.#table.setAttribute('role', 'presentation');
|
|
13686
13820
|
this.#colgroup = this.#table.appendChild(document.createElement('colgroup'));
|
|
13687
13821
|
this.contentDOM = this.#table.appendChild(document.createElement('tbody'));
|
|
@@ -13811,7 +13945,7 @@ class TableView {
|
|
|
13811
13945
|
// is CSS-inset to the wrapper's content box (inside the editor-only
|
|
13812
13946
|
// gutter), so a child's `left` percentage is a share of the table's own
|
|
13813
13947
|
// area — table-relative shares are converted once, here.
|
|
13814
|
-
this.#table.setAttribute('style', tableStyle(tableWidth, offset));
|
|
13948
|
+
this.#table.setAttribute('style', tableStyle(tableWidth, offset, node.attrs['spacing']));
|
|
13815
13949
|
// An authored table (a template's, parsed in the `email` mode) shows its
|
|
13816
13950
|
// own attributes, as the email does — see authored.ts.
|
|
13817
13951
|
applyAuthoredAttributes(this.#table, node, 0);
|
|
@@ -13981,7 +14115,9 @@ class TableView {
|
|
|
13981
14115
|
rows are the same object (ProseMirror's persistent tree), so a keystroke
|
|
13982
14116
|
is one new cell and a handful of identity compares. */
|
|
13983
14117
|
function sameTableChrome(a, b) {
|
|
13984
|
-
if (a.attrs['width'] !== b.attrs['width'] ||
|
|
14118
|
+
if (a.attrs['width'] !== b.attrs['width'] ||
|
|
14119
|
+
a.attrs['offset'] !== b.attrs['offset'] ||
|
|
14120
|
+
a.attrs['spacing'] !== b.attrs['spacing']) {
|
|
13985
14121
|
return false;
|
|
13986
14122
|
}
|
|
13987
14123
|
if (a.childCount !== b.childCount)
|
|
@@ -14113,6 +14249,8 @@ class ColumnsView {
|
|
|
14113
14249
|
contentDOM;
|
|
14114
14250
|
#box;
|
|
14115
14251
|
#lines;
|
|
14252
|
+
#append;
|
|
14253
|
+
#remove;
|
|
14116
14254
|
#node;
|
|
14117
14255
|
#view;
|
|
14118
14256
|
#getPos;
|
|
@@ -14135,6 +14273,14 @@ class ColumnsView {
|
|
|
14135
14273
|
this.#lines.className = 'aee-col-lines';
|
|
14136
14274
|
this.#lines.contentEditable = 'false';
|
|
14137
14275
|
this.#lines.setAttribute('aria-hidden', 'true');
|
|
14276
|
+
// The block's two pills, the table's `+` turned into a stepper: `+` on
|
|
14277
|
+
// the right flank appends a column, `−` on the left takes the last one
|
|
14278
|
+
// away. Each sits in a sensor zone over the gutter beside the box — only
|
|
14279
|
+
// non-editable ground, so the zone can stay hit-testable and the pointer
|
|
14280
|
+
// never crosses dead space on its way over. No delete button: the block
|
|
14281
|
+
// goes the table's way, selected whole and Delete.
|
|
14282
|
+
this.#append = this.#pill('end', '+', 'Add column', appendColumn);
|
|
14283
|
+
this.#remove = this.#pill('start', '−', 'Remove last column', removeLastColumn);
|
|
14138
14284
|
this.#render(node);
|
|
14139
14285
|
}
|
|
14140
14286
|
update(node) {
|
|
@@ -14150,11 +14296,34 @@ class ColumnsView {
|
|
|
14150
14296
|
target === this.#box ||
|
|
14151
14297
|
target === this.#lines ||
|
|
14152
14298
|
this.#lines.contains(target) ||
|
|
14299
|
+
// The pills and their zones are ours too.
|
|
14300
|
+
(target instanceof Element && !!target.closest('.aee-add-zone')) ||
|
|
14153
14301
|
// The contentDOM's own attributes are ours; its children are ProseMirror's.
|
|
14154
14302
|
(target === this.contentDOM && record.type === 'attributes'));
|
|
14155
14303
|
}
|
|
14304
|
+
#pill(side, glyph, label, command) {
|
|
14305
|
+
const zone = this.#box.appendChild(document.createElement('div'));
|
|
14306
|
+
zone.className = `aee-add-zone aee-add-zone--${side}`;
|
|
14307
|
+
zone.contentEditable = 'false';
|
|
14308
|
+
zone.setAttribute('aria-hidden', 'true');
|
|
14309
|
+
const pill = zone.appendChild(document.createElement('div'));
|
|
14310
|
+
pill.className = `aee-add-pill aee-add-pill--${side}`;
|
|
14311
|
+
pill.textContent = glyph;
|
|
14312
|
+
pill.setAttribute('role', 'button');
|
|
14313
|
+
pill.setAttribute('title', label);
|
|
14314
|
+
// preventDefault keeps the caret where it is; the command runs on click.
|
|
14315
|
+
pill.addEventListener('mousedown', (event) => event.preventDefault());
|
|
14316
|
+
pill.addEventListener('click', () => {
|
|
14317
|
+
command(this.#getPos())(this.#view.state, this.#view.dispatch);
|
|
14318
|
+
});
|
|
14319
|
+
return pill;
|
|
14320
|
+
}
|
|
14156
14321
|
#render(node) {
|
|
14157
14322
|
this.#box.setAttribute('style', containerStyle(node.attrs['align']));
|
|
14323
|
+
// A pill with nothing to do says so: no `+` past the most columns, no
|
|
14324
|
+
// `−` on the last one.
|
|
14325
|
+
this.#append.toggleAttribute('data-disabled', node.childCount >= MAX_COLUMNS);
|
|
14326
|
+
this.#remove.toggleAttribute('data-disabled', node.childCount <= 1);
|
|
14158
14327
|
// An authored columns block shows its own attributes (authored.ts).
|
|
14159
14328
|
applyAuthoredAttributes(this.#box, node, 0);
|
|
14160
14329
|
const caps = columnCaps(node);
|
|
@@ -17359,29 +17528,43 @@ function discardTransaction(view) {
|
|
|
17359
17528
|
/**
|
|
17360
17529
|
* Where a floating menu goes for an anchor line: under it, clamped to its
|
|
17361
17530
|
* scroll container, flipped above when below would overflow the visible
|
|
17362
|
-
* area.
|
|
17363
|
-
*
|
|
17364
|
-
*
|
|
17531
|
+
* area. When it fits on neither side it goes to the side with more room and
|
|
17532
|
+
* says how tall it may be there (`maxHeight`) — flip, then size, as Floating
|
|
17533
|
+
* UI does — so the container never clips it.
|
|
17534
|
+
*
|
|
17535
|
+
* Read-only — it measures and returns, and the caller writes `left` / `top`
|
|
17536
|
+
* (and the height cap) in its own write phase, so measuring never follows a
|
|
17537
|
+
* write in the same task. The element must be `position: absolute` inside a
|
|
17365
17538
|
* `position: relative` scroll container; its size is read as rendered, so
|
|
17366
|
-
* call it after its contents are in.
|
|
17539
|
+
* call it after its contents are in. A caller that caps the box itself
|
|
17540
|
+
* passes `height`, the box's height without that cap — a box measured at the
|
|
17541
|
+
* last cap would choose its side by the old room, not its own size.
|
|
17367
17542
|
*/
|
|
17368
|
-
function measureMenuPlacement(anchor, element, offset) {
|
|
17543
|
+
function measureMenuPlacement(anchor, element, offset, height = element.offsetHeight) {
|
|
17369
17544
|
const container = element.offsetParent ?? element.parentElement;
|
|
17370
17545
|
if (!container)
|
|
17371
17546
|
return null;
|
|
17372
17547
|
const containerRect = container.getBoundingClientRect();
|
|
17373
|
-
const
|
|
17548
|
+
const width = element.offsetWidth;
|
|
17374
17549
|
const { scrollLeft, scrollTop, scrollWidth, clientHeight } = container;
|
|
17375
17550
|
const left = Math.min(Math.max(anchor.left - containerRect.left + scrollLeft, 0), Math.max(scrollWidth - width, 0));
|
|
17376
|
-
//
|
|
17377
|
-
// the scroll container.
|
|
17378
|
-
|
|
17379
|
-
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
17383
|
-
|
|
17384
|
-
|
|
17551
|
+
// The box's top edge below the line, its bottom edge above it, and the
|
|
17552
|
+
// visible part of the scroll container each leaves.
|
|
17553
|
+
const below = anchor.bottom - containerRect.top + scrollTop + offset;
|
|
17554
|
+
const above = anchor.top - containerRect.top + scrollTop - offset;
|
|
17555
|
+
const roomBelow = scrollTop + clientHeight - below;
|
|
17556
|
+
const roomAbove = above - scrollTop;
|
|
17557
|
+
if (height <= roomBelow)
|
|
17558
|
+
return { left, top: below, maxHeight: null };
|
|
17559
|
+
if (height <= roomAbove)
|
|
17560
|
+
return { left, top: above - height, maxHeight: null };
|
|
17561
|
+
// Fits neither: the side with more room, shrunk to it. A line out of
|
|
17562
|
+
// sight on both sides leaves no room to shrink to — below, as it is.
|
|
17563
|
+
if (roomAbove > roomBelow && roomAbove > 0)
|
|
17564
|
+
return { left, top: scrollTop, maxHeight: roomAbove };
|
|
17565
|
+
if (roomBelow > 0)
|
|
17566
|
+
return { left, top: below, maxHeight: roomBelow };
|
|
17567
|
+
return { left, top: below, maxHeight: null };
|
|
17385
17568
|
}
|
|
17386
17569
|
|
|
17387
17570
|
/** The dialect installed on an editor, as plugin state, so the send intent
|
|
@@ -18840,5 +19023,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
|
|
|
18840
19023
|
* Generated bundle index. Do not edit.
|
|
18841
19024
|
*/
|
|
18842
19025
|
|
|
18843
|
-
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, 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_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, 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,
|
|
19026
|
+
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, 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 };
|
|
18844
19027
|
//# sourceMappingURL=h-k-dev-angular-email-editor.mjs.map
|