@voila.dev/ui-email-block-editor 1.1.9
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/README.md +23 -0
- package/package.json +64 -0
- package/src/blocks/article-block.tsx +122 -0
- package/src/blocks/block-definitions.tsx +94 -0
- package/src/blocks/block-text-input.tsx +44 -0
- package/src/blocks/button-block.tsx +119 -0
- package/src/blocks/divider-block.tsx +19 -0
- package/src/blocks/email-card-shell.tsx +80 -0
- package/src/blocks/grid-block.tsx +95 -0
- package/src/blocks/heading-block.tsx +86 -0
- package/src/blocks/image-block.tsx +264 -0
- package/src/blocks/list-block.tsx +202 -0
- package/src/blocks/offer-block.tsx +267 -0
- package/src/blocks/paragraph-block.tsx +47 -0
- package/src/blocks/product-block.tsx +155 -0
- package/src/blocks/rating-block.tsx +121 -0
- package/src/blocks/rich-text-editable.tsx +88 -0
- package/src/blocks/stat-block.tsx +113 -0
- package/src/blocks/table-block.tsx +257 -0
- package/src/dnd/sortable-block-list.tsx +263 -0
- package/src/document/reducer.ts +345 -0
- package/src/document/rich-text.ts +168 -0
- package/src/document/types.ts +388 -0
- package/src/email-block-editor.tsx +163 -0
- package/src/lib/use-media-query.ts +38 -0
- package/src/sections/add-block-menu.tsx +56 -0
- package/src/sections/block-options/alignment-option.tsx +53 -0
- package/src/sections/block-options/block-option-row.tsx +79 -0
- package/src/sections/block-options/money-option.tsx +69 -0
- package/src/sections/block-options/segmented-option.tsx +62 -0
- package/src/sections/block-options/select-option.tsx +76 -0
- package/src/sections/block-options/text-option.tsx +91 -0
- package/src/sections/block-toolbar.tsx +379 -0
- package/src/sections/editor-canvas.tsx +403 -0
- package/src/sections/editor-sidebar.tsx +95 -0
- package/src/sections/link-popover.tsx +89 -0
- package/src/sections/preview-toggle.tsx +45 -0
- package/src/styles.css +10 -0
- package/src/theme.ts +26 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEmailEditorBlock,
|
|
3
|
+
type EmailEditorBlock,
|
|
4
|
+
type EmailEditorBlockType,
|
|
5
|
+
type EmailEditorDocument,
|
|
6
|
+
type EmailEditorGridBlock,
|
|
7
|
+
type EmailEditorLeafBlock,
|
|
8
|
+
isEmailEditorGridBlock,
|
|
9
|
+
} from "#/document/types.ts";
|
|
10
|
+
|
|
11
|
+
/** The editor's full state: the document plus which block is selected. */
|
|
12
|
+
export interface EmailEditorState {
|
|
13
|
+
readonly document: EmailEditorDocument;
|
|
14
|
+
readonly selectedBlockId: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which container an action addresses: `null` is the document root, a string
|
|
19
|
+
* is a grid's id. Only leaf blocks may address a grid — the document model
|
|
20
|
+
* forbids a grid inside a grid, and the reducer enforces it at runtime for the
|
|
21
|
+
* drag-and-drop layer, which cannot be typed.
|
|
22
|
+
*/
|
|
23
|
+
export type EmailEditorContainerId = string | null;
|
|
24
|
+
|
|
25
|
+
export type EmailEditorAction =
|
|
26
|
+
| {
|
|
27
|
+
readonly type: "add";
|
|
28
|
+
readonly blockType: EmailEditorBlockType;
|
|
29
|
+
readonly containerId?: EmailEditorContainerId;
|
|
30
|
+
/** Insertion position within the container; appends when omitted. */
|
|
31
|
+
readonly index?: number;
|
|
32
|
+
}
|
|
33
|
+
| { readonly type: "update"; readonly block: EmailEditorBlock }
|
|
34
|
+
| { readonly type: "remove"; readonly blockId: string }
|
|
35
|
+
| {
|
|
36
|
+
readonly type: "move";
|
|
37
|
+
readonly blockId: string;
|
|
38
|
+
readonly toContainerId: EmailEditorContainerId;
|
|
39
|
+
readonly toIndex: number;
|
|
40
|
+
}
|
|
41
|
+
| { readonly type: "duplicate"; readonly blockId: string }
|
|
42
|
+
| { readonly type: "select"; readonly blockId: string | null }
|
|
43
|
+
| { readonly type: "replace"; readonly document: EmailEditorDocument };
|
|
44
|
+
|
|
45
|
+
const withBlocks = (
|
|
46
|
+
state: EmailEditorState,
|
|
47
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
48
|
+
): EmailEditorState => ({
|
|
49
|
+
...state,
|
|
50
|
+
document: { ...state.document, blocks },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Insert at `index`, clamped into the list — an out-of-range drop appends or
|
|
54
|
+
* prepends rather than losing the block. */
|
|
55
|
+
const insertAt = <Block>(
|
|
56
|
+
blocks: ReadonlyArray<Block>,
|
|
57
|
+
index: number,
|
|
58
|
+
block: Block,
|
|
59
|
+
): ReadonlyArray<Block> => {
|
|
60
|
+
const position = Math.max(0, Math.min(index, blocks.length));
|
|
61
|
+
return [...blocks.slice(0, position), block, ...blocks.slice(position)];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Every block in the document, grids and their children alike. */
|
|
65
|
+
export const allEmailEditorBlocks = (
|
|
66
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
67
|
+
): ReadonlyArray<EmailEditorBlock> =>
|
|
68
|
+
blocks.flatMap((block) =>
|
|
69
|
+
isEmailEditorGridBlock(block) ? [block, ...block.children] : [block],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The container a block lives in: `null` for the root, a grid id for a nested
|
|
74
|
+
* block, `undefined` when the id is unknown to the document.
|
|
75
|
+
*/
|
|
76
|
+
export const emailEditorContainerOf = (
|
|
77
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
78
|
+
blockId: string,
|
|
79
|
+
): EmailEditorContainerId | undefined => {
|
|
80
|
+
if (blocks.some((block) => block.id === blockId)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const grid = blocks.find(
|
|
84
|
+
(block) =>
|
|
85
|
+
isEmailEditorGridBlock(block) &&
|
|
86
|
+
block.children.some((child) => child.id === blockId),
|
|
87
|
+
);
|
|
88
|
+
return grid?.id;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const gridById = (
|
|
92
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
93
|
+
gridId: string,
|
|
94
|
+
): EmailEditorGridBlock | undefined =>
|
|
95
|
+
blocks.find(
|
|
96
|
+
(block): block is EmailEditorGridBlock =>
|
|
97
|
+
isEmailEditorGridBlock(block) && block.id === gridId,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/** The blocks of one container, or `undefined` when it does not exist. */
|
|
101
|
+
export const emailEditorContainerBlocks = (
|
|
102
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
103
|
+
containerId: EmailEditorContainerId,
|
|
104
|
+
): ReadonlyArray<EmailEditorBlock> | undefined =>
|
|
105
|
+
containerId === null ? blocks : gridById(blocks, containerId)?.children;
|
|
106
|
+
|
|
107
|
+
/** Replace one grid's children, leaving every other block untouched. */
|
|
108
|
+
const withGridChildren = (
|
|
109
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
110
|
+
gridId: string,
|
|
111
|
+
children: ReadonlyArray<EmailEditorLeafBlock>,
|
|
112
|
+
): ReadonlyArray<EmailEditorBlock> =>
|
|
113
|
+
blocks.map((block) =>
|
|
114
|
+
isEmailEditorGridBlock(block) && block.id === gridId
|
|
115
|
+
? { ...block, children }
|
|
116
|
+
: block,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const addBlock = (
|
|
120
|
+
state: EmailEditorState,
|
|
121
|
+
action: Extract<EmailEditorAction, { type: "add" }>,
|
|
122
|
+
blockId: string,
|
|
123
|
+
): EmailEditorState => {
|
|
124
|
+
const blocks = state.document.blocks;
|
|
125
|
+
const containerId = action.containerId ?? null;
|
|
126
|
+
const block = createEmailEditorBlock(action.blockType, blockId);
|
|
127
|
+
if (containerId === null) {
|
|
128
|
+
return {
|
|
129
|
+
...withBlocks(
|
|
130
|
+
state,
|
|
131
|
+
insertAt(blocks, action.index ?? blocks.length, block),
|
|
132
|
+
),
|
|
133
|
+
selectedBlockId: block.id,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const grid = gridById(blocks, containerId);
|
|
137
|
+
// A grid inside a grid is not a document the model can express.
|
|
138
|
+
if (grid === undefined || isEmailEditorGridBlock(block)) {
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...withBlocks(
|
|
143
|
+
state,
|
|
144
|
+
withGridChildren(
|
|
145
|
+
blocks,
|
|
146
|
+
grid.id,
|
|
147
|
+
insertAt(grid.children, action.index ?? grid.children.length, block),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
selectedBlockId: block.id,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const updateBlock = (
|
|
155
|
+
state: EmailEditorState,
|
|
156
|
+
updated: EmailEditorBlock,
|
|
157
|
+
): EmailEditorState =>
|
|
158
|
+
withBlocks(
|
|
159
|
+
state,
|
|
160
|
+
state.document.blocks.map((block) => {
|
|
161
|
+
if (block.id === updated.id) {
|
|
162
|
+
return updated;
|
|
163
|
+
}
|
|
164
|
+
if (!isEmailEditorGridBlock(block) || isEmailEditorGridBlock(updated)) {
|
|
165
|
+
return block;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
...block,
|
|
169
|
+
children: block.children.map((child) =>
|
|
170
|
+
child.id === updated.id ? updated : child,
|
|
171
|
+
),
|
|
172
|
+
};
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const removeBlock = (
|
|
177
|
+
state: EmailEditorState,
|
|
178
|
+
blockId: string,
|
|
179
|
+
): EmailEditorState => {
|
|
180
|
+
// Removing a grid takes its children with it — that is what deleting a row
|
|
181
|
+
// means, and the toolbar sits on the grid, not on the document.
|
|
182
|
+
const blocks = state.document.blocks
|
|
183
|
+
.filter((block) => block.id !== blockId)
|
|
184
|
+
.map((block) =>
|
|
185
|
+
isEmailEditorGridBlock(block)
|
|
186
|
+
? {
|
|
187
|
+
...block,
|
|
188
|
+
children: block.children.filter((child) => child.id !== blockId),
|
|
189
|
+
}
|
|
190
|
+
: block,
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
...withBlocks(state, blocks),
|
|
194
|
+
selectedBlockId:
|
|
195
|
+
state.selectedBlockId === blockId ? null : state.selectedBlockId,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Pull a block out of whatever container holds it. */
|
|
200
|
+
const detach = (
|
|
201
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
202
|
+
blockId: string,
|
|
203
|
+
): {
|
|
204
|
+
readonly blocks: ReadonlyArray<EmailEditorBlock>;
|
|
205
|
+
readonly block: EmailEditorBlock | undefined;
|
|
206
|
+
} => {
|
|
207
|
+
const block = allEmailEditorBlocks(blocks).find(
|
|
208
|
+
(candidate) => candidate.id === blockId,
|
|
209
|
+
);
|
|
210
|
+
if (block === undefined) {
|
|
211
|
+
return { blocks, block: undefined };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
blocks: blocks
|
|
215
|
+
.filter((candidate) => candidate.id !== blockId)
|
|
216
|
+
.map((candidate) =>
|
|
217
|
+
isEmailEditorGridBlock(candidate)
|
|
218
|
+
? {
|
|
219
|
+
...candidate,
|
|
220
|
+
children: candidate.children.filter(
|
|
221
|
+
(child) => child.id !== blockId,
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
: candidate,
|
|
225
|
+
),
|
|
226
|
+
block,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const moveBlock = (
|
|
231
|
+
state: EmailEditorState,
|
|
232
|
+
action: Extract<EmailEditorAction, { type: "move" }>,
|
|
233
|
+
): EmailEditorState => {
|
|
234
|
+
const { blocks, block: moved } = detach(
|
|
235
|
+
state.document.blocks,
|
|
236
|
+
action.blockId,
|
|
237
|
+
);
|
|
238
|
+
if (moved === undefined) {
|
|
239
|
+
return state;
|
|
240
|
+
}
|
|
241
|
+
if (isEmailEditorGridBlock(moved)) {
|
|
242
|
+
// A grid can only ever live at the root; a drop that would nest it is a
|
|
243
|
+
// no-op rather than a silently flattened document.
|
|
244
|
+
return action.toContainerId === null
|
|
245
|
+
? withBlocks(state, insertAt(blocks, action.toIndex, moved))
|
|
246
|
+
: state;
|
|
247
|
+
}
|
|
248
|
+
if (action.toContainerId === null) {
|
|
249
|
+
return withBlocks(state, insertAt(blocks, action.toIndex, moved));
|
|
250
|
+
}
|
|
251
|
+
const grid = gridById(blocks, action.toContainerId);
|
|
252
|
+
if (grid === undefined) {
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
return withBlocks(
|
|
256
|
+
state,
|
|
257
|
+
withGridChildren(
|
|
258
|
+
blocks,
|
|
259
|
+
grid.id,
|
|
260
|
+
insertAt(grid.children, action.toIndex, moved),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** A copy under fresh ids, deep for a grid so its children stay unique. */
|
|
266
|
+
const copyBlock = (
|
|
267
|
+
block: EmailEditorBlock,
|
|
268
|
+
generateBlockId: () => string,
|
|
269
|
+
): EmailEditorBlock =>
|
|
270
|
+
isEmailEditorGridBlock(block)
|
|
271
|
+
? {
|
|
272
|
+
...block,
|
|
273
|
+
id: generateBlockId(),
|
|
274
|
+
children: block.children.map((child) => ({
|
|
275
|
+
...child,
|
|
276
|
+
id: generateBlockId(),
|
|
277
|
+
})),
|
|
278
|
+
}
|
|
279
|
+
: { ...block, id: generateBlockId() };
|
|
280
|
+
|
|
281
|
+
const duplicateBlock = (
|
|
282
|
+
state: EmailEditorState,
|
|
283
|
+
blockId: string,
|
|
284
|
+
generateBlockId: () => string,
|
|
285
|
+
): EmailEditorState => {
|
|
286
|
+
const blocks = state.document.blocks;
|
|
287
|
+
const containerId = emailEditorContainerOf(blocks, blockId);
|
|
288
|
+
if (containerId === null) {
|
|
289
|
+
const original = blocks.find((block) => block.id === blockId);
|
|
290
|
+
if (original === undefined) {
|
|
291
|
+
return state;
|
|
292
|
+
}
|
|
293
|
+
const copy = copyBlock(original, generateBlockId);
|
|
294
|
+
return {
|
|
295
|
+
...withBlocks(
|
|
296
|
+
state,
|
|
297
|
+
insertAt(blocks, blocks.indexOf(original) + 1, copy),
|
|
298
|
+
),
|
|
299
|
+
selectedBlockId: copy.id,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const grid =
|
|
303
|
+
containerId === undefined ? undefined : gridById(blocks, containerId);
|
|
304
|
+
const original = grid?.children.find((child) => child.id === blockId);
|
|
305
|
+
if (grid === undefined || original === undefined) {
|
|
306
|
+
return state;
|
|
307
|
+
}
|
|
308
|
+
const copy: EmailEditorLeafBlock = { ...original, id: generateBlockId() };
|
|
309
|
+
return {
|
|
310
|
+
...withBlocks(
|
|
311
|
+
state,
|
|
312
|
+
withGridChildren(
|
|
313
|
+
blocks,
|
|
314
|
+
grid.id,
|
|
315
|
+
insertAt(grid.children, grid.children.indexOf(original) + 1, copy),
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
selectedBlockId: copy.id,
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Pure reducer over the editor state. The block-id factory is injected so the
|
|
324
|
+
* host controls id generation and tests stay deterministic.
|
|
325
|
+
*/
|
|
326
|
+
export const createEmailEditorReducer =
|
|
327
|
+
(generateBlockId: () => string) =>
|
|
328
|
+
(state: EmailEditorState, action: EmailEditorAction): EmailEditorState => {
|
|
329
|
+
switch (action.type) {
|
|
330
|
+
case "add":
|
|
331
|
+
return addBlock(state, action, generateBlockId());
|
|
332
|
+
case "update":
|
|
333
|
+
return updateBlock(state, action.block);
|
|
334
|
+
case "remove":
|
|
335
|
+
return removeBlock(state, action.blockId);
|
|
336
|
+
case "move":
|
|
337
|
+
return moveBlock(state, action);
|
|
338
|
+
case "duplicate":
|
|
339
|
+
return duplicateBlock(state, action.blockId, generateBlockId);
|
|
340
|
+
case "select":
|
|
341
|
+
return { ...state, selectedBlockId: action.blockId };
|
|
342
|
+
case "replace":
|
|
343
|
+
return { document: action.document, selectedBlockId: null };
|
|
344
|
+
}
|
|
345
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { EmailEditorTextSpan } from "#/document/types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialization between the typed span model and the contentEditable DOM the
|
|
5
|
+
* paragraph block edits in place. The HTML vocabulary is intentionally tiny —
|
|
6
|
+
* b/i/u/a plus <br> for line breaks — so both directions stay trivial to
|
|
7
|
+
* reason about and the domain renderer can mirror it safely server-side.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const escapeHtml = (value: string): string =>
|
|
11
|
+
value
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """)
|
|
16
|
+
.replace(/'/g, "'");
|
|
17
|
+
|
|
18
|
+
/** Render spans as the minimal editing HTML for the contentEditable surface. */
|
|
19
|
+
export const textSpansToEditorHtml = (
|
|
20
|
+
spans: ReadonlyArray<EmailEditorTextSpan>,
|
|
21
|
+
): string =>
|
|
22
|
+
spans
|
|
23
|
+
.map((span) => {
|
|
24
|
+
let html = escapeHtml(span.text).replaceAll("\n", "<br>");
|
|
25
|
+
if (span.bold) {
|
|
26
|
+
html = `<b>${html}</b>`;
|
|
27
|
+
}
|
|
28
|
+
if (span.italic) {
|
|
29
|
+
html = `<i>${html}</i>`;
|
|
30
|
+
}
|
|
31
|
+
if (span.underline) {
|
|
32
|
+
html = `<u>${html}</u>`;
|
|
33
|
+
}
|
|
34
|
+
if (span.href !== undefined) {
|
|
35
|
+
html = `<a href="${escapeHtml(span.href)}">${html}</a>`;
|
|
36
|
+
}
|
|
37
|
+
return html;
|
|
38
|
+
})
|
|
39
|
+
.join("");
|
|
40
|
+
|
|
41
|
+
interface InlineMarks {
|
|
42
|
+
readonly bold?: boolean;
|
|
43
|
+
readonly italic?: boolean;
|
|
44
|
+
readonly underline?: boolean;
|
|
45
|
+
readonly href?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isMarked = (span: EmailEditorTextSpan, marks: InlineMarks): boolean =>
|
|
49
|
+
Boolean(span.bold) === Boolean(marks.bold) &&
|
|
50
|
+
Boolean(span.italic) === Boolean(marks.italic) &&
|
|
51
|
+
Boolean(span.underline) === Boolean(marks.underline) &&
|
|
52
|
+
span.href === marks.href;
|
|
53
|
+
|
|
54
|
+
// Tag-based marks are what execCommand produces with styleWithCSS off; the
|
|
55
|
+
// style-based fallbacks cover pasted content and browsers that emit spans.
|
|
56
|
+
const rendersBold = (tag: string, style: CSSStyleDeclaration): boolean =>
|
|
57
|
+
tag === "b" ||
|
|
58
|
+
tag === "strong" ||
|
|
59
|
+
style.fontWeight === "bold" ||
|
|
60
|
+
Number(style.fontWeight) >= 600;
|
|
61
|
+
|
|
62
|
+
const rendersItalic = (tag: string, style: CSSStyleDeclaration): boolean =>
|
|
63
|
+
tag === "i" || tag === "em" || style.fontStyle === "italic";
|
|
64
|
+
|
|
65
|
+
const rendersUnderline = (tag: string, style: CSSStyleDeclaration): boolean =>
|
|
66
|
+
tag === "u" || style.textDecoration.includes("underline");
|
|
67
|
+
|
|
68
|
+
const elementMarks = (
|
|
69
|
+
element: HTMLElement,
|
|
70
|
+
marks: InlineMarks,
|
|
71
|
+
): InlineMarks => {
|
|
72
|
+
const tag = element.tagName.toLowerCase();
|
|
73
|
+
const style = element.style;
|
|
74
|
+
const href = tag === "a" ? element.getAttribute("href") : null;
|
|
75
|
+
return {
|
|
76
|
+
...marks,
|
|
77
|
+
...(rendersBold(tag, style) && { bold: true }),
|
|
78
|
+
...(rendersItalic(tag, style) && { italic: true }),
|
|
79
|
+
...(rendersUnderline(tag, style) && { underline: true }),
|
|
80
|
+
...(href !== null && { href }),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const collectElementSpans = (
|
|
85
|
+
element: HTMLElement,
|
|
86
|
+
marks: InlineMarks,
|
|
87
|
+
out: Array<EmailEditorTextSpan>,
|
|
88
|
+
): void => {
|
|
89
|
+
const tag = element.tagName.toLowerCase();
|
|
90
|
+
if (tag === "br") {
|
|
91
|
+
out.push({ text: "\n", ...marks });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// contentEditable wraps new lines in block elements (div/p); each one after
|
|
95
|
+
// the first starts on a new line.
|
|
96
|
+
if ((tag === "div" || tag === "p") && out.length > 0) {
|
|
97
|
+
out.push({ text: "\n", ...marks });
|
|
98
|
+
}
|
|
99
|
+
const childMarks = elementMarks(element, marks);
|
|
100
|
+
for (const child of element.childNodes) {
|
|
101
|
+
collectSpans(child, childMarks, out);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const collectSpans = (
|
|
106
|
+
node: Node,
|
|
107
|
+
marks: InlineMarks,
|
|
108
|
+
out: Array<EmailEditorTextSpan>,
|
|
109
|
+
): void => {
|
|
110
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
111
|
+
const text = node.textContent ?? "";
|
|
112
|
+
if (text !== "") {
|
|
113
|
+
out.push({ text, ...marks });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
118
|
+
collectElementSpans(node as HTMLElement, marks, out);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Merge adjacent spans carrying identical marks and drop empty runs. */
|
|
123
|
+
const normalizeSpans = (
|
|
124
|
+
spans: ReadonlyArray<EmailEditorTextSpan>,
|
|
125
|
+
): ReadonlyArray<EmailEditorTextSpan> => {
|
|
126
|
+
const merged: Array<EmailEditorTextSpan> = [];
|
|
127
|
+
for (const span of spans) {
|
|
128
|
+
if (span.text === "") {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const previous = merged[merged.length - 1];
|
|
132
|
+
if (previous && isMarked(previous, span)) {
|
|
133
|
+
merged[merged.length - 1] = {
|
|
134
|
+
...previous,
|
|
135
|
+
text: previous.text + span.text,
|
|
136
|
+
};
|
|
137
|
+
} else {
|
|
138
|
+
merged.push(span);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// A single trailing newline is contentEditable noise (trailing <br>).
|
|
142
|
+
const last = merged[merged.length - 1];
|
|
143
|
+
if (last?.text.endsWith("\n")) {
|
|
144
|
+
const trimmed = last.text.slice(0, -1);
|
|
145
|
+
if (trimmed === "") {
|
|
146
|
+
merged.pop();
|
|
147
|
+
} else {
|
|
148
|
+
merged[merged.length - 1] = { ...last, text: trimmed };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Read the contentEditable DOM back into the typed span model. */
|
|
155
|
+
export const editorElementToTextSpans = (
|
|
156
|
+
root: HTMLElement,
|
|
157
|
+
): ReadonlyArray<EmailEditorTextSpan> => {
|
|
158
|
+
const spans: Array<EmailEditorTextSpan> = [];
|
|
159
|
+
for (const child of root.childNodes) {
|
|
160
|
+
collectSpans(child, {}, spans);
|
|
161
|
+
}
|
|
162
|
+
return normalizeSpans(spans);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The plain-text projection of the spans (line breaks preserved). */
|
|
166
|
+
export const textSpansToPlainText = (
|
|
167
|
+
spans: ReadonlyArray<EmailEditorTextSpan>,
|
|
168
|
+
): string => spans.map((span) => span.text).join("");
|