flowtext-editor 0.1.0

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/dist/index.cjs ADDED
@@ -0,0 +1,2910 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const react = require("react");
5
+ const cx = (...values) => values.filter(Boolean).join(" ");
6
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ function SpinnerLoader() {
8
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "erte-spinner-overlay", role: "status", "aria-live": "polite", "aria-label": "Loading", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "erte-spinner" }) });
9
+ }
10
+ function ErrorToaster({ message, hidden }) {
11
+ if (hidden || !message) return null;
12
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "erte-toast", role: "alert", children: message });
13
+ }
14
+ const TABLE_ACTION_SPACE = 52;
15
+ const CELL_STYLE = [
16
+ "padding: 8px 10px",
17
+ "border: 1px solid #d7dce2",
18
+ "font-weight: normal",
19
+ "font-family: sans-serif",
20
+ "vertical-align: top",
21
+ "word-break: break-word",
22
+ "overflow-wrap: break-word"
23
+ ].join("; ");
24
+ const getTableHtml = (rows, cols) => `
25
+ <table data-erte-table="true" style="width: calc(100% - ${TABLE_ACTION_SPACE}px); table-layout: fixed; border-collapse: collapse; font-weight: normal; font-family: sans-serif;">
26
+ <colgroup>${Array.from({ length: cols }).map(() => "<col>").join("")}</colgroup>
27
+ <tbody>
28
+ ${Array.from({ length: rows }).map(
29
+ () => `
30
+ <tr>
31
+ ${Array.from({ length: cols }).map(() => `<td style="${CELL_STYLE}">&nbsp;</td>`).join("")}
32
+ </tr>
33
+ `
34
+ ).join("")}
35
+ </tbody>
36
+ </table>
37
+ <p><br></p>
38
+ `;
39
+ const svg = (viewBox, path) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="currentColor" aria-hidden="true" focusable="false"><path d="${path}"/></svg>`;
40
+ const strokeSvg = (inner) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${inner}</svg>`;
41
+ const icons = {
42
+ undo: svg(
43
+ "0 0 512 512",
44
+ "M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"
45
+ ),
46
+ redo: svg(
47
+ "0 0 512 512",
48
+ "M500.33 0h-47.41a12 12 0 0 0-12 12.57l4 82.76A247.42 247.42 0 0 0 256 8C119.34 8 7.9 119.53 8 256.19 8.1 393.07 119.1 504 256 504a247.1 247.1 0 0 0 166.18-63.91 12 12 0 0 0 .48-17.43l-34-34a12 12 0 0 0-16.38-.55A176 176 0 1 1 402.1 157.8l-101.53-4.87a12 12 0 0 0-12.57 12v47.41a12 12 0 0 0 12 12h200.33a12 12 0 0 0 12-12V12a12 12 0 0 0-12-12z"
49
+ ),
50
+ bold: svg(
51
+ "0 0 384 512",
52
+ "M333.49 238a122 122 0 0 0 27-65.21C367.87 96.49 308 32 233.42 32H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h31.87v288H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h209.32c70.8 0 134.14-51.75 141-122.4 4.74-48.45-16.39-92.06-50.83-119.6zM145.66 112h87.76a48 48 0 0 1 0 96h-87.76zm87.76 288h-87.76V288h87.76a56 56 0 0 1 0 112z"
53
+ ),
54
+ italic: svg(
55
+ "0 0 320 512",
56
+ "M320 48v32a16 16 0 0 1-16 16h-62.76l-80 320H208a16 16 0 0 1 16 16v32a16 16 0 0 1-16 16H16a16 16 0 0 1-16-16v-32a16 16 0 0 1 16-16h62.76l80-320H112a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h192a16 16 0 0 1 16 16z"
57
+ ),
58
+ underline: svg(
59
+ "0 0 448 512",
60
+ "M32 64h32v160c0 88.22 71.78 160 160 160s160-71.78 160-160V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H272a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h32v160a80 80 0 0 1-160 0V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H32a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm400 384H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z"
61
+ ),
62
+ alignLeft: svg(
63
+ "0 0 448 512",
64
+ "M12.83 352h262.34A12.82 12.82 0 0 0 288 339.17v-38.34A12.82 12.82 0 0 0 275.17 288H12.83A12.82 12.82 0 0 0 0 300.83v38.34A12.82 12.82 0 0 0 12.83 352zm0-256h262.34A12.82 12.82 0 0 0 288 83.17V44.83A12.82 12.82 0 0 0 275.17 32H12.83A12.82 12.82 0 0 0 0 44.83v38.34A12.82 12.82 0 0 0 12.83 96zM432 160H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0 256H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z"
65
+ ),
66
+ alignCenter: svg(
67
+ "0 0 448 512",
68
+ "M432 160H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0 256H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM108.1 96h231.81A12.09 12.09 0 0 0 352 83.9V44.09A12.09 12.09 0 0 0 339.91 32H108.1A12.09 12.09 0 0 0 96 44.09V83.9A12.1 12.1 0 0 0 108.1 96zm231.81 256A12.09 12.09 0 0 0 352 339.9v-39.81A12.09 12.09 0 0 0 339.91 288H108.1A12.09 12.09 0 0 0 96 300.09v39.81a12.1 12.1 0 0 0 12.1 12.1z"
69
+ ),
70
+ alignRight: svg(
71
+ "0 0 448 512",
72
+ "M16 224h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm416 192H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm3.17-384H172.83A12.82 12.82 0 0 0 160 44.83v38.34A12.82 12.82 0 0 0 172.83 96h262.34A12.82 12.82 0 0 0 448 83.17V44.83A12.82 12.82 0 0 0 435.17 32zm0 256H172.83A12.82 12.82 0 0 0 160 300.83v38.34A12.82 12.82 0 0 0 172.83 352h262.34A12.82 12.82 0 0 0 448 339.17v-38.34A12.82 12.82 0 0 0 435.17 288z"
73
+ ),
74
+ listUl: svg(
75
+ "0 0 512 512",
76
+ "M48 48a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 16H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z"
77
+ ),
78
+ listOl: svg(
79
+ "0 0 512 512",
80
+ "M61.77 401l17.5-20.15a19.92 19.92 0 0 0 5.07-14.19v-3.31C84.34 356 80.5 352 73 352H16a8 8 0 0 0-8 8v16a8 8 0 0 0 8 8h22.83a157.41 157.41 0 0 0-11 12.31l-5.61 7c-4 5.07-5.25 10.13-2.8 14.88l1.05 1.93c3 5.76 6.29 7.88 12.25 7.88h4.73c10.33 0 15.94 2.44 15.94 9.09 0 4.72-4.2 8.22-14.36 8.22a41.54 41.54 0 0 1-15.47-3.12c-6.49-3.88-11.74-3.5-15.6 3.12l-5.59 9.31c-3.72 6.13-3.19 11.72 2.63 15.94 7.71 4.69 20.38 9.44 37 9.44 34.16 0 48.5-22.75 48.5-44.12-.03-14.38-9.12-29.76-28.73-34.88zM496 224H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM16 160h64a8 8 0 0 0 8-8v-16a8 8 0 0 0-8-8H64V40a8 8 0 0 0-8-8H32a8 8 0 0 0-7.14 4.42l-8 16A8 8 0 0 0 24 64h8v64H16a8 8 0 0 0-8 8v16a8 8 0 0 0 8 8zm-3.91 160H80a8 8 0 0 0 8-8v-16a8 8 0 0 0-8-8H41.32c3.29-10.29 48.34-18.68 48.34-56.44 0-29.06-25-39.56-44.47-39.56-21.36 0-33.8 10-40.46 18.75-4.37 5.59-3 10.84 2.8 15.37l8.58 6.88c5.61 4.56 11 2.47 16.12-2.44a13.44 13.44 0 0 1 9.46-3.84c3.33 0 9.28 1.56 9.28 8.75C51 248.19 0 257.31 0 304.59v4C0 316 5.08 320 12.09 320z"
81
+ ),
82
+ link: svg(
83
+ "0 0 512 512",
84
+ "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z"
85
+ ),
86
+ eraser: svg(
87
+ "0 0 512 512",
88
+ "M497.941 273.941c18.745-18.745 18.745-49.137 0-67.882l-160-160c-18.745-18.745-49.136-18.746-67.883 0l-256 256c-18.745 18.745-18.745 49.137 0 67.882l96 96A48.004 48.004 0 0 0 144 480h356c6.627 0 12-5.373 12-12v-40c0-6.627-5.373-12-12-12H355.883l142.058-142.059zm-302.627-62.627l137.373 137.373L265.373 416H150.628l-80-80 124.686-124.686z"
89
+ ),
90
+ table: svg(
91
+ "0 0 512 512",
92
+ "M464 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V80c0-26.51-21.49-48-48-48zM224 416H64v-96h160v96zm0-160H64v-96h160v96zm224 160H288v-96h160v96zm0-160H288v-96h160v96z"
93
+ ),
94
+ outdent: svg(
95
+ "0 0 448 512",
96
+ "M100.69 363.29c10 10 27.31 2.93 27.31-11.31V160c0-14.32-17.33-21.31-27.31-11.31l-96 96a16 16 0 0 0 0 22.62zM432 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm3.17-128H204.83A12.82 12.82 0 0 0 192 300.83v38.34A12.82 12.82 0 0 0 204.83 352h230.34A12.82 12.82 0 0 0 448 339.17v-38.34A12.82 12.82 0 0 0 435.17 288zm0-128H204.83A12.82 12.82 0 0 0 192 172.83v38.34A12.82 12.82 0 0 0 204.83 224h230.34A12.82 12.82 0 0 0 448 211.17v-38.34A12.82 12.82 0 0 0 435.17 160zM432 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
97
+ ),
98
+ indent: svg(
99
+ "0 0 448 512",
100
+ "M27.31 363.3l96-96a16 16 0 0 0 0-22.62l-96-96C17.27 138.66 0 145.78 0 160v192c0 14.31 17.33 21.3 27.31 11.3zM432 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm3.17-128H204.83A12.82 12.82 0 0 0 192 300.83v38.34A12.82 12.82 0 0 0 204.83 352h230.34A12.82 12.82 0 0 0 448 339.17v-38.34A12.82 12.82 0 0 0 435.17 288zm0-128H204.83A12.82 12.82 0 0 0 192 172.83v38.34A12.82 12.82 0 0 0 204.83 224h230.34A12.82 12.82 0 0 0 448 211.17v-38.34A12.82 12.82 0 0 0 435.17 160zM432 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
101
+ ),
102
+ alignJustify: svg(
103
+ "0 0 448 512",
104
+ "M432 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
105
+ ),
106
+ strikethrough: svg(
107
+ "0 0 512 512",
108
+ "M496 224H293.9l-87.17-26.83A43.55 43.55 0 0 1 219.55 112h66.79A49.89 49.89 0 0 1 331 139.58a16 16 0 0 0 21.46 7.15l42.94-21.47a16 16 0 0 0 7.16-21.46l-.53-1A128 128 0 0 0 287.51 32h-68a123.68 123.68 0 0 0-123 135.64c2 20.89 10.1 39.83 21.78 56.36H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h480a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm-180.24 96A43 43 0 0 1 336 356.45 43.59 43.59 0 0 1 292.45 400h-66.79A49.89 49.89 0 0 1 181 372.42a16 16 0 0 0-21.46-7.15l-42.94 21.47a16 16 0 0 0-7.16 21.46l.53 1A128 128 0 0 0 224.49 480h68a123.68 123.68 0 0 0 123-135.64 114.25 114.25 0 0 0-5.34-24.36z"
109
+ ),
110
+ subscript: svg(
111
+ "0 0 512 512",
112
+ "M496 448h-16V304a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 352h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z"
113
+ ),
114
+ superscript: svg(
115
+ "0 0 512 512",
116
+ "M496 160h-16V16a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 64h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z"
117
+ ),
118
+ ellipsis: svg(
119
+ "0 0 512 512",
120
+ "M328 256c0 39.8-32.2 72-72 72s-72-32.2-72-72 32.2-72 72-72 72 32.2 72 72zm104-72c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72zm-352 0c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72z"
121
+ ),
122
+ quote: svg(
123
+ "0 0 512 512",
124
+ "M464 32H336c-26.5 0-48 21.5-48 48v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48zm-288 0H48C21.5 32 0 53.5 0 80v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48z"
125
+ ),
126
+ paragraph: svg(
127
+ "0 0 448 512",
128
+ "M448 48v32a16 16 0 0 1-16 16h-48v368a16 16 0 0 1-16 16h-32a16 16 0 0 1-16-16V96h-32v368a16 16 0 0 1-16 16h-32a16 16 0 0 1-16-16V352h-16a160 160 0 0 1 0-320h240a16 16 0 0 1 16 16z"
129
+ ),
130
+ code: svg(
131
+ "0 0 640 512",
132
+ "M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
133
+ ),
134
+ horizontalRule: svg(
135
+ "0 0 448 512",
136
+ "M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
137
+ ),
138
+ highlighter: svg(
139
+ "0 0 544 512",
140
+ "M0 479.98L99.92 512l35.45-35.45-67.04-67.04L0 479.98zm124.61-240.01a36.592 36.592 0 0 0-10.79 38.1l13.05 42.83-50.93 50.94 96.23 96.23 50.86-50.86 42.74 13.08c13.73 4.2 28.65-.01 38.15-10.78l35.55-41.64-173.34-173.34-41.52 35.44zm403.31-160.7l-63.2-63.2c-20.49-20.49-53.38-21.52-75.12-2.35L190.55 183.68l169.77 169.78L530.27 154.4c19.18-21.74 18.15-54.63-2.35-75.13z"
141
+ ),
142
+ palette: svg(
143
+ "0 0 512 512",
144
+ "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"
145
+ ),
146
+ check: svg(
147
+ "0 0 512 512",
148
+ "M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204 0z"
149
+ ),
150
+ // Link-popover actions (↩ apply / open in new tab / remove link).
151
+ linkApply: strokeSvg('<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>'),
152
+ linkOpen: strokeSvg(
153
+ '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>'
154
+ ),
155
+ linkRemove: strokeSvg(
156
+ '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>'
157
+ )
158
+ };
159
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
160
+ const clampByte = (value) => clamp(Math.round(value) || 0, 0, 255);
161
+ function hexToRgb(input) {
162
+ let hex = input.trim().replace(/^#/, "").toLowerCase();
163
+ if (/^[0-9a-f]{3}$/.test(hex)) {
164
+ hex = hex.split("").map((char) => char + char).join("");
165
+ }
166
+ if (!/^[0-9a-f]{6}$/.test(hex)) return null;
167
+ return {
168
+ r: parseInt(hex.slice(0, 2), 16),
169
+ g: parseInt(hex.slice(2, 4), 16),
170
+ b: parseInt(hex.slice(4, 6), 16)
171
+ };
172
+ }
173
+ function rgbToHex({ r, g, b }) {
174
+ const part = (value) => clampByte(value).toString(16).padStart(2, "0");
175
+ return `#${part(r)}${part(g)}${part(b)}`;
176
+ }
177
+ function rgbToHsv({ r, g, b }) {
178
+ const rn = r / 255;
179
+ const gn = g / 255;
180
+ const bn = b / 255;
181
+ const max = Math.max(rn, gn, bn);
182
+ const min = Math.min(rn, gn, bn);
183
+ const delta = max - min;
184
+ let h = 0;
185
+ if (delta !== 0) {
186
+ if (max === rn) h = 60 * ((gn - bn) / delta % 6);
187
+ else if (max === gn) h = 60 * ((bn - rn) / delta + 2);
188
+ else h = 60 * ((rn - gn) / delta + 4);
189
+ }
190
+ if (h < 0) h += 360;
191
+ return { h, s: max === 0 ? 0 : delta / max, v: max };
192
+ }
193
+ function hsvToRgb({ h, s, v }) {
194
+ const c = v * s;
195
+ const x = c * (1 - Math.abs(h / 60 % 2 - 1));
196
+ const m = v - c;
197
+ let rn = 0;
198
+ let gn = 0;
199
+ let bn = 0;
200
+ if (h < 60) [rn, gn, bn] = [c, x, 0];
201
+ else if (h < 120) [rn, gn, bn] = [x, c, 0];
202
+ else if (h < 180) [rn, gn, bn] = [0, c, x];
203
+ else if (h < 240) [rn, gn, bn] = [0, x, c];
204
+ else if (h < 300) [rn, gn, bn] = [x, 0, c];
205
+ else [rn, gn, bn] = [c, 0, x];
206
+ return {
207
+ r: clampByte((rn + m) * 255),
208
+ g: clampByte((gn + m) * 255),
209
+ b: clampByte((bn + m) * 255)
210
+ };
211
+ }
212
+ const RGB_PATTERN = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i;
213
+ function parseCssColor(input) {
214
+ const value = input.trim().toLowerCase();
215
+ if (value === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
216
+ const fromHex = hexToRgb(value);
217
+ if (fromHex) return { ...fromHex, a: 1 };
218
+ const match = RGB_PATTERN.exec(value);
219
+ if (!match) return null;
220
+ return {
221
+ r: clampByte(Number(match[1])),
222
+ g: clampByte(Number(match[2])),
223
+ b: clampByte(Number(match[3])),
224
+ a: match[4] === void 0 ? 1 : clamp(Number(match[4]), 0, 1)
225
+ };
226
+ }
227
+ const clamp01 = (value) => Math.min(1, Math.max(0, value));
228
+ function openColorPicker(options) {
229
+ const { doc } = options;
230
+ let hsv = rgbToHsv(hexToRgb(options.initialColor) ?? { r: 0, g: 0, b: 0 });
231
+ const backdrop = doc.createElement("div");
232
+ backdrop.className = "erte-picker-backdrop";
233
+ backdrop.innerHTML = `
234
+ <div class="erte-picker" role="dialog" aria-modal="true" aria-label="Colour picker">
235
+ <div class="erte-picker-header">
236
+ <span class="erte-picker-title">${options.title ?? "Color Picker"}</span>
237
+ <button type="button" class="erte-picker-close" aria-label="Close">&#10005;</button>
238
+ </div>
239
+ <div class="erte-picker-body">
240
+ <div class="erte-picker-sv"><div class="erte-picker-sv-dot"></div></div>
241
+ <div class="erte-picker-hue"><div class="erte-picker-hue-thumb"></div></div>
242
+ <div class="erte-picker-fields">
243
+ <label class="erte-picker-field">R<input data-channel="r" type="number" min="0" max="255" /></label>
244
+ <label class="erte-picker-field">G<input data-channel="g" type="number" min="0" max="255" /></label>
245
+ <label class="erte-picker-field">B<input data-channel="b" type="number" min="0" max="255" /></label>
246
+ <label class="erte-picker-field">#<input data-hex type="text" maxlength="7" spellcheck="false" /></label>
247
+ <div class="erte-picker-preview" aria-hidden="true"></div>
248
+ </div>
249
+ </div>
250
+ <div class="erte-picker-actions">
251
+ <button type="button" class="erte-modal-btn erte-modal-cancel" data-cancel>Cancel</button>
252
+ <button type="button" class="erte-modal-btn erte-modal-insert" data-save>Save</button>
253
+ </div>
254
+ </div>`;
255
+ const svEl = backdrop.querySelector(".erte-picker-sv");
256
+ const svDot = backdrop.querySelector(".erte-picker-sv-dot");
257
+ const hueEl = backdrop.querySelector(".erte-picker-hue");
258
+ const hueThumb = backdrop.querySelector(".erte-picker-hue-thumb");
259
+ const rInput = backdrop.querySelector('input[data-channel="r"]');
260
+ const gInput = backdrop.querySelector('input[data-channel="g"]');
261
+ const bInput = backdrop.querySelector('input[data-channel="b"]');
262
+ const hexInput = backdrop.querySelector("input[data-hex]");
263
+ const preview = backdrop.querySelector(".erte-picker-preview");
264
+ const saveBtn = backdrop.querySelector("[data-save]");
265
+ const cancelBtn = backdrop.querySelector("[data-cancel]");
266
+ const closeBtn = backdrop.querySelector(".erte-picker-close");
267
+ if (!svEl || !svDot || !hueEl || !hueThumb || !rInput || !gInput || !bInput || !hexInput || !preview || !saveBtn || !cancelBtn || !closeBtn) {
268
+ return () => void 0;
269
+ }
270
+ const close = () => {
271
+ doc.removeEventListener("keydown", onKeyDown, true);
272
+ backdrop.remove();
273
+ };
274
+ const cancel = () => {
275
+ options.onCancel?.();
276
+ close();
277
+ };
278
+ const onKeyDown = (event) => {
279
+ if (event.key === "Escape") {
280
+ event.stopPropagation();
281
+ cancel();
282
+ }
283
+ };
284
+ const render = (except) => {
285
+ const rgb = hsvToRgb(hsv);
286
+ const hex = rgbToHex(rgb);
287
+ svEl.style.background = `linear-gradient(to top, #000, rgba(0, 0, 0, 0)), linear-gradient(to right, #fff, rgba(255, 255, 255, 0)), hsl(${Math.round(hsv.h)}, 100%, 50%)`;
288
+ svDot.style.left = `${hsv.s * 100}%`;
289
+ svDot.style.top = `${(1 - hsv.v) * 100}%`;
290
+ hueThumb.style.top = `${hsv.h / 360 * 100}%`;
291
+ preview.style.background = hex;
292
+ if (rInput !== except) rInput.value = String(rgb.r);
293
+ if (gInput !== except) gInput.value = String(rgb.g);
294
+ if (bInput !== except) bInput.value = String(rgb.b);
295
+ if (hexInput !== except) hexInput.value = hex;
296
+ };
297
+ const bindDrag = (el, update) => {
298
+ el.addEventListener("pointerdown", (event) => {
299
+ event.preventDefault();
300
+ try {
301
+ el.setPointerCapture(event.pointerId);
302
+ } catch {
303
+ }
304
+ update(event);
305
+ const move = (moveEvent) => update(moveEvent);
306
+ const end = () => {
307
+ el.removeEventListener("pointermove", move);
308
+ el.removeEventListener("pointerup", end);
309
+ el.removeEventListener("pointercancel", end);
310
+ };
311
+ el.addEventListener("pointermove", move);
312
+ el.addEventListener("pointerup", end);
313
+ el.addEventListener("pointercancel", end);
314
+ });
315
+ };
316
+ bindDrag(svEl, (event) => {
317
+ const rect = svEl.getBoundingClientRect();
318
+ hsv = {
319
+ h: hsv.h,
320
+ s: clamp01((event.clientX - rect.left) / rect.width),
321
+ v: 1 - clamp01((event.clientY - rect.top) / rect.height)
322
+ };
323
+ render();
324
+ });
325
+ bindDrag(hueEl, (event) => {
326
+ const rect = hueEl.getBoundingClientRect();
327
+ hsv = { ...hsv, h: clamp01((event.clientY - rect.top) / rect.height) * 360 };
328
+ render();
329
+ });
330
+ [rInput, gInput, bInput].forEach((input) => {
331
+ input.addEventListener("input", () => {
332
+ hsv = rgbToHsv({
333
+ r: clampByte(Number(rInput.value)),
334
+ g: clampByte(Number(gInput.value)),
335
+ b: clampByte(Number(bInput.value))
336
+ });
337
+ render(input);
338
+ });
339
+ });
340
+ hexInput.addEventListener("input", () => {
341
+ const rgb = hexToRgb(hexInput.value);
342
+ if (!rgb) return;
343
+ hsv = rgbToHsv(rgb);
344
+ render(hexInput);
345
+ });
346
+ saveBtn.addEventListener("click", (event) => {
347
+ event.preventDefault();
348
+ options.onSave(rgbToHex(hsvToRgb(hsv)));
349
+ close();
350
+ });
351
+ cancelBtn.addEventListener("click", (event) => {
352
+ event.preventDefault();
353
+ cancel();
354
+ });
355
+ closeBtn.addEventListener("click", (event) => {
356
+ event.preventDefault();
357
+ cancel();
358
+ });
359
+ backdrop.addEventListener("mousedown", (event) => {
360
+ if (event.target === backdrop) cancel();
361
+ });
362
+ doc.addEventListener("keydown", onKeyDown, true);
363
+ doc.body.appendChild(backdrop);
364
+ render();
365
+ hexInput.focus();
366
+ hexInput.select();
367
+ return close;
368
+ }
369
+ const validateLinkUrl = (raw) => {
370
+ const value = raw.trim();
371
+ if (!value) return { valid: false, normalized: "" };
372
+ if (/^https?:\/\//i.test(value)) {
373
+ return { valid: /^https?:\/\/\S+$/i.test(value), normalized: value };
374
+ }
375
+ if (/^mailto:/i.test(value)) {
376
+ return { valid: /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(value), normalized: value };
377
+ }
378
+ if (/^tel:/i.test(value)) {
379
+ return { valid: /^tel:\+?[\d\s().-]{3,}$/.test(value), normalized: value };
380
+ }
381
+ if (/^(\/|\.\/|\.\.\/|#|\?)\S*$/.test(value)) {
382
+ return { valid: true, normalized: value };
383
+ }
384
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
385
+ return { valid: true, normalized: `mailto:${value}` };
386
+ }
387
+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?([/?#]\S*)?$/i.test(value)) {
388
+ return { valid: true, normalized: `https://${value}` };
389
+ }
390
+ return { valid: false, normalized: value };
391
+ };
392
+ const CLOSE_ANIMATION_MS = 160;
393
+ function openLinkPopover(options) {
394
+ const { doc, anchor } = options;
395
+ const win = doc.defaultView;
396
+ const popover = doc.createElement("div");
397
+ popover.className = "erte-link-popover";
398
+ popover.setAttribute("role", "dialog");
399
+ popover.setAttribute("aria-label", options.hasLink ? "Edit link" : "Insert link");
400
+ popover.innerHTML = `
401
+ <div class="erte-link-row">
402
+ <input class="erte-link-input" type="text" placeholder="Paste a link..."
403
+ spellcheck="false" autocomplete="off" aria-label="Link URL" />
404
+ <button type="button" class="erte-link-btn" data-apply title="Apply link" aria-label="Apply link">${icons.linkApply}</button>
405
+ <span class="erte-link-sep" aria-hidden="true"></span>
406
+ <button type="button" class="erte-link-btn" data-open title="Open link in new tab" aria-label="Open link in new tab">${icons.linkOpen}</button>
407
+ <button type="button" class="erte-link-btn" data-remove title="Remove link" aria-label="Remove link">${icons.linkRemove}</button>
408
+ </div>
409
+ <div class="erte-link-error" role="alert" hidden>Enter a valid URL (https://, mailto:, tel: or a relative path).</div>`;
410
+ const input = popover.querySelector(".erte-link-input");
411
+ const applyBtn = popover.querySelector("[data-apply]");
412
+ const openBtn = popover.querySelector("[data-open]");
413
+ const removeBtn = popover.querySelector("[data-remove]");
414
+ const errorEl = popover.querySelector(".erte-link-error");
415
+ if (!input || !applyBtn || !openBtn || !removeBtn || !errorEl || !win) {
416
+ return () => void 0;
417
+ }
418
+ let parentDoc = null;
419
+ try {
420
+ parentDoc = win.frameElement?.ownerDocument ?? null;
421
+ } catch {
422
+ parentDoc = null;
423
+ }
424
+ let closed = false;
425
+ const close = (reason) => {
426
+ if (closed) return;
427
+ closed = true;
428
+ doc.removeEventListener("mousedown", onDocMouseDown, true);
429
+ doc.removeEventListener("keydown", onDocKeyDown, true);
430
+ parentDoc?.removeEventListener("mousedown", onParentMouseDown, true);
431
+ popover.classList.remove("is-open");
432
+ win.setTimeout(() => popover.remove(), CLOSE_ANIMATION_MS);
433
+ options.onClose?.(reason);
434
+ };
435
+ const onDocMouseDown = (event) => {
436
+ const target = event.target;
437
+ if (target && (popover.contains(target) || anchor.contains(target))) return;
438
+ close("outside");
439
+ };
440
+ const onParentMouseDown = () => close("outside");
441
+ const onDocKeyDown = (event) => {
442
+ if (event.key === "Escape") {
443
+ event.stopPropagation();
444
+ close("escape");
445
+ }
446
+ };
447
+ const update = () => {
448
+ const { valid } = validateLinkUrl(input.value);
449
+ const isEmpty = input.value.trim() === "";
450
+ applyBtn.disabled = !valid;
451
+ openBtn.disabled = !valid;
452
+ removeBtn.disabled = !options.hasLink;
453
+ const showError = !isEmpty && !valid;
454
+ errorEl.hidden = !showError;
455
+ popover.classList.toggle("is-invalid", showError);
456
+ };
457
+ const apply = () => {
458
+ const { valid, normalized } = validateLinkUrl(input.value);
459
+ if (!valid) return;
460
+ options.onApply(normalized);
461
+ close("apply");
462
+ };
463
+ input.addEventListener("input", update);
464
+ input.addEventListener("keydown", (event) => {
465
+ if (event.key === "Enter") {
466
+ event.preventDefault();
467
+ apply();
468
+ }
469
+ });
470
+ [applyBtn, openBtn, removeBtn].forEach(
471
+ (btn) => btn.addEventListener("mousedown", (event) => event.preventDefault())
472
+ );
473
+ applyBtn.addEventListener("click", apply);
474
+ openBtn.addEventListener("click", () => {
475
+ const { valid, normalized } = validateLinkUrl(input.value);
476
+ if (valid) win.open(normalized, "_blank", "noopener,noreferrer");
477
+ });
478
+ removeBtn.addEventListener("click", () => {
479
+ options.onRemove();
480
+ close("remove");
481
+ });
482
+ popover.addEventListener("keydown", (event) => {
483
+ if (event.key !== "Tab") return;
484
+ const focusables = [input, applyBtn, openBtn, removeBtn].filter(
485
+ (el) => !(el instanceof HTMLButtonElement) || !el.disabled
486
+ );
487
+ const first = focusables[0];
488
+ const last = focusables[focusables.length - 1];
489
+ if (!first || !last) return;
490
+ if (event.shiftKey && doc.activeElement === first) {
491
+ event.preventDefault();
492
+ last.focus();
493
+ } else if (!event.shiftKey && doc.activeElement === last) {
494
+ event.preventDefault();
495
+ first.focus();
496
+ }
497
+ });
498
+ doc.body.appendChild(popover);
499
+ const anchorRect = anchor.getBoundingClientRect();
500
+ const margin = 8;
501
+ const maxLeft = doc.documentElement.clientWidth - popover.offsetWidth - margin;
502
+ popover.style.top = `${Math.round(anchorRect.bottom + margin)}px`;
503
+ popover.style.left = `${Math.round(Math.min(Math.max(anchorRect.left, margin), Math.max(margin, maxLeft)))}px`;
504
+ input.value = options.initialUrl;
505
+ update();
506
+ input.focus();
507
+ if (options.initialUrl) input.select();
508
+ doc.addEventListener("mousedown", onDocMouseDown, true);
509
+ doc.addEventListener("keydown", onDocKeyDown, true);
510
+ parentDoc?.addEventListener("mousedown", onParentMouseDown, true);
511
+ win.requestAnimationFrame(() => {
512
+ win.requestAnimationFrame(() => popover.classList.add("is-open"));
513
+ });
514
+ return () => close("programmatic");
515
+ }
516
+ const EDGE = 4;
517
+ const MIN_COL_WIDTH = 40;
518
+ const MIN_ROW_HEIGHT = 24;
519
+ const TABLE_WIDTH_CSS = `calc(100% - ${TABLE_ACTION_SPACE}px)`;
520
+ const getCols = (table) => Array.from(table.querySelectorAll(":scope > colgroup > col"));
521
+ const isEditableTable = (table) => table.dataset.erteTable === "true" || table.getAttribute("role") !== "presentation";
522
+ const ensureTableSetup = (doc, table) => {
523
+ const firstRow = table.rows[0];
524
+ if (!firstRow) return [];
525
+ let cols = getCols(table);
526
+ if (cols.length !== firstRow.cells.length) {
527
+ table.querySelector(":scope > colgroup")?.remove();
528
+ const colgroup = doc.createElement("colgroup");
529
+ cols = Array.from(firstRow.cells).map(() => doc.createElement("col"));
530
+ cols.forEach((col) => colgroup.appendChild(col));
531
+ table.insertBefore(colgroup, table.firstChild);
532
+ }
533
+ table.style.tableLayout = "fixed";
534
+ if (!table.style.width || table.style.width === "100%") {
535
+ table.style.width = TABLE_WIDTH_CSS;
536
+ }
537
+ return cols;
538
+ };
539
+ const materializeColWidths = (table, cols) => {
540
+ const firstRow = table.rows[0];
541
+ if (!firstRow) return;
542
+ cols.forEach((col, index) => {
543
+ const cell = firstRow.cells[index];
544
+ if (cell) col.style.width = `${Math.round(cell.getBoundingClientRect().width)}px`;
545
+ });
546
+ };
547
+ function initTableTools({ doc, onContentChange }) {
548
+ const editor = doc.getElementById("editor");
549
+ const scroll = doc.getElementById("editor-scroll");
550
+ if (!editor || !scroll) return () => void 0;
551
+ const makeAddButton = (extraClass, title) => {
552
+ const btn = doc.createElement("button");
553
+ btn.type = "button";
554
+ btn.className = `erte-table-add ${extraClass}`;
555
+ btn.title = title;
556
+ btn.setAttribute("aria-label", title);
557
+ btn.textContent = "+";
558
+ btn.setAttribute("contenteditable", "false");
559
+ scroll.appendChild(btn);
560
+ return btn;
561
+ };
562
+ const addColBtn = makeAddButton("erte-table-add--col", "Add column");
563
+ const addRowBtn = makeAddButton("erte-table-add--row", "Add row");
564
+ const win = doc.defaultView ?? window;
565
+ const HIDE_DELAY = 200;
566
+ const ADD_BTN_SIZE = 16;
567
+ let activeTable = null;
568
+ let hideTimer = null;
569
+ const cancelHide = () => {
570
+ if (hideTimer !== null) {
571
+ win.clearTimeout(hideTimer);
572
+ hideTimer = null;
573
+ }
574
+ };
575
+ const hideAddButtons = () => {
576
+ cancelHide();
577
+ addColBtn.classList.remove("is-visible");
578
+ addRowBtn.classList.remove("is-visible");
579
+ activeTable = null;
580
+ };
581
+ const scheduleHide = () => {
582
+ if (hideTimer !== null) return;
583
+ hideTimer = win.setTimeout(() => {
584
+ hideTimer = null;
585
+ addColBtn.classList.remove("is-visible");
586
+ addRowBtn.classList.remove("is-visible");
587
+ activeTable = null;
588
+ }, HIDE_DELAY);
589
+ };
590
+ addColBtn.addEventListener("mouseenter", cancelHide);
591
+ addRowBtn.addEventListener("mouseenter", cancelHide);
592
+ const GAP = 8;
593
+ const positionAddButtons = (table, showCol, showRow) => {
594
+ const tRect = table.getBoundingClientRect();
595
+ const sRect = scroll.getBoundingClientRect();
596
+ const offsetLeft = tRect.left - sRect.left + scroll.scrollLeft;
597
+ const offsetTop = tRect.top - sRect.top + scroll.scrollTop;
598
+ if (showCol) {
599
+ const centred = offsetLeft + tRect.width + (TABLE_ACTION_SPACE - ADD_BTN_SIZE) / 2;
600
+ const maxLeft = scroll.scrollLeft + scroll.clientWidth - ADD_BTN_SIZE - 6;
601
+ addColBtn.style.left = `${Math.round(Math.min(centred, maxLeft))}px`;
602
+ addColBtn.style.top = `${offsetTop}px`;
603
+ addColBtn.style.width = `${ADD_BTN_SIZE}px`;
604
+ addColBtn.style.height = `${tRect.height}px`;
605
+ }
606
+ addColBtn.classList.toggle("is-visible", showCol);
607
+ if (showRow) {
608
+ addRowBtn.style.left = `${offsetLeft}px`;
609
+ addRowBtn.style.top = `${offsetTop + tRect.height + GAP}px`;
610
+ addRowBtn.style.width = `${tRect.width}px`;
611
+ addRowBtn.style.height = `${ADD_BTN_SIZE}px`;
612
+ }
613
+ addRowBtn.classList.toggle("is-visible", showRow);
614
+ };
615
+ const addColumn = () => {
616
+ const table = activeTable;
617
+ if (!table) return;
618
+ const cols = ensureTableSetup(doc, table);
619
+ materializeColWidths(table, cols);
620
+ const fitWidth = table.getBoundingClientRect().width;
621
+ const widths = cols.map((col) => parseFloat(col.style.width) || 0);
622
+ const currentTotal = widths.reduce((a, b) => a + b, 0) || fitWidth;
623
+ const newColCount = cols.length + 1;
624
+ const idealNew = fitWidth / newColCount;
625
+ const colgroup = table.querySelector(":scope > colgroup");
626
+ const newCol = doc.createElement("col");
627
+ colgroup?.appendChild(newCol);
628
+ if (idealNew >= MIN_COL_WIDTH) {
629
+ const scale = (fitWidth - idealNew) / currentTotal;
630
+ cols.forEach((col, i) => {
631
+ col.style.width = `${Math.round((widths[i] ?? 0) * scale)}px`;
632
+ });
633
+ newCol.style.width = `${Math.round(idealNew)}px`;
634
+ table.style.width = TABLE_WIDTH_CSS;
635
+ } else {
636
+ cols.forEach((col) => {
637
+ col.style.width = `${MIN_COL_WIDTH}px`;
638
+ });
639
+ newCol.style.width = `${MIN_COL_WIDTH}px`;
640
+ table.style.width = `${newColCount * MIN_COL_WIDTH}px`;
641
+ }
642
+ Array.from(table.rows).forEach((row) => {
643
+ const last = row.cells[row.cells.length - 1];
644
+ if (!last) return;
645
+ const cell = last.cloneNode(false);
646
+ cell.innerHTML = "&nbsp;";
647
+ row.appendChild(cell);
648
+ });
649
+ positionAddButtons(table, true, addRowBtn.classList.contains("is-visible"));
650
+ onContentChange();
651
+ };
652
+ const addRow = () => {
653
+ const table = activeTable;
654
+ if (!table) return;
655
+ const lastRow = table.rows[table.rows.length - 1];
656
+ if (!lastRow) return;
657
+ const newRow = lastRow.cloneNode(false);
658
+ newRow.style.height = "";
659
+ Array.from(lastRow.cells).forEach((cell) => {
660
+ const clone = cell.cloneNode(false);
661
+ clone.innerHTML = "&nbsp;";
662
+ newRow.appendChild(clone);
663
+ });
664
+ lastRow.parentElement?.appendChild(newRow);
665
+ positionAddButtons(table, addColBtn.classList.contains("is-visible"), true);
666
+ onContentChange();
667
+ };
668
+ addColBtn.addEventListener("click", addColumn);
669
+ addRowBtn.addEventListener("click", addRow);
670
+ let colZone = null;
671
+ let rowZone = null;
672
+ let dragging = false;
673
+ const clearHoverState = () => {
674
+ colZone = null;
675
+ rowZone = null;
676
+ if (editor.style.cursor) editor.style.cursor = "";
677
+ };
678
+ const onMouseMove = (event) => {
679
+ if (dragging) return;
680
+ const target = event.target;
681
+ if (target === addColBtn || target === addRowBtn) {
682
+ cancelHide();
683
+ return;
684
+ }
685
+ const cell = target?.closest?.("td, th");
686
+ const table = cell?.closest("table");
687
+ if (!cell || !table || !editor.contains(table) || !isEditableTable(table)) {
688
+ clearHoverState();
689
+ scheduleHide();
690
+ return;
691
+ }
692
+ cancelHide();
693
+ const rect = cell.getBoundingClientRect();
694
+ const row = cell.parentElement;
695
+ if (event.clientX >= rect.right - EDGE && cell.cellIndex < row.cells.length - 1) {
696
+ colZone = { table, leftIndex: cell.cellIndex };
697
+ } else if (event.clientX <= rect.left + EDGE && cell.cellIndex > 0) {
698
+ colZone = { table, leftIndex: cell.cellIndex - 1 };
699
+ } else {
700
+ colZone = null;
701
+ }
702
+ if (event.clientY >= rect.bottom - EDGE) {
703
+ rowZone = { row };
704
+ } else if (event.clientY <= rect.top + EDGE && row.rowIndex > 0) {
705
+ const prev = table.rows[row.rowIndex - 1];
706
+ rowZone = prev ? { row: prev } : null;
707
+ } else {
708
+ rowZone = null;
709
+ }
710
+ editor.style.cursor = colZone ? "ew-resize" : rowZone ? "ns-resize" : "";
711
+ activeTable = table;
712
+ const isLastCol = cell.cellIndex === row.cells.length - 1;
713
+ const isLastRow = row.rowIndex === table.rows.length - 1;
714
+ if (isLastCol || isLastRow) positionAddButtons(table, isLastCol, isLastRow);
715
+ else scheduleHide();
716
+ };
717
+ const onPointerDown = (event) => {
718
+ if (event.button !== 0) return;
719
+ const downTarget = event.target;
720
+ if (downTarget && (addColBtn.contains(downTarget) || addRowBtn.contains(downTarget))) return;
721
+ if (!colZone && !rowZone) return;
722
+ event.preventDefault();
723
+ dragging = true;
724
+ cancelHide();
725
+ hideAddButtons();
726
+ const startX = event.clientX;
727
+ const startY = event.clientY;
728
+ let moveHandler = () => void 0;
729
+ if (colZone) {
730
+ const { table, leftIndex } = colZone;
731
+ const cols = ensureTableSetup(doc, table);
732
+ materializeColWidths(table, cols);
733
+ const leftCol = cols[leftIndex];
734
+ const rightCol = cols[leftIndex + 1];
735
+ const leftStart = leftCol ? parseFloat(leftCol.style.width) || 0 : 0;
736
+ const rightStart = rightCol ? parseFloat(rightCol.style.width) || 0 : 0;
737
+ const total = leftStart + rightStart;
738
+ editor.style.cursor = "ew-resize";
739
+ moveHandler = (e) => {
740
+ if (!leftCol || !rightCol) return;
741
+ const dx = e.clientX - startX;
742
+ const left = Math.min(Math.max(leftStart + dx, MIN_COL_WIDTH), total - MIN_COL_WIDTH);
743
+ leftCol.style.width = `${Math.round(left)}px`;
744
+ rightCol.style.width = `${Math.round(total - left)}px`;
745
+ };
746
+ } else if (rowZone) {
747
+ const { row } = rowZone;
748
+ const startHeight = row.getBoundingClientRect().height;
749
+ editor.style.cursor = "ns-resize";
750
+ moveHandler = (e) => {
751
+ const dy = e.clientY - startY;
752
+ row.style.height = `${Math.round(Math.max(MIN_ROW_HEIGHT, startHeight + dy))}px`;
753
+ };
754
+ }
755
+ const endHandler = () => {
756
+ doc.removeEventListener("pointermove", moveHandler);
757
+ doc.removeEventListener("pointerup", endHandler);
758
+ doc.removeEventListener("pointercancel", endHandler);
759
+ dragging = false;
760
+ clearHoverState();
761
+ onContentChange();
762
+ };
763
+ doc.addEventListener("pointermove", moveHandler);
764
+ doc.addEventListener("pointerup", endHandler);
765
+ doc.addEventListener("pointercancel", endHandler);
766
+ };
767
+ const onScroll = () => hideAddButtons();
768
+ const onMouseLeave = () => {
769
+ if (dragging) return;
770
+ clearHoverState();
771
+ scheduleHide();
772
+ };
773
+ scroll.addEventListener("mousemove", onMouseMove);
774
+ scroll.addEventListener("mouseleave", onMouseLeave);
775
+ scroll.addEventListener("pointerdown", onPointerDown, true);
776
+ scroll.addEventListener("scroll", onScroll, { passive: true });
777
+ return () => {
778
+ cancelHide();
779
+ scroll.removeEventListener("mousemove", onMouseMove);
780
+ scroll.removeEventListener("mouseleave", onMouseLeave);
781
+ scroll.removeEventListener("pointerdown", onPointerDown, true);
782
+ scroll.removeEventListener("scroll", onScroll);
783
+ addColBtn.remove();
784
+ addRowBtn.remove();
785
+ };
786
+ }
787
+ const applySpellcheckIgnore = (content, words) => words.reduce(
788
+ (html, word) => html.replace(new RegExp(escapeRegExp(word), "g"), `<span spellcheck="false">${word}</span>`),
789
+ content
790
+ );
791
+ const TOGGLE_COMMANDS = /* @__PURE__ */ new Set([
792
+ "bold",
793
+ "italic",
794
+ "underline",
795
+ "strikeThrough",
796
+ "subscript",
797
+ "superscript",
798
+ "justifyLeft",
799
+ "justifyCenter",
800
+ "justifyRight",
801
+ "justifyFull",
802
+ "insertUnorderedList",
803
+ "insertOrderedList"
804
+ ]);
805
+ const BLOCK_OPTIONS = [
806
+ { label: "Paragraph", tag: "p", optionClass: "" },
807
+ { label: "Heading 1", tag: "h1", optionClass: "erte-option--h1" },
808
+ { label: "Heading 2", tag: "h2", optionClass: "erte-option--h2" },
809
+ { label: "Heading 3", tag: "h3", optionClass: "erte-option--h3" },
810
+ { label: "Heading 4", tag: "h4", optionClass: "erte-option--h4" },
811
+ { label: "Heading 5", tag: "h5", optionClass: "erte-option--h5" },
812
+ { label: "Heading 6", tag: "h6", optionClass: "erte-option--h6" },
813
+ { label: "Preformatted", tag: "pre", optionClass: "erte-option--pre" }
814
+ ];
815
+ const ALIGN_OPTIONS = [
816
+ { key: "left", label: "Left", cmd: "justifyLeft", iconKey: "alignLeft" },
817
+ { key: "center", label: "Center", cmd: "justifyCenter", iconKey: "alignCenter" },
818
+ { key: "right", label: "Right", cmd: "justifyRight", iconKey: "alignRight" },
819
+ { key: "justify", label: "Justify", cmd: "justifyFull", iconKey: "alignJustify" }
820
+ ];
821
+ const ORDERED_LIST_STYLES = [
822
+ { key: "decimal", label: "Decimal", markers: ["1.", "2.", "3."], css: "decimal" },
823
+ { key: "lower-alpha", label: "Lower alpha", markers: ["a.", "b.", "c."], css: "lower-alpha" },
824
+ { key: "upper-alpha", label: "Upper alpha", markers: ["A.", "B.", "C."], css: "upper-alpha" },
825
+ { key: "lower-roman", label: "Lower roman", markers: ["i.", "ii.", "iii."], css: "lower-roman" },
826
+ { key: "upper-roman", label: "Upper roman", markers: ["I.", "II.", "III."], css: "upper-roman" },
827
+ {
828
+ key: "leading-zero",
829
+ label: "Leading zero",
830
+ markers: ["01.", "02.", "03."],
831
+ css: "decimal-leading-zero"
832
+ },
833
+ { key: "legal", label: "Legal (nested 1.1)", markers: ["1.1", "1.2", "1.3"], css: null }
834
+ ];
835
+ const BULLET_LIST_STYLES = [
836
+ { key: "disc", label: "Solid circle", markers: ["●", "●", "●"], css: "disc" },
837
+ { key: "circle", label: "Hollow circle", markers: ["○", "○", "○"], css: "circle" },
838
+ { key: "square", label: "Square", markers: ["▪", "▪", "▪"], css: "square" },
839
+ { key: "diamond", label: "Diamond", markers: ["◆", "◆", "◆"], css: '"◆ "' },
840
+ { key: "dash", label: "Dash", markers: ["–", "–", "–"], css: '"– "' },
841
+ { key: "arrow", label: "Arrow", markers: ["➤", "➤", "➤"], css: '"➤ "' },
842
+ { key: "check", label: "Checkmark", markers: ["✓", "✓", "✓"], css: '"✓ "' }
843
+ ];
844
+ const QUOTE_INLINE_STYLE = {
845
+ margin: "12px 0",
846
+ padding: "2px 0 2px 14px",
847
+ borderLeft: "3px solid #d5dbe2",
848
+ color: "#5f6368"
849
+ };
850
+ const CODE_BLOCK_INLINE_STYLE = {
851
+ background: "#f6f8fa",
852
+ border: "1px solid #e8ebee",
853
+ borderRadius: "8px",
854
+ padding: "12px 14px",
855
+ margin: "12px 0",
856
+ fontFamily: "ui-monospace, 'Cascadia Code', Consolas, monospace",
857
+ fontSize: "13px",
858
+ lineHeight: "1.55",
859
+ whiteSpace: "pre-wrap",
860
+ overflowX: "auto"
861
+ };
862
+ const HR_INLINE_STYLE = {
863
+ border: "none",
864
+ borderTop: "1px solid #d5dbe2",
865
+ margin: "18px 0"
866
+ };
867
+ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 32, 36, 48, 64, 72];
868
+ const DEFAULT_FONT_SIZE = 16;
869
+ const MIN_FONT_SIZE = 1;
870
+ const MAX_FONT_SIZE = 200;
871
+ const DEFAULT_TEXT_COLOR = "#1a1a1a";
872
+ const COLOR_PALETTE = [
873
+ { hex: "#bfedd2", name: "Light green" },
874
+ { hex: "#fbeeb8", name: "Light yellow" },
875
+ { hex: "#f8cac6", name: "Light red" },
876
+ { hex: "#eccafa", name: "Light purple" },
877
+ { hex: "#c2e0f4", name: "Light blue" },
878
+ { hex: "#b2ebf2", name: "Light cyan" },
879
+ { hex: "#2dc26b", name: "Green" },
880
+ { hex: "#f1c40f", name: "Yellow" },
881
+ { hex: "#e03e2d", name: "Red" },
882
+ { hex: "#b96ad9", name: "Purple" },
883
+ { hex: "#3598db", name: "Blue" },
884
+ { hex: "#00bcd4", name: "Cyan" },
885
+ { hex: "#169179", name: "Dark turquoise" },
886
+ { hex: "#e67e23", name: "Orange" },
887
+ { hex: "#ba372a", name: "Dark red" },
888
+ { hex: "#843fa1", name: "Dark purple" },
889
+ { hex: "#236fa1", name: "Dark blue" },
890
+ { hex: "#e91e63", name: "Pink" },
891
+ { hex: "#ecf0f1", name: "Light gray" },
892
+ { hex: "#ced4d9", name: "Medium gray" },
893
+ { hex: "#95a5a6", name: "Gray" },
894
+ { hex: "#7e8c8d", name: "Dark gray" },
895
+ { hex: "#34495e", name: "Navy blue" },
896
+ { hex: "#795548", name: "Brown" },
897
+ { hex: "#000000", name: "Black" },
898
+ { hex: "#434343", name: "Dark charcoal" },
899
+ { hex: "#666666", name: "Dim gray" },
900
+ { hex: "#b7b7b7", name: "Silver" },
901
+ { hex: "#f3f3f3", name: "Off white" },
902
+ { hex: "#ffffff", name: "White" }
903
+ ];
904
+ const MIN_EDITOR_WIDTH = 600;
905
+ const MIN_EDITOR_HEIGHT = 350;
906
+ const MAX_EDITOR_HEIGHT = 1200;
907
+ const RESIZE_ZONES = [
908
+ {
909
+ key: "right",
910
+ label: "Resize editor width",
911
+ cursor: "ew-resize",
912
+ edges: { h: "right", v: null },
913
+ placement: { top: 12, bottom: 12, right: 0, width: 6 }
914
+ },
915
+ {
916
+ key: "bottom-left",
917
+ label: "Resize editor",
918
+ cursor: "nesw-resize",
919
+ edges: { h: "left", v: "bottom" },
920
+ placement: { bottom: 0, left: 0, width: 12, height: 12 }
921
+ },
922
+ {
923
+ key: "bottom-right",
924
+ label: "Resize editor",
925
+ cursor: "nwse-resize",
926
+ edges: { h: "right", v: "bottom" },
927
+ placement: { bottom: 0, right: 0, width: 12, height: 12 }
928
+ }
929
+ ];
930
+ const clampNumber = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
931
+ function FlowTextEditor({
932
+ mailContent,
933
+ setMailContent,
934
+ resetMailContent = false,
935
+ showAiTools = false,
936
+ modalHeight = "",
937
+ spellcheckIgnoreWords = [],
938
+ onAiTextAction
939
+ }) {
940
+ const iframeRef = react.useRef(null);
941
+ const [doc, setDoc] = react.useState(null);
942
+ const [savedRange, setSavedRange] = react.useState(null);
943
+ const linkPopoverCloseRef = react.useRef(null);
944
+ const [isErrorToastHidden, setIsErrorToastHidden] = react.useState(true);
945
+ const [toastErrorMessage, setToastErrorMessage] = react.useState(null);
946
+ const [loading, setLoading] = react.useState(false);
947
+ const isInternalEdit = react.useRef(false);
948
+ const toggleButtonsRef = react.useRef([]);
949
+ const blockToggleButtonsRef = react.useRef([]);
950
+ const blockTriggerRef = react.useRef(null);
951
+ const blockMenuRef = react.useRef(null);
952
+ const sizeLabelRef = react.useRef(null);
953
+ const sizeMenuRef = react.useRef(null);
954
+ const alignIconRef = react.useRef(null);
955
+ const alignMenuRef = react.useRef(null);
956
+ const olMenuRef = react.useRef(null);
957
+ const ulMenuRef = react.useRef(null);
958
+ const dropdownRangeRef = react.useRef(null);
959
+ const currentFontSizeRef = react.useRef(DEFAULT_FONT_SIZE);
960
+ const textColorBarRef = react.useRef(null);
961
+ const bgColorBarRef = react.useRef(null);
962
+ const textColorMenuRef = react.useRef(null);
963
+ const bgColorMenuRef = react.useRef(null);
964
+ const currentTextColorRef = react.useRef(DEFAULT_TEXT_COLOR);
965
+ const currentBgColorRef = react.useRef(null);
966
+ const lastCustomColorRef = react.useRef({
967
+ text: null,
968
+ bg: null
969
+ });
970
+ const containerRef = react.useRef(null);
971
+ const resizeCleanupRef = react.useRef(null);
972
+ const [size, setSize] = react.useState({ width: null, height: null });
973
+ const callAiTextAction = async (action, text, wordCount, toneType) => {
974
+ if (!onAiTextAction) {
975
+ console.warn(
976
+ "[FlowTextEditor] `onAiTextAction` prop was not provided; AI tools are disabled."
977
+ );
978
+ return null;
979
+ }
980
+ setLoading(true);
981
+ try {
982
+ const result = await onAiTextAction({ action, text, wordCount, toneType });
983
+ return result ?? null;
984
+ } catch (error) {
985
+ console.error("[FlowTextEditor] AI request failed:", error);
986
+ return null;
987
+ } finally {
988
+ setLoading(false);
989
+ }
990
+ };
991
+ const handleErrorTextSelection = () => {
992
+ setToastErrorMessage("Please select some text before using AI tools.");
993
+ setIsErrorToastHidden(false);
994
+ setTimeout(() => {
995
+ setIsErrorToastHidden(true);
996
+ setToastErrorMessage(null);
997
+ }, 3e3);
998
+ };
999
+ const saveSelection = () => {
1000
+ if (!doc) return;
1001
+ const sel = doc.getSelection();
1002
+ if (sel && sel.rangeCount > 0) {
1003
+ setSavedRange(sel.getRangeAt(0));
1004
+ }
1005
+ };
1006
+ const restoreSelection = () => {
1007
+ if (!doc || !savedRange) return;
1008
+ const sel = doc.getSelection();
1009
+ if (!sel) return;
1010
+ sel.removeAllRanges();
1011
+ sel.addRange(savedRange);
1012
+ };
1013
+ const updateToolbarActiveState = () => {
1014
+ if (!doc) return;
1015
+ for (const { el, cmd } of toggleButtonsRef.current) {
1016
+ let active = false;
1017
+ try {
1018
+ active = doc.queryCommandState(cmd);
1019
+ } catch {
1020
+ active = false;
1021
+ }
1022
+ el.classList.toggle("is-active", active);
1023
+ el.setAttribute("aria-pressed", active ? "true" : "false");
1024
+ }
1025
+ for (const { el, detect } of blockToggleButtonsRef.current) {
1026
+ const active = detect();
1027
+ el.classList.toggle("is-active", active);
1028
+ el.setAttribute("aria-pressed", active ? "true" : "false");
1029
+ }
1030
+ };
1031
+ const closestFromNode = (start, match) => {
1032
+ if (!doc) return null;
1033
+ const editor = doc.getElementById("editor");
1034
+ if (!editor || !start || !editor.contains(start)) return null;
1035
+ let node = start;
1036
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
1037
+ while (node && node !== editor) {
1038
+ if (node.nodeType === Node.ELEMENT_NODE && match(node)) {
1039
+ return node;
1040
+ }
1041
+ node = node.parentNode;
1042
+ }
1043
+ return null;
1044
+ };
1045
+ const closestFromSelection = (match) => {
1046
+ const sel = doc?.getSelection();
1047
+ return closestFromNode(sel?.anchorNode ?? null, match);
1048
+ };
1049
+ const listStyleKeyOf = (list, kind) => {
1050
+ const dataStyle = list.dataset.erteList;
1051
+ if (dataStyle) return dataStyle;
1052
+ const raw = list.style.listStyleType.trim();
1053
+ if (!raw) return kind === "ol" ? "decimal" : "disc";
1054
+ const styles = kind === "ol" ? ORDERED_LIST_STYLES : BULLET_LIST_STYLES;
1055
+ const normalize = (value) => value.replace(/["']/g, "").trim();
1056
+ const match = styles.find(
1057
+ (option) => option.css !== null && normalize(option.css) === normalize(raw)
1058
+ );
1059
+ return match?.key ?? "custom";
1060
+ };
1061
+ const markListMenu = (kind) => {
1062
+ const menu = kind === "ol" ? olMenuRef.current : ulMenuRef.current;
1063
+ if (!menu) return;
1064
+ const list = closestFromSelection((el) => el.tagName === kind.toUpperCase());
1065
+ const activeKey = list ? listStyleKeyOf(list, kind) : null;
1066
+ menu.querySelectorAll("[data-list-style]").forEach((el) => {
1067
+ el.classList.toggle("is-selected", el.dataset.listStyle === activeKey);
1068
+ });
1069
+ };
1070
+ const syncToolbarState = () => {
1071
+ if (!doc) return;
1072
+ updateToolbarActiveState();
1073
+ let block = "";
1074
+ try {
1075
+ block = String(doc.queryCommandValue("formatBlock") || "").toLowerCase();
1076
+ } catch {
1077
+ block = "";
1078
+ }
1079
+ let activeTag = BLOCK_OPTIONS.find((option) => option.tag === block)?.tag ?? "p";
1080
+ const special = closestFromSelection(
1081
+ (el) => el.tagName === "PRE" && el.hasAttribute("data-erte-code") || el.tagName === "BLOCKQUOTE" && el.hasAttribute("data-erte-quote")
1082
+ );
1083
+ if (special) activeTag = special.tagName === "PRE" ? "codeblock" : "blockquote";
1084
+ const matched = BLOCK_OPTIONS.find((option) => option.tag === activeTag);
1085
+ if (blockTriggerRef.current) {
1086
+ blockTriggerRef.current.title = matched ? `Text styles: ${matched.label}` : "Text styles";
1087
+ }
1088
+ blockMenuRef.current?.querySelectorAll("[data-tag]").forEach((el) => {
1089
+ el.classList.toggle("is-selected", el.dataset.tag === activeTag);
1090
+ });
1091
+ let alignKey = "left";
1092
+ for (const option of ALIGN_OPTIONS) {
1093
+ try {
1094
+ if (doc.queryCommandState(option.cmd)) {
1095
+ alignKey = option.key;
1096
+ break;
1097
+ }
1098
+ } catch {
1099
+ }
1100
+ }
1101
+ const alignActive = ALIGN_OPTIONS.find((option) => option.key === alignKey);
1102
+ if (alignIconRef.current && alignActive) {
1103
+ alignIconRef.current.innerHTML = icons[alignActive.iconKey];
1104
+ }
1105
+ alignMenuRef.current?.querySelectorAll("[data-align]").forEach((el) => {
1106
+ el.classList.toggle("is-selected", el.dataset.align === alignKey);
1107
+ });
1108
+ markListMenu("ol");
1109
+ markListMenu("ul");
1110
+ const view = doc.defaultView;
1111
+ const selection = doc.getSelection();
1112
+ let node = selection?.anchorNode ?? null;
1113
+ if (node && node.nodeType === Node.TEXT_NODE) node = node.parentNode;
1114
+ if (view && node && node.nodeType === Node.ELEMENT_NODE) {
1115
+ const size2 = Math.round(parseFloat(view.getComputedStyle(node).fontSize));
1116
+ if (!Number.isNaN(size2)) {
1117
+ currentFontSizeRef.current = size2;
1118
+ if (sizeLabelRef.current) sizeLabelRef.current.textContent = `${size2}px`;
1119
+ sizeMenuRef.current?.querySelectorAll("[data-size]").forEach((el) => {
1120
+ el.classList.toggle("is-selected", Number(el.dataset.size) === size2);
1121
+ });
1122
+ }
1123
+ }
1124
+ const editorRoot = doc.getElementById("editor");
1125
+ if (view && node && node.nodeType === Node.ELEMENT_NODE && editorRoot?.contains(node)) {
1126
+ const element = node;
1127
+ const parsedColor = parseCssColor(view.getComputedStyle(element).color);
1128
+ currentTextColorRef.current = parsedColor ? rgbToHex(parsedColor) : DEFAULT_TEXT_COLOR;
1129
+ let bgHex = null;
1130
+ let cursor = element;
1131
+ while (cursor && cursor !== editorRoot) {
1132
+ const parsedBg = parseCssColor(view.getComputedStyle(cursor).backgroundColor);
1133
+ if (parsedBg && parsedBg.a > 0) {
1134
+ bgHex = rgbToHex(parsedBg);
1135
+ break;
1136
+ }
1137
+ cursor = cursor.parentElement;
1138
+ }
1139
+ currentBgColorRef.current = bgHex;
1140
+ updateColorIndicators();
1141
+ markSwatches(textColorMenuRef.current, currentTextColorRef.current);
1142
+ markSwatches(bgColorMenuRef.current, currentBgColorRef.current);
1143
+ }
1144
+ };
1145
+ const exec = (cmd, val = null) => {
1146
+ if (!doc) return;
1147
+ restoreSelection();
1148
+ doc.execCommand(cmd, false, val ?? void 0);
1149
+ syncToolbarState();
1150
+ };
1151
+ const insertTable = (rows, cols) => {
1152
+ if (!doc) return;
1153
+ restoreSelection();
1154
+ doc.execCommand("insertHTML", false, getTableHtml(rows, cols));
1155
+ };
1156
+ const findLinkInRange = (range) => {
1157
+ if (!doc) return null;
1158
+ const editor = doc.getElementById("editor");
1159
+ if (!editor) return null;
1160
+ const closestAnchor = (start) => {
1161
+ let node = start;
1162
+ while (node && node !== editor) {
1163
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === "A") {
1164
+ return node;
1165
+ }
1166
+ node = node.parentNode;
1167
+ }
1168
+ return null;
1169
+ };
1170
+ return closestAnchor(range.commonAncestorContainer) ?? closestAnchor(range.startContainer) ?? closestAnchor(range.endContainer);
1171
+ };
1172
+ const restoreLinkRange = (range) => {
1173
+ if (!doc) return null;
1174
+ const selection = doc.getSelection();
1175
+ const editor = doc.getElementById("editor");
1176
+ if (!selection || !editor) return null;
1177
+ editor.focus();
1178
+ let target = range;
1179
+ if (!target) {
1180
+ target = doc.createRange();
1181
+ target.selectNodeContents(editor);
1182
+ target.collapse(false);
1183
+ }
1184
+ selection.removeAllRanges();
1185
+ selection.addRange(target);
1186
+ return selection;
1187
+ };
1188
+ const applyLink = (url, range, existingLink) => {
1189
+ if (!doc) return;
1190
+ const selection = restoreLinkRange(range);
1191
+ if (!selection) return;
1192
+ if (existingLink && existingLink.isConnected) {
1193
+ const linkRange = doc.createRange();
1194
+ linkRange.selectNodeContents(existingLink);
1195
+ selection.removeAllRanges();
1196
+ selection.addRange(linkRange);
1197
+ doc.execCommand("unlink");
1198
+ doc.execCommand("createLink", false, url);
1199
+ } else if (!selection.isCollapsed) {
1200
+ doc.execCommand("createLink", false, url);
1201
+ } else {
1202
+ const escaped = url.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1203
+ doc.execCommand("insertHTML", false, `<a href="${escaped}">${escaped}</a>`);
1204
+ }
1205
+ saveSelection();
1206
+ propagateContent();
1207
+ syncToolbarState();
1208
+ };
1209
+ const removeLink = (range, existingLink) => {
1210
+ if (!doc) return;
1211
+ const selection = restoreLinkRange(range);
1212
+ if (!selection) return;
1213
+ if (existingLink && existingLink.isConnected) {
1214
+ const linkRange = doc.createRange();
1215
+ linkRange.selectNodeContents(existingLink);
1216
+ selection.removeAllRanges();
1217
+ selection.addRange(linkRange);
1218
+ }
1219
+ doc.execCommand("unlink");
1220
+ saveSelection();
1221
+ propagateContent();
1222
+ syncToolbarState();
1223
+ };
1224
+ const toggleLinkPopover = (button) => {
1225
+ if (!doc) return;
1226
+ if (linkPopoverCloseRef.current) {
1227
+ linkPopoverCloseRef.current();
1228
+ return;
1229
+ }
1230
+ saveSelection();
1231
+ const selection = doc.getSelection();
1232
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
1233
+ const existingLink = range ? findLinkInRange(range) : null;
1234
+ linkPopoverCloseRef.current = openLinkPopover({
1235
+ doc,
1236
+ anchor: button,
1237
+ initialUrl: existingLink?.getAttribute("href") ?? "",
1238
+ hasLink: existingLink !== null,
1239
+ onApply: (url) => applyLink(url, range, existingLink),
1240
+ onRemove: () => removeLink(range, existingLink),
1241
+ onClose: (reason) => {
1242
+ linkPopoverCloseRef.current = null;
1243
+ if (reason === "escape" || reason === "programmatic") {
1244
+ restoreLinkRange(range);
1245
+ syncToolbarState();
1246
+ }
1247
+ }
1248
+ });
1249
+ };
1250
+ const propagateContent = () => {
1251
+ if (!doc) return;
1252
+ const editor = doc.getElementById("editor");
1253
+ if (!editor) return;
1254
+ isInternalEdit.current = true;
1255
+ setMailContent(editor.innerHTML);
1256
+ setTimeout(() => {
1257
+ isInternalEdit.current = false;
1258
+ }, 100);
1259
+ };
1260
+ const applyInlineStyles = (el, styles) => {
1261
+ Object.assign(el.style, styles);
1262
+ };
1263
+ const decorateSelectionBlocks = (tagName) => {
1264
+ if (!doc) return;
1265
+ const selection = doc.getSelection();
1266
+ if (!selection) return;
1267
+ const attr = tagName === "PRE" ? "data-erte-code" : "data-erte-quote";
1268
+ const styles = tagName === "PRE" ? CODE_BLOCK_INLINE_STYLE : QUOTE_INLINE_STYLE;
1269
+ for (const endpoint of [selection.anchorNode, selection.focusNode]) {
1270
+ const blockEl = closestFromNode(
1271
+ endpoint,
1272
+ (el) => el.tagName === tagName && !el.hasAttribute(attr)
1273
+ );
1274
+ if (blockEl) {
1275
+ blockEl.setAttribute(attr, "true");
1276
+ applyInlineStyles(blockEl, styles);
1277
+ }
1278
+ }
1279
+ };
1280
+ const BLOCK_OVERRIDE_PROPS = ["font-size", "font-weight", "line-height", "margin"];
1281
+ const stripBlockOverrides = (el) => {
1282
+ BLOCK_OVERRIDE_PROPS.forEach((prop) => el.style.removeProperty(prop));
1283
+ ["margin-top", "margin-bottom"].forEach((prop) => el.style.removeProperty(prop));
1284
+ if (!el.getAttribute("style")) el.removeAttribute("style");
1285
+ };
1286
+ const stripBlocksInSelection = (tag) => {
1287
+ if (!doc) return;
1288
+ const selection = doc.getSelection();
1289
+ if (!selection || selection.rangeCount === 0) return;
1290
+ const range = selection.getRangeAt(0);
1291
+ const upper = tag.toUpperCase();
1292
+ const blocks = /* @__PURE__ */ new Set();
1293
+ for (const endpoint of [range.startContainer, range.endContainer]) {
1294
+ const el = closestFromNode(endpoint, (node) => node.tagName === upper);
1295
+ if (el) blocks.add(el);
1296
+ }
1297
+ let root = range.commonAncestorContainer;
1298
+ if (root.nodeType === Node.TEXT_NODE) root = root.parentElement;
1299
+ if (root && root.nodeType === Node.ELEMENT_NODE) {
1300
+ root.querySelectorAll(tag).forEach((el) => {
1301
+ if (range.intersectsNode(el)) blocks.add(el);
1302
+ });
1303
+ }
1304
+ blocks.forEach(stripBlockOverrides);
1305
+ };
1306
+ const applyBlockFormat = (tag) => {
1307
+ if (!doc) return;
1308
+ const savedDropdownRange = dropdownRangeRef.current;
1309
+ if (savedDropdownRange) {
1310
+ const selection = doc.getSelection();
1311
+ if (selection) {
1312
+ selection.removeAllRanges();
1313
+ selection.addRange(savedDropdownRange);
1314
+ }
1315
+ } else {
1316
+ restoreSelection();
1317
+ }
1318
+ const quote = closestFromSelection(
1319
+ (el) => el.tagName === "BLOCKQUOTE" && el.hasAttribute("data-erte-quote")
1320
+ );
1321
+ const code = closestFromSelection(
1322
+ (el) => el.tagName === "PRE" && el.hasAttribute("data-erte-code")
1323
+ );
1324
+ if (tag === "blockquote") {
1325
+ if (quote) {
1326
+ doc.execCommand("outdent");
1327
+ } else {
1328
+ doc.execCommand("formatBlock", false, "<blockquote>");
1329
+ decorateSelectionBlocks("BLOCKQUOTE");
1330
+ }
1331
+ } else if (tag === "codeblock") {
1332
+ if (code) {
1333
+ code.removeAttribute("data-erte-code");
1334
+ code.removeAttribute("style");
1335
+ doc.execCommand("formatBlock", false, "<p>");
1336
+ stripBlocksInSelection("p");
1337
+ } else {
1338
+ if (quote) doc.execCommand("outdent");
1339
+ doc.execCommand("formatBlock", false, "<pre>");
1340
+ decorateSelectionBlocks("PRE");
1341
+ }
1342
+ } else if (tag === "pre" && code) {
1343
+ code.removeAttribute("data-erte-code");
1344
+ code.removeAttribute("style");
1345
+ } else {
1346
+ if (quote) doc.execCommand("outdent");
1347
+ doc.execCommand("formatBlock", false, `<${tag}>`);
1348
+ stripBlocksInSelection(tag);
1349
+ }
1350
+ propagateContent();
1351
+ syncToolbarState();
1352
+ };
1353
+ const applyListStyle = (kind, styleKey, cssValue) => {
1354
+ if (!doc) return;
1355
+ const saved = dropdownRangeRef.current;
1356
+ if (saved) {
1357
+ const selection = doc.getSelection();
1358
+ if (selection) {
1359
+ selection.removeAllRanges();
1360
+ selection.addRange(saved);
1361
+ }
1362
+ }
1363
+ const tagName = kind === "ol" ? "OL" : "UL";
1364
+ const findList = () => closestFromSelection((el) => el.tagName === tagName);
1365
+ let list = findList();
1366
+ if (!list) {
1367
+ doc.execCommand(kind === "ol" ? "insertOrderedList" : "insertUnorderedList");
1368
+ list = findList();
1369
+ }
1370
+ if (!list) return;
1371
+ if (cssValue === null) {
1372
+ list.dataset.erteList = styleKey;
1373
+ list.style.listStyleType = styleKey === "legal" ? "none" : "";
1374
+ } else {
1375
+ if (styleKey === "custom") list.dataset.erteList = "custom";
1376
+ else delete list.dataset.erteList;
1377
+ list.style.listStyleType = cssValue;
1378
+ }
1379
+ propagateContent();
1380
+ syncToolbarState();
1381
+ };
1382
+ const resetIndentation = () => {
1383
+ if (!doc) return;
1384
+ restoreSelection();
1385
+ const editor = doc.getElementById("editor");
1386
+ if (!editor) return;
1387
+ const hasIndentation = () => {
1388
+ let listDepth = 0;
1389
+ let found = false;
1390
+ closestFromSelection((el) => {
1391
+ if (el.tagName === "BLOCKQUOTE" && !el.hasAttribute("data-erte-quote")) found = true;
1392
+ if (el.tagName === "OL" || el.tagName === "UL") listDepth += 1;
1393
+ if (parseFloat(el.style.marginLeft || "0") > 0) found = true;
1394
+ return false;
1395
+ });
1396
+ return found || listDepth > 1;
1397
+ };
1398
+ for (let i = 0; i < 10 && hasIndentation(); i++) {
1399
+ doc.execCommand("outdent");
1400
+ }
1401
+ propagateContent();
1402
+ syncToolbarState();
1403
+ };
1404
+ const insertHorizontalRule = () => {
1405
+ if (!doc) return;
1406
+ restoreSelection();
1407
+ doc.execCommand("insertHorizontalRule");
1408
+ doc.getElementById("editor")?.querySelectorAll("hr:not([data-erte-hr])").forEach((hr) => {
1409
+ hr.setAttribute("data-erte-hr", "true");
1410
+ applyInlineStyles(hr, HR_INLINE_STYLE);
1411
+ });
1412
+ propagateContent();
1413
+ syncToolbarState();
1414
+ };
1415
+ const insertTypingSpan = (decorate) => {
1416
+ if (!doc) return;
1417
+ const selection = doc.getSelection();
1418
+ if (!selection || selection.rangeCount === 0) return;
1419
+ const range = selection.getRangeAt(0);
1420
+ const span = doc.createElement("span");
1421
+ decorate(span);
1422
+ span.appendChild(doc.createTextNode(String.fromCharCode(8203)));
1423
+ range.insertNode(span);
1424
+ const caret = doc.createRange();
1425
+ if (span.firstChild) caret.setStart(span.firstChild, 1);
1426
+ caret.collapse(true);
1427
+ selection.removeAllRanges();
1428
+ selection.addRange(caret);
1429
+ };
1430
+ const applyFontSize = (px) => {
1431
+ if (!doc) return;
1432
+ const editor = doc.getElementById("editor");
1433
+ const selection = doc.getSelection();
1434
+ if (!editor || !selection || selection.rangeCount === 0) return;
1435
+ const size2 = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(px)));
1436
+ if (selection.isCollapsed) {
1437
+ insertTypingSpan((span) => {
1438
+ span.style.fontSize = `${size2}px`;
1439
+ });
1440
+ } else {
1441
+ doc.execCommand("styleWithCSS", false, "false");
1442
+ doc.execCommand("fontSize", false, "7");
1443
+ const restyled = [];
1444
+ editor.querySelectorAll('font[size="7"]').forEach((font) => {
1445
+ const parent = font.parentElement;
1446
+ if (parent && parent.tagName === "SPAN" && parent.childNodes.length === 1 && parent.style.length === 1 && parent.style.fontSize) {
1447
+ parent.style.fontSize = `${size2}px`;
1448
+ while (font.firstChild) parent.appendChild(font.firstChild);
1449
+ font.remove();
1450
+ restyled.push(parent);
1451
+ } else {
1452
+ const span = doc.createElement("span");
1453
+ span.style.fontSize = `${size2}px`;
1454
+ while (font.firstChild) span.appendChild(font.firstChild);
1455
+ font.replaceWith(span);
1456
+ restyled.push(span);
1457
+ }
1458
+ });
1459
+ const first = restyled[0];
1460
+ const last = restyled[restyled.length - 1];
1461
+ if (first && last) {
1462
+ const range = doc.createRange();
1463
+ range.setStart(first, 0);
1464
+ range.setEnd(last, last.childNodes.length);
1465
+ selection.removeAllRanges();
1466
+ selection.addRange(range);
1467
+ }
1468
+ }
1469
+ currentFontSizeRef.current = size2;
1470
+ propagateContent();
1471
+ syncToolbarState();
1472
+ };
1473
+ const stepFontSize = (delta) => {
1474
+ applyFontSize(currentFontSizeRef.current + delta);
1475
+ };
1476
+ const updateColorIndicators = () => {
1477
+ const textBar = textColorBarRef.current;
1478
+ if (textBar) textBar.style.background = currentTextColorRef.current;
1479
+ const bgBar = bgColorBarRef.current;
1480
+ if (bgBar) {
1481
+ const bg = currentBgColorRef.current;
1482
+ bgBar.classList.toggle("is-transparent", !bg);
1483
+ bgBar.style.background = bg ?? "";
1484
+ }
1485
+ };
1486
+ const markSwatches = (menu, hex) => {
1487
+ if (!menu) return;
1488
+ menu.querySelectorAll("[data-color]").forEach((el) => {
1489
+ el.classList.toggle("is-selected", !!hex && el.dataset.color === hex);
1490
+ });
1491
+ menu.querySelector(".erte-color-remove")?.classList.toggle("is-selected", hex === null);
1492
+ };
1493
+ const applyColor = (kind, color) => {
1494
+ if (!doc) return;
1495
+ const selection = doc.getSelection();
1496
+ if (!selection || selection.rangeCount === 0) return;
1497
+ const cssValue = kind === "text" ? color ?? DEFAULT_TEXT_COLOR : color ?? "transparent";
1498
+ if (selection.isCollapsed) {
1499
+ insertTypingSpan((span) => {
1500
+ if (kind === "text") span.style.color = cssValue;
1501
+ else span.style.backgroundColor = cssValue;
1502
+ });
1503
+ } else {
1504
+ doc.execCommand("styleWithCSS", false, "true");
1505
+ if (kind === "text") {
1506
+ doc.execCommand("foreColor", false, cssValue);
1507
+ } else if (!doc.execCommand("hiliteColor", false, cssValue)) {
1508
+ doc.execCommand("backColor", false, cssValue);
1509
+ }
1510
+ }
1511
+ if (kind === "text") currentTextColorRef.current = cssValue;
1512
+ else currentBgColorRef.current = color;
1513
+ updateColorIndicators();
1514
+ propagateContent();
1515
+ syncToolbarState();
1516
+ };
1517
+ const openCustomPicker = (kind) => {
1518
+ if (!doc) return;
1519
+ const selection = doc.getSelection();
1520
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
1521
+ const restoreRange = () => {
1522
+ if (!range || !selection) return;
1523
+ selection.removeAllRanges();
1524
+ selection.addRange(range);
1525
+ };
1526
+ const initialColor = kind === "text" ? lastCustomColorRef.current.text ?? currentTextColorRef.current : lastCustomColorRef.current.bg ?? currentBgColorRef.current ?? "#fbeeb8";
1527
+ openColorPicker({
1528
+ doc,
1529
+ initialColor,
1530
+ title: kind === "text" ? "Text color" : "Highlight color",
1531
+ onSave: (hex) => {
1532
+ if (kind === "text") lastCustomColorRef.current.text = hex;
1533
+ else lastCustomColorRef.current.bg = hex;
1534
+ restoreRange();
1535
+ applyColor(kind, hex);
1536
+ },
1537
+ onCancel: restoreRange
1538
+ });
1539
+ };
1540
+ const runAiAction = async (action, computeWordCount, toneType) => {
1541
+ if (!doc) return;
1542
+ saveSelection();
1543
+ const selectedText = doc.getSelection()?.toString() ?? "";
1544
+ if (!selectedText) {
1545
+ handleErrorTextSelection();
1546
+ return;
1547
+ }
1548
+ const wordCount = computeWordCount ? computeWordCount(selectedText) : null;
1549
+ const result = await callAiTextAction(action, selectedText, wordCount, toneType);
1550
+ if (result) exec("insertText", result);
1551
+ };
1552
+ const updateToolbarRowStarts = () => {
1553
+ if (!doc) return;
1554
+ const toolbar = doc.getElementById("toolbar");
1555
+ if (!toolbar) return;
1556
+ let prevLeft = Number.POSITIVE_INFINITY;
1557
+ toolbar.querySelectorAll(".erte-toolbar-group").forEach((group) => {
1558
+ const isRowStart = group.offsetLeft <= prevLeft;
1559
+ group.classList.toggle("erte-row-start", isRowStart);
1560
+ prevLeft = group.offsetLeft;
1561
+ });
1562
+ };
1563
+ const renderToolbar = () => {
1564
+ if (!doc) return;
1565
+ const editorDoc = doc;
1566
+ const toolbar = editorDoc.getElementById("toolbar");
1567
+ if (!toolbar) return;
1568
+ toolbar.innerHTML = "";
1569
+ toggleButtonsRef.current = [];
1570
+ blockToggleButtonsRef.current = [];
1571
+ const makeButton = (config) => {
1572
+ const btn = editorDoc.createElement("button");
1573
+ btn.type = "button";
1574
+ btn.className = "erte-btn";
1575
+ btn.title = config.title;
1576
+ btn.setAttribute("aria-label", config.title);
1577
+ btn.innerHTML = config.icon;
1578
+ btn.onclick = (event) => {
1579
+ event.preventDefault();
1580
+ if (config.onClick) {
1581
+ config.onClick();
1582
+ return;
1583
+ }
1584
+ if (config.cmd) exec(config.cmd);
1585
+ };
1586
+ if (config.cmd && TOGGLE_COMMANDS.has(config.cmd)) {
1587
+ btn.setAttribute("aria-pressed", "false");
1588
+ toggleButtonsRef.current.push({ el: btn, cmd: config.cmd });
1589
+ }
1590
+ return btn;
1591
+ };
1592
+ const makeGroup = (items) => {
1593
+ const group = editorDoc.createElement("div");
1594
+ group.className = "erte-toolbar-group";
1595
+ items.forEach((item) => group.appendChild(item));
1596
+ return group;
1597
+ };
1598
+ const closeAllDropdowns = () => {
1599
+ editorDoc.querySelectorAll(".erte-select-wrap.is-open").forEach((wrap) => {
1600
+ wrap.classList.remove("is-open");
1601
+ wrap.querySelector(".erte-select")?.setAttribute("aria-expanded", "false");
1602
+ });
1603
+ };
1604
+ const positionMenu = (wrap) => {
1605
+ const menu = wrap.querySelector(".erte-select-menu");
1606
+ if (!menu) return;
1607
+ const MARGIN = 8;
1608
+ menu.style.left = "0px";
1609
+ menu.style.right = "auto";
1610
+ menu.style.top = "";
1611
+ menu.style.bottom = "";
1612
+ const wrapRect = wrap.getBoundingClientRect();
1613
+ const viewportW = editorDoc.documentElement.clientWidth;
1614
+ const viewportH = editorDoc.documentElement.clientHeight;
1615
+ const menuW = menu.offsetWidth;
1616
+ const menuH = menu.offsetHeight;
1617
+ const targetLeft = Math.max(MARGIN, Math.min(wrapRect.left, viewportW - MARGIN - menuW));
1618
+ menu.style.left = `${Math.round(targetLeft - wrapRect.left)}px`;
1619
+ if (viewportH - wrapRect.bottom < menuH + MARGIN && wrapRect.top > menuH + MARGIN) {
1620
+ menu.style.top = "auto";
1621
+ menu.style.bottom = "calc(100% + 4px)";
1622
+ }
1623
+ };
1624
+ const toggleDropdown = (wrap) => {
1625
+ const willOpen = !wrap.classList.contains("is-open");
1626
+ closeAllDropdowns();
1627
+ if (willOpen) {
1628
+ wrap.classList.add("is-open");
1629
+ wrap.querySelector(".erte-select")?.setAttribute("aria-expanded", "true");
1630
+ positionMenu(wrap);
1631
+ }
1632
+ };
1633
+ const makeSelect = (options, config) => {
1634
+ const wrap = editorDoc.createElement("div");
1635
+ wrap.className = "erte-select-wrap";
1636
+ const trigger = editorDoc.createElement("button");
1637
+ trigger.type = "button";
1638
+ trigger.className = `erte-select${config.triggerClass ? ` ${config.triggerClass}` : ""}`;
1639
+ trigger.title = config.title;
1640
+ trigger.setAttribute("aria-label", config.title);
1641
+ trigger.setAttribute("aria-haspopup", "listbox");
1642
+ trigger.setAttribute("aria-expanded", "false");
1643
+ const caret = editorDoc.createElement("span");
1644
+ caret.className = "erte-select-caret";
1645
+ caret.setAttribute("aria-hidden", "true");
1646
+ let label = null;
1647
+ if (config.triggerIconHtml) {
1648
+ const icon = editorDoc.createElement("span");
1649
+ icon.className = "erte-select-icon";
1650
+ icon.setAttribute("aria-hidden", "true");
1651
+ icon.innerHTML = config.triggerIconHtml;
1652
+ trigger.append(icon, caret);
1653
+ } else {
1654
+ label = editorDoc.createElement("span");
1655
+ label.className = "erte-select-label";
1656
+ label.textContent = config.initialLabel;
1657
+ trigger.append(label, caret);
1658
+ }
1659
+ const menu = editorDoc.createElement("div");
1660
+ menu.className = `erte-select-menu${config.menuClass ? ` ${config.menuClass}` : ""}`;
1661
+ menu.setAttribute("role", "listbox");
1662
+ options.forEach((option) => {
1663
+ const item = editorDoc.createElement("button");
1664
+ item.type = "button";
1665
+ item.className = `erte-option${option.optionClass ? ` ${option.optionClass}` : ""}`;
1666
+ item.setAttribute("role", "option");
1667
+ item.setAttribute(option.dataAttr, option.key);
1668
+ item.textContent = option.label;
1669
+ item.addEventListener("mousedown", (event) => event.preventDefault());
1670
+ item.addEventListener("click", (event) => {
1671
+ event.preventDefault();
1672
+ option.onSelect();
1673
+ closeAllDropdowns();
1674
+ });
1675
+ menu.appendChild(item);
1676
+ });
1677
+ trigger.addEventListener("mousedown", (event) => event.preventDefault());
1678
+ trigger.addEventListener("click", (event) => {
1679
+ event.preventDefault();
1680
+ event.stopPropagation();
1681
+ saveSelection();
1682
+ const sel = editorDoc.getSelection();
1683
+ dropdownRangeRef.current = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
1684
+ toggleDropdown(wrap);
1685
+ });
1686
+ wrap.append(trigger, menu);
1687
+ return { wrap, trigger, label, menu };
1688
+ };
1689
+ const makeIconDropdown = (config) => {
1690
+ const wrap = editorDoc.createElement("div");
1691
+ wrap.className = "erte-select-wrap";
1692
+ const trigger = editorDoc.createElement("button");
1693
+ trigger.type = "button";
1694
+ trigger.className = `erte-select erte-icon-select${config.triggerClass ? ` ${config.triggerClass}` : ""}`;
1695
+ trigger.title = config.title;
1696
+ trigger.setAttribute("aria-label", config.title);
1697
+ trigger.setAttribute("aria-haspopup", "menu");
1698
+ trigger.setAttribute("aria-expanded", "false");
1699
+ const icon = editorDoc.createElement("span");
1700
+ icon.className = "erte-icon-select-icon";
1701
+ icon.setAttribute("aria-hidden", "true");
1702
+ icon.innerHTML = config.triggerHtml;
1703
+ const caret = editorDoc.createElement("span");
1704
+ caret.className = "erte-select-caret";
1705
+ caret.setAttribute("aria-hidden", "true");
1706
+ trigger.append(icon, caret);
1707
+ const menu = editorDoc.createElement("div");
1708
+ menu.className = `erte-select-menu${config.menuClass ? ` ${config.menuClass}` : ""}`;
1709
+ menu.setAttribute("role", "menu");
1710
+ trigger.addEventListener("mousedown", (event) => event.preventDefault());
1711
+ trigger.addEventListener("click", (event) => {
1712
+ event.preventDefault();
1713
+ event.stopPropagation();
1714
+ saveSelection();
1715
+ const sel = editorDoc.getSelection();
1716
+ dropdownRangeRef.current = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
1717
+ toggleDropdown(wrap);
1718
+ });
1719
+ wrap.append(trigger, menu);
1720
+ return { wrap, trigger, icon, menu };
1721
+ };
1722
+ const makeMenuItem = (config) => {
1723
+ const item = editorDoc.createElement("button");
1724
+ item.type = "button";
1725
+ item.className = "erte-menu-item";
1726
+ item.setAttribute("role", "menuitem");
1727
+ if (config.data) item.setAttribute(config.data.attr, config.data.value);
1728
+ if (config.leadingHtml !== void 0) {
1729
+ const lead = editorDoc.createElement("span");
1730
+ lead.className = "erte-menu-item-lead";
1731
+ lead.setAttribute("aria-hidden", "true");
1732
+ lead.innerHTML = config.leadingHtml;
1733
+ item.appendChild(lead);
1734
+ }
1735
+ const text = editorDoc.createElement("span");
1736
+ text.className = "erte-menu-item-label";
1737
+ text.textContent = config.label;
1738
+ item.appendChild(text);
1739
+ item.addEventListener("mousedown", (event) => event.preventDefault());
1740
+ item.addEventListener("click", (event) => {
1741
+ event.preventDefault();
1742
+ config.onSelect();
1743
+ closeAllDropdowns();
1744
+ });
1745
+ return item;
1746
+ };
1747
+ const makeStepButton = (glyph, title, onClick) => {
1748
+ const btn = editorDoc.createElement("button");
1749
+ btn.type = "button";
1750
+ btn.className = "erte-btn erte-step-btn";
1751
+ btn.title = title;
1752
+ btn.setAttribute("aria-label", title);
1753
+ btn.textContent = glyph;
1754
+ btn.addEventListener("mousedown", (event) => event.preventDefault());
1755
+ btn.addEventListener("click", (event) => {
1756
+ event.preventDefault();
1757
+ onClick();
1758
+ });
1759
+ return btn;
1760
+ };
1761
+ const makeTablePicker = () => {
1762
+ const GRID_COLS = 10;
1763
+ const GRID_ROWS = 8;
1764
+ const wrap = editorDoc.createElement("div");
1765
+ wrap.className = "erte-select-wrap erte-table-wrap";
1766
+ const trigger = editorDoc.createElement("button");
1767
+ trigger.type = "button";
1768
+ trigger.className = "erte-btn";
1769
+ trigger.title = "Insert table";
1770
+ trigger.setAttribute("aria-label", "Insert table");
1771
+ trigger.setAttribute("aria-haspopup", "dialog");
1772
+ trigger.setAttribute("aria-expanded", "false");
1773
+ trigger.innerHTML = icons.table;
1774
+ trigger.addEventListener("mousedown", (event) => event.preventDefault());
1775
+ trigger.addEventListener("click", (event) => {
1776
+ event.preventDefault();
1777
+ event.stopPropagation();
1778
+ toggleDropdown(wrap);
1779
+ });
1780
+ const menu = editorDoc.createElement("div");
1781
+ menu.className = "erte-select-menu erte-table-menu";
1782
+ const grid = editorDoc.createElement("div");
1783
+ grid.className = "erte-table-grid";
1784
+ const label = editorDoc.createElement("div");
1785
+ label.className = "erte-table-label";
1786
+ label.textContent = "1 × 1";
1787
+ const highlight = (cols, rows) => {
1788
+ grid.querySelectorAll(".erte-table-cell").forEach((el) => {
1789
+ el.classList.toggle(
1790
+ "is-on",
1791
+ Number(el.dataset.c) <= cols && Number(el.dataset.r) <= rows
1792
+ );
1793
+ });
1794
+ label.textContent = `${cols} × ${rows}`;
1795
+ };
1796
+ for (let r = 1; r <= GRID_ROWS; r++) {
1797
+ for (let c = 1; c <= GRID_COLS; c++) {
1798
+ const cell = editorDoc.createElement("button");
1799
+ cell.type = "button";
1800
+ cell.className = "erte-table-cell";
1801
+ cell.dataset.c = String(c);
1802
+ cell.dataset.r = String(r);
1803
+ cell.setAttribute("aria-label", `${c} × ${r} table`);
1804
+ cell.addEventListener("mouseover", () => highlight(c, r));
1805
+ cell.addEventListener("focus", () => highlight(c, r));
1806
+ cell.addEventListener("mousedown", (event) => event.preventDefault());
1807
+ cell.addEventListener("click", (event) => {
1808
+ event.preventDefault();
1809
+ insertTable(r, c);
1810
+ closeAllDropdowns();
1811
+ highlight(0, 0);
1812
+ label.textContent = "1 × 1";
1813
+ });
1814
+ grid.appendChild(cell);
1815
+ }
1816
+ }
1817
+ grid.addEventListener("mouseleave", () => {
1818
+ highlight(0, 0);
1819
+ label.textContent = "1 × 1";
1820
+ });
1821
+ menu.append(grid, label);
1822
+ wrap.append(trigger, menu);
1823
+ return wrap;
1824
+ };
1825
+ const paragraph = makeSelect(
1826
+ BLOCK_OPTIONS.map((option) => ({
1827
+ key: option.tag,
1828
+ label: option.label,
1829
+ optionClass: option.optionClass,
1830
+ dataAttr: "data-tag",
1831
+ onSelect: () => applyBlockFormat(option.tag)
1832
+ })),
1833
+ {
1834
+ title: "Text styles",
1835
+ triggerClass: "erte-select--block",
1836
+ menuClass: "erte-select-menu--block",
1837
+ initialLabel: "",
1838
+ triggerIconHtml: icons.paragraph
1839
+ }
1840
+ );
1841
+ blockTriggerRef.current = paragraph.trigger;
1842
+ blockMenuRef.current = paragraph.menu;
1843
+ const fontSize = makeSelect(
1844
+ FONT_SIZES.map((size2) => ({
1845
+ key: String(size2),
1846
+ label: `${size2}px`,
1847
+ dataAttr: "data-size",
1848
+ onSelect: () => applyFontSize(size2)
1849
+ })),
1850
+ {
1851
+ title: "Font size",
1852
+ triggerClass: "erte-select--size",
1853
+ menuClass: "erte-select-menu--size",
1854
+ initialLabel: `${DEFAULT_FONT_SIZE}px`
1855
+ }
1856
+ );
1857
+ sizeLabelRef.current = fontSize.label;
1858
+ sizeMenuRef.current = fontSize.menu;
1859
+ const makeBlockToggle = (config) => {
1860
+ const btn = makeButton({
1861
+ icon: config.icon,
1862
+ title: config.title,
1863
+ onClick: () => applyBlockFormat(config.tag)
1864
+ });
1865
+ btn.addEventListener("mousedown", (event) => event.preventDefault());
1866
+ btn.setAttribute("aria-pressed", "false");
1867
+ blockToggleButtonsRef.current.push({ el: btn, detect: config.detect });
1868
+ return btn;
1869
+ };
1870
+ const blockQuoteBtn = makeBlockToggle({
1871
+ icon: icons.quote,
1872
+ title: "Block quote",
1873
+ tag: "blockquote",
1874
+ detect: () => !!closestFromSelection(
1875
+ (el) => el.tagName === "BLOCKQUOTE" && el.hasAttribute("data-erte-quote")
1876
+ )
1877
+ });
1878
+ const codeBlockBtn = makeBlockToggle({
1879
+ icon: icons.code,
1880
+ title: "Code block",
1881
+ tag: "codeblock",
1882
+ detect: () => !!closestFromSelection((el) => el.tagName === "PRE" && el.hasAttribute("data-erte-code"))
1883
+ });
1884
+ const blockGroup = editorDoc.createElement("div");
1885
+ blockGroup.className = "erte-toolbar-group erte-block-group";
1886
+ blockGroup.append(paragraph.wrap, blockQuoteBtn, codeBlockBtn);
1887
+ const styleGroup = editorDoc.createElement("div");
1888
+ styleGroup.className = "erte-toolbar-group erte-style-group";
1889
+ styleGroup.append(
1890
+ makeStepButton("−", "Decrease font size", () => stepFontSize(-1)),
1891
+ fontSize.wrap,
1892
+ makeStepButton("+", "Increase font size", () => stepFontSize(1))
1893
+ );
1894
+ const makeColorControl = (kind) => {
1895
+ const isText = kind === "text";
1896
+ const wrap = editorDoc.createElement("div");
1897
+ wrap.className = "erte-select-wrap erte-split-wrap erte-color-wrap";
1898
+ const main = editorDoc.createElement("button");
1899
+ main.type = "button";
1900
+ main.className = "erte-btn erte-split-main erte-color-btn";
1901
+ main.title = isText ? "Text color" : "Highlight color";
1902
+ main.setAttribute("aria-label", main.title);
1903
+ main.innerHTML = `${isText ? '<span class="erte-color-glyph">A</span>' : icons.highlighter}`;
1904
+ const bar = editorDoc.createElement("span");
1905
+ bar.className = "erte-color-bar";
1906
+ bar.setAttribute("aria-hidden", "true");
1907
+ main.appendChild(bar);
1908
+ main.addEventListener("mousedown", (event) => event.preventDefault());
1909
+ main.addEventListener("click", (event) => {
1910
+ event.preventDefault();
1911
+ applyColor(kind, isText ? currentTextColorRef.current : currentBgColorRef.current);
1912
+ });
1913
+ const caretBtn = editorDoc.createElement("button");
1914
+ caretBtn.type = "button";
1915
+ caretBtn.className = "erte-select erte-split-caret erte-color-caret";
1916
+ caretBtn.title = isText ? "Text color palette" : "Highlight color palette";
1917
+ caretBtn.setAttribute("aria-label", caretBtn.title);
1918
+ caretBtn.setAttribute("aria-haspopup", "listbox");
1919
+ caretBtn.setAttribute("aria-expanded", "false");
1920
+ caretBtn.innerHTML = '<span class="erte-select-caret"></span>';
1921
+ caretBtn.addEventListener("mousedown", (event) => event.preventDefault());
1922
+ caretBtn.addEventListener("click", (event) => {
1923
+ event.preventDefault();
1924
+ event.stopPropagation();
1925
+ toggleDropdown(wrap);
1926
+ });
1927
+ const menu = editorDoc.createElement("div");
1928
+ menu.className = "erte-select-menu erte-color-menu";
1929
+ const removeBtn = editorDoc.createElement("button");
1930
+ removeBtn.type = "button";
1931
+ removeBtn.className = "erte-color-remove";
1932
+ removeBtn.innerHTML = `${icons.check}<span>Remove color</span>`;
1933
+ removeBtn.addEventListener("mousedown", (event) => event.preventDefault());
1934
+ removeBtn.addEventListener("click", (event) => {
1935
+ event.preventDefault();
1936
+ applyColor(kind, null);
1937
+ closeAllDropdowns();
1938
+ });
1939
+ const grid = editorDoc.createElement("div");
1940
+ grid.className = "erte-color-grid";
1941
+ COLOR_PALETTE.forEach(({ hex, name }) => {
1942
+ const swatch = editorDoc.createElement("button");
1943
+ swatch.type = "button";
1944
+ swatch.className = "erte-color-swatch";
1945
+ swatch.title = name;
1946
+ swatch.setAttribute("aria-label", name);
1947
+ swatch.dataset.color = hex;
1948
+ swatch.style.background = hex;
1949
+ swatch.addEventListener("mousedown", (event) => event.preventDefault());
1950
+ swatch.addEventListener("click", (event) => {
1951
+ event.preventDefault();
1952
+ applyColor(kind, hex);
1953
+ closeAllDropdowns();
1954
+ });
1955
+ grid.appendChild(swatch);
1956
+ });
1957
+ const customBtn = editorDoc.createElement("button");
1958
+ customBtn.type = "button";
1959
+ customBtn.className = "erte-color-custom";
1960
+ customBtn.innerHTML = `${icons.palette}<span>Custom color&hellip;</span>`;
1961
+ customBtn.addEventListener("mousedown", (event) => event.preventDefault());
1962
+ customBtn.addEventListener("click", (event) => {
1963
+ event.preventDefault();
1964
+ closeAllDropdowns();
1965
+ openCustomPicker(kind);
1966
+ });
1967
+ menu.append(removeBtn, grid, customBtn);
1968
+ wrap.append(main, caretBtn, menu);
1969
+ if (isText) {
1970
+ textColorBarRef.current = bar;
1971
+ textColorMenuRef.current = menu;
1972
+ } else {
1973
+ bgColorBarRef.current = bar;
1974
+ bgColorMenuRef.current = menu;
1975
+ }
1976
+ return wrap;
1977
+ };
1978
+ const colorGroup = editorDoc.createElement("div");
1979
+ colorGroup.className = "erte-toolbar-group erte-color-group";
1980
+ colorGroup.append(makeColorControl("text"), makeColorControl("bg"));
1981
+ const alignDropdown = makeIconDropdown({
1982
+ title: "Text alignment",
1983
+ triggerHtml: icons.alignLeft,
1984
+ triggerClass: "erte-align-select"
1985
+ });
1986
+ alignIconRef.current = alignDropdown.icon;
1987
+ alignMenuRef.current = alignDropdown.menu;
1988
+ ALIGN_OPTIONS.forEach((option) => {
1989
+ alignDropdown.menu.appendChild(
1990
+ makeMenuItem({
1991
+ leadingHtml: icons[option.iconKey],
1992
+ label: option.label,
1993
+ data: { attr: "data-align", value: option.key },
1994
+ onSelect: () => exec(option.cmd)
1995
+ })
1996
+ );
1997
+ });
1998
+ const makeListControl = (kind) => {
1999
+ const isOrdered = kind === "ol";
2000
+ const styles = isOrdered ? ORDERED_LIST_STYLES : BULLET_LIST_STYLES;
2001
+ const wrap = editorDoc.createElement("div");
2002
+ wrap.className = "erte-select-wrap erte-split-wrap";
2003
+ const main = makeButton({
2004
+ icon: isOrdered ? icons.listOl : icons.listUl,
2005
+ title: isOrdered ? "Numbered list" : "Bulleted list",
2006
+ cmd: isOrdered ? "insertOrderedList" : "insertUnorderedList"
2007
+ });
2008
+ main.classList.add("erte-split-main");
2009
+ const caretBtn = editorDoc.createElement("button");
2010
+ caretBtn.type = "button";
2011
+ caretBtn.className = "erte-select erte-split-caret";
2012
+ caretBtn.title = isOrdered ? "Numbered list styles" : "Bullet list styles";
2013
+ caretBtn.setAttribute("aria-label", caretBtn.title);
2014
+ caretBtn.setAttribute("aria-haspopup", "menu");
2015
+ caretBtn.setAttribute("aria-expanded", "false");
2016
+ caretBtn.innerHTML = '<span class="erte-select-caret"></span>';
2017
+ caretBtn.addEventListener("mousedown", (event) => event.preventDefault());
2018
+ caretBtn.addEventListener("click", (event) => {
2019
+ event.preventDefault();
2020
+ event.stopPropagation();
2021
+ saveSelection();
2022
+ const sel = editorDoc.getSelection();
2023
+ dropdownRangeRef.current = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
2024
+ toggleDropdown(wrap);
2025
+ });
2026
+ const menu = editorDoc.createElement("div");
2027
+ menu.className = "erte-select-menu erte-list-menu";
2028
+ menu.setAttribute("role", "menu");
2029
+ const grid = editorDoc.createElement("div");
2030
+ grid.className = "erte-list-grid";
2031
+ styles.forEach((style) => {
2032
+ const tile = editorDoc.createElement("button");
2033
+ tile.type = "button";
2034
+ tile.className = "erte-list-tile";
2035
+ tile.title = style.label;
2036
+ tile.setAttribute("aria-label", style.label);
2037
+ tile.dataset.listStyle = style.key;
2038
+ tile.innerHTML = style.markers.map(
2039
+ (marker) => `<span class="erte-list-row"><span class="erte-list-marker">${marker}</span><span class="erte-list-bar"></span></span>`
2040
+ ).join("");
2041
+ tile.addEventListener("mousedown", (event) => event.preventDefault());
2042
+ tile.addEventListener("click", (event) => {
2043
+ event.preventDefault();
2044
+ applyListStyle(kind, style.key, style.css);
2045
+ closeAllDropdowns();
2046
+ });
2047
+ grid.appendChild(tile);
2048
+ });
2049
+ menu.appendChild(grid);
2050
+ if (!isOrdered) {
2051
+ const customRow = editorDoc.createElement("div");
2052
+ customRow.className = "erte-list-custom";
2053
+ const input = editorDoc.createElement("input");
2054
+ input.type = "text";
2055
+ input.className = "erte-list-custom-input";
2056
+ input.maxLength = 2;
2057
+ input.placeholder = "★";
2058
+ input.setAttribute("aria-label", "Custom bullet character");
2059
+ const apply = editorDoc.createElement("button");
2060
+ apply.type = "button";
2061
+ apply.className = "erte-list-custom-apply";
2062
+ apply.textContent = "Use bullet";
2063
+ apply.addEventListener("mousedown", (event) => event.preventDefault());
2064
+ apply.addEventListener("click", (event) => {
2065
+ event.preventDefault();
2066
+ const char = input.value.trim();
2067
+ if (!char) return;
2068
+ applyListStyle("ul", "custom", `"${char} "`);
2069
+ closeAllDropdowns();
2070
+ });
2071
+ customRow.append(input, apply);
2072
+ menu.appendChild(customRow);
2073
+ }
2074
+ caretBtn.addEventListener("keydown", () => void 0);
2075
+ wrap.append(main, caretBtn, menu);
2076
+ if (isOrdered) olMenuRef.current = menu;
2077
+ else ulMenuRef.current = menu;
2078
+ return wrap;
2079
+ };
2080
+ const indentDropdown = makeIconDropdown({
2081
+ title: "Indentation",
2082
+ triggerHtml: icons.indent,
2083
+ menuClass: "erte-plain-menu"
2084
+ });
2085
+ [
2086
+ { icon: icons.indent, label: "Increase indent", onSelect: () => exec("indent") },
2087
+ { icon: icons.outdent, label: "Decrease indent", onSelect: () => exec("outdent") },
2088
+ { icon: icons.outdent, label: "Reset indentation", onSelect: resetIndentation }
2089
+ ].forEach((entry) => {
2090
+ indentDropdown.menu.appendChild(
2091
+ makeMenuItem({ leadingHtml: entry.icon, label: entry.label, onSelect: entry.onSelect })
2092
+ );
2093
+ });
2094
+ const moreDropdown = makeIconDropdown({
2095
+ title: "More formatting",
2096
+ triggerHtml: icons.ellipsis,
2097
+ menuClass: "erte-plain-menu"
2098
+ });
2099
+ const registerMoreToggle = (item, cmd) => {
2100
+ item.setAttribute("aria-pressed", "false");
2101
+ item.dataset.toggleCmd = cmd;
2102
+ toggleButtonsRef.current.push({ el: item, cmd });
2103
+ };
2104
+ const strikeItem = makeMenuItem({
2105
+ leadingHtml: icons.strikethrough,
2106
+ label: "Strikethrough",
2107
+ onSelect: () => exec("strikeThrough")
2108
+ });
2109
+ registerMoreToggle(strikeItem, "strikeThrough");
2110
+ const subItem = makeMenuItem({
2111
+ leadingHtml: icons.subscript,
2112
+ label: "Subscript",
2113
+ onSelect: () => exec("subscript")
2114
+ });
2115
+ registerMoreToggle(subItem, "subscript");
2116
+ const superItem = makeMenuItem({
2117
+ leadingHtml: icons.superscript,
2118
+ label: "Superscript",
2119
+ onSelect: () => exec("superscript")
2120
+ });
2121
+ registerMoreToggle(superItem, "superscript");
2122
+ moreDropdown.menu.append(
2123
+ strikeItem,
2124
+ subItem,
2125
+ superItem,
2126
+ makeMenuItem({
2127
+ leadingHtml: icons.horizontalRule,
2128
+ label: "Horizontal rule",
2129
+ onSelect: insertHorizontalRule
2130
+ }),
2131
+ makeMenuItem({
2132
+ leadingHtml: icons.eraser,
2133
+ label: "Clear formatting",
2134
+ onSelect: () => exec("removeFormat")
2135
+ })
2136
+ );
2137
+ const linkButton = makeButton({
2138
+ icon: icons.link,
2139
+ title: "Insert link",
2140
+ onClick: () => toggleLinkPopover(linkButton)
2141
+ });
2142
+ linkButton.addEventListener("mousedown", (event) => event.preventDefault());
2143
+ const groups = [
2144
+ [
2145
+ makeButton({ icon: icons.undo, title: "Undo", cmd: "undo" }),
2146
+ makeButton({ icon: icons.redo, title: "Redo", cmd: "redo" })
2147
+ ],
2148
+ [
2149
+ makeButton({ icon: icons.bold, title: "Bold", cmd: "bold" }),
2150
+ makeButton({ icon: icons.italic, title: "Italic", cmd: "italic" }),
2151
+ makeButton({ icon: icons.underline, title: "Underline", cmd: "underline" })
2152
+ ],
2153
+ [alignDropdown.wrap],
2154
+ [makeListControl("ol"), makeListControl("ul")],
2155
+ [indentDropdown.wrap],
2156
+ [moreDropdown.wrap],
2157
+ [linkButton, makeTablePicker()]
2158
+ ];
2159
+ const iconGroups = groups.map(makeGroup);
2160
+ const orderedGroups = [
2161
+ ...iconGroups.slice(0, 2),
2162
+ // undo/redo, bold/italic/underline
2163
+ blockGroup,
2164
+ // paragraph styles + block quote + code block
2165
+ styleGroup,
2166
+ // font size (− size +)
2167
+ ...iconGroups.slice(2),
2168
+ // alignment … link/table
2169
+ colorGroup
2170
+ ];
2171
+ orderedGroups.forEach((group) => toolbar.appendChild(group));
2172
+ updateColorIndicators();
2173
+ markSwatches(textColorMenuRef.current, currentTextColorRef.current);
2174
+ markSwatches(bgColorMenuRef.current, currentBgColorRef.current);
2175
+ updateToolbarRowStarts();
2176
+ if (showAiTools) {
2177
+ const aiGroup = editorDoc.createElement("div");
2178
+ aiGroup.className = "erte-ai-group";
2179
+ const addAiBtn = (label, onClick) => {
2180
+ const btn = editorDoc.createElement("button");
2181
+ btn.type = "button";
2182
+ btn.className = "erte-ai-btn";
2183
+ btn.textContent = label;
2184
+ btn.onclick = (event) => {
2185
+ event.preventDefault();
2186
+ onClick();
2187
+ };
2188
+ aiGroup.appendChild(btn);
2189
+ };
2190
+ addAiBtn("Enhance", () => void runAiAction("enhance", null, null));
2191
+ addAiBtn("Proofread", () => void runAiAction("proofread", null, null));
2192
+ addAiBtn(
2193
+ "Expand",
2194
+ () => void runAiAction("expand", (t) => t.trim().split(/\s+/).length * 2, null)
2195
+ );
2196
+ addAiBtn(
2197
+ "Shorten",
2198
+ () => void runAiAction(
2199
+ "shorten",
2200
+ (t) => Math.max(1, Math.floor(t.trim().split(/\s+/).length / 2)),
2201
+ null
2202
+ )
2203
+ );
2204
+ addAiBtn("Friendly", () => void runAiAction("changeTone", null, "friendly"));
2205
+ addAiBtn("Formal", () => void runAiAction("changeTone", null, "formal"));
2206
+ addAiBtn("Undo", () => exec("undo"));
2207
+ toolbar.appendChild(aiGroup);
2208
+ }
2209
+ syncToolbarState();
2210
+ };
2211
+ const startResize = (edges) => (event) => {
2212
+ const container = containerRef.current;
2213
+ if (!container) return;
2214
+ event.preventDefault();
2215
+ const rect = container.getBoundingClientRect();
2216
+ const startX = event.clientX;
2217
+ const startY = event.clientY;
2218
+ const startWidth = rect.width;
2219
+ const startHeight = rect.height;
2220
+ const handleEl = event.currentTarget;
2221
+ const { pointerId } = event;
2222
+ try {
2223
+ handleEl.setPointerCapture(pointerId);
2224
+ } catch {
2225
+ }
2226
+ container.classList.add("is-resizing");
2227
+ handleEl.classList.add("is-active");
2228
+ const previousCursor = document.body.style.cursor;
2229
+ const previousUserSelect = document.body.style.userSelect;
2230
+ document.body.style.cursor = edges.h && edges.v ? edges.h === "left" === (edges.v === "top") ? "nwse-resize" : "nesw-resize" : edges.h ? "ew-resize" : "ns-resize";
2231
+ document.body.style.userSelect = "none";
2232
+ if (iframeRef.current) iframeRef.current.style.pointerEvents = "none";
2233
+ const parentEl = container.parentElement;
2234
+ let maxAvailableWidth = startWidth;
2235
+ if (parentEl) {
2236
+ const parentStyle = parentEl.ownerDocument.defaultView?.getComputedStyle(parentEl);
2237
+ const parentPadding = parentStyle ? (parseFloat(parentStyle.paddingLeft) || 0) + (parseFloat(parentStyle.paddingRight) || 0) : 0;
2238
+ maxAvailableWidth = parentEl.clientWidth - parentPadding;
2239
+ }
2240
+ const handleMove = (moveEvent) => {
2241
+ if (edges.h) {
2242
+ const dx = moveEvent.clientX - startX;
2243
+ const nextWidth = clampNumber(
2244
+ startWidth + (edges.h === "right" ? dx : -dx),
2245
+ MIN_EDITOR_WIDTH,
2246
+ maxAvailableWidth
2247
+ );
2248
+ container.style.width = `${nextWidth}px`;
2249
+ }
2250
+ if (edges.v) {
2251
+ const dy = moveEvent.clientY - startY;
2252
+ const nextHeight = clampNumber(
2253
+ startHeight + (edges.v === "bottom" ? dy : -dy),
2254
+ MIN_EDITOR_HEIGHT,
2255
+ MAX_EDITOR_HEIGHT
2256
+ );
2257
+ container.style.height = `${nextHeight}px`;
2258
+ }
2259
+ };
2260
+ const cleanup = () => {
2261
+ window.removeEventListener("pointermove", handleMove);
2262
+ window.removeEventListener("pointerup", handleEnd);
2263
+ window.removeEventListener("pointercancel", handleEnd);
2264
+ document.body.style.cursor = previousCursor;
2265
+ document.body.style.userSelect = previousUserSelect;
2266
+ if (iframeRef.current) iframeRef.current.style.pointerEvents = "";
2267
+ container.classList.remove("is-resizing");
2268
+ handleEl.classList.remove("is-active");
2269
+ try {
2270
+ handleEl.releasePointerCapture(pointerId);
2271
+ } catch {
2272
+ }
2273
+ resizeCleanupRef.current = null;
2274
+ };
2275
+ const handleEnd = () => {
2276
+ setSize((prev) => ({
2277
+ width: edges.h ? Math.round(container.offsetWidth) : prev.width,
2278
+ height: edges.v ? Math.round(container.offsetHeight) : prev.height
2279
+ }));
2280
+ cleanup();
2281
+ };
2282
+ resizeCleanupRef.current = cleanup;
2283
+ window.addEventListener("pointermove", handleMove);
2284
+ window.addEventListener("pointerup", handleEnd);
2285
+ window.addEventListener("pointercancel", handleEnd);
2286
+ };
2287
+ react.useEffect(() => {
2288
+ const iframe = iframeRef.current;
2289
+ if (!iframe?.contentDocument) return;
2290
+ const iframeDoc = iframe.contentDocument;
2291
+ iframeDoc.open();
2292
+ iframeDoc.write(`
2293
+ <html>
2294
+ <head>
2295
+ <style>
2296
+ :root {
2297
+ --erte-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
2298
+ --erte-text: #1a1a1a;
2299
+ --erte-toolbar-bg: #ffffff;
2300
+ --erte-border: #e6e6e6;
2301
+ --erte-icon: #5f6368;
2302
+ --erte-icon-hover: #1a1a1a;
2303
+ --erte-hover-bg: #f1f3f4;
2304
+ --erte-active-bg: #e8eaed;
2305
+ --erte-active-icon: #1a1a1a;
2306
+ --erte-accent: #d23f00;
2307
+ --erte-accent-hover: #b83700;
2308
+ --erte-radius: 8px;
2309
+ }
2310
+ * { box-sizing: border-box; }
2311
+ html, body { height: 100%; }
2312
+ /* Flex column: fixed toolbar row + scrollable content area.
2313
+ The toolbar is OUTSIDE the scroll container, so it can never
2314
+ scroll out of view — content scrolls underneath it. */
2315
+ body {
2316
+ margin: 0; font-family: var(--erte-font); font-weight: normal;
2317
+ color: var(--erte-text); background: #fff;
2318
+ display: flex; flex-direction: column; overflow: hidden;
2319
+ }
2320
+
2321
+ .toolbar {
2322
+ position: relative; z-index: 10; flex: 0 0 auto;
2323
+ display: flex; align-items: center; flex-wrap: wrap; gap: 5px 15px;
2324
+ padding: 7px 10px;
2325
+ background: var(--erte-toolbar-bg);
2326
+ border-bottom: 1px solid var(--erte-border);
2327
+ }
2328
+ /* Group separators are drawn by each group in the gap to its
2329
+ left (never standalone elements), so wrapping can't strand
2330
+ one at a row edge. Groups that start a row get .erte-row-start
2331
+ (computed from layout) which hides their separator. */
2332
+ .erte-toolbar-group { position: relative; }
2333
+ .erte-toolbar-group::before {
2334
+ content: ''; position: absolute; left: -8px; top: 50%;
2335
+ transform: translateY(-50%);
2336
+ width: 1px; height: 24px; background: #e5e7eb;
2337
+ }
2338
+ .erte-toolbar-group.erte-row-start::before { display: none; }
2339
+
2340
+ .editor-scroll {
2341
+ position: relative; /* positioning context for table add-buttons */
2342
+ flex: 1 1 auto; min-height: 0; overflow: auto;
2343
+ scrollbar-width: thin; scrollbar-color: #c9cfd6 transparent;
2344
+ }
2345
+ .editor-scroll::-webkit-scrollbar { width: 12px; height: 12px; }
2346
+ .editor-scroll::-webkit-scrollbar-track { background: transparent; }
2347
+ .editor-scroll::-webkit-scrollbar-thumb {
2348
+ background: #c9cfd6; border-radius: 8px;
2349
+ border: 3px solid #fff; background-clip: padding-box;
2350
+ }
2351
+ .editor-scroll::-webkit-scrollbar-thumb:hover { background-color: #aab2bc; }
2352
+ .erte-toolbar-group { display: flex; align-items: center; gap: 2px; }
2353
+ .erte-ai-group { display: flex; align-items: center; gap: 6px; margin-left: auto; padding-left: 8px; flex-wrap: wrap; }
2354
+
2355
+ .erte-btn {
2356
+ display: inline-flex; align-items: center; justify-content: center;
2357
+ width: 34px; height: 34px; padding: 0;
2358
+ border: none; background: transparent; border-radius: var(--erte-radius);
2359
+ color: var(--erte-icon); cursor: pointer;
2360
+ transition: background-color 0.15s ease, color 0.15s ease;
2361
+ }
2362
+ .erte-btn svg { display: block; width: 17px; height: 17px; }
2363
+ .erte-btn:hover { background: var(--erte-hover-bg); color: var(--erte-icon-hover); }
2364
+ .erte-btn:focus-visible { outline: 2px solid var(--erte-accent); outline-offset: 1px; }
2365
+ .erte-btn.is-active { background: var(--erte-active-bg); color: var(--erte-active-icon); }
2366
+
2367
+ .erte-ai-btn {
2368
+ display: inline-flex; align-items: center; height: 32px; padding: 0 14px;
2369
+ border: 1px solid var(--erte-border); background: #fff; border-radius: 999px;
2370
+ color: #3c4043; font-family: var(--erte-font); font-size: 13px; font-weight: 500;
2371
+ cursor: pointer; white-space: nowrap;
2372
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
2373
+ }
2374
+ .erte-ai-btn:hover { background: var(--erte-hover-bg); border-color: #d5d5d5; color: var(--erte-text); }
2375
+ .erte-ai-btn:focus-visible { outline: 2px solid var(--erte-accent); outline-offset: 1px; }
2376
+
2377
+ /* Paragraph style + font-size controls */
2378
+ .erte-style-group { gap: 6px; }
2379
+ .erte-select-wrap { position: relative; display: inline-flex; }
2380
+ .erte-select {
2381
+ display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 10px;
2382
+ border: 1px solid var(--erte-border); background: #fff; border-radius: var(--erte-radius);
2383
+ color: var(--erte-text); font-family: var(--erte-font); font-size: 13px; line-height: 1;
2384
+ cursor: pointer; white-space: nowrap;
2385
+ transition: background-color 0.15s ease, border-color 0.15s ease;
2386
+ }
2387
+ .erte-select:hover { background: var(--erte-hover-bg); }
2388
+ .erte-select:focus-visible { outline: 2px solid var(--erte-accent); outline-offset: 1px; }
2389
+ /* Paragraph-style trigger: a clear text-styles icon + caret,
2390
+ sized to sit alongside the Bold/Italic/Underline icons. */
2391
+ .erte-block-group { gap: 3px; }
2392
+ .erte-select--block { gap: 3px; padding: 0 6px 0 8px; }
2393
+ .erte-select-icon {
2394
+ display: inline-flex; align-items: center; justify-content: center;
2395
+ width: 20px; height: 20px; color: var(--erte-icon);
2396
+ }
2397
+ .erte-select-icon svg { display: block; width: 19px; height: 19px; }
2398
+ .erte-select--block:hover .erte-select-icon { color: var(--erte-icon-hover); }
2399
+ .erte-select--size { min-width: 60px; justify-content: center; }
2400
+ .erte-select-caret {
2401
+ width: 0; height: 0; margin-left: 2px;
2402
+ border-left: 4px solid transparent; border-right: 4px solid transparent;
2403
+ border-top: 5px solid var(--erte-icon);
2404
+ }
2405
+
2406
+ /* Icon-triggered dropdowns (alignment, indent, more) */
2407
+ .erte-icon-select { gap: 3px; padding: 0 6px 0 8px; }
2408
+ .erte-icon-select-icon {
2409
+ display: inline-flex; align-items: center; justify-content: center;
2410
+ width: 17px; height: 17px; color: var(--erte-icon);
2411
+ }
2412
+ .erte-icon-select-icon svg { display: block; width: 17px; height: 17px; }
2413
+
2414
+ /* Split-buttons (list styles): ONE shared rounded frame holding an
2415
+ icon half (default action) and a caret half (opens the style
2416
+ menu), split by a thin divider. The frame, border and base hover
2417
+ live on the wrap so the two halves read as a single control —
2418
+ matching the icon-dropdowns (alignment / indent / more). */
2419
+ .erte-split-wrap {
2420
+ display: inline-flex; align-items: stretch; height: 34px;
2421
+ border: 1px solid var(--erte-border); background: #fff;
2422
+ border-radius: var(--erte-radius);
2423
+ transition: background-color 0.15s ease, border-color 0.15s ease;
2424
+ }
2425
+ .erte-split-wrap:hover { background: var(--erte-hover-bg); }
2426
+ .erte-split-wrap:focus-within { border-color: #d0d0d0; }
2427
+ /* Halves are transparent and borderless; they inherit the wrap's
2428
+ frame. height:auto lets them stretch to the frame's inner box,
2429
+ and each keeps only its outer-edge radius. */
2430
+ .erte-split-wrap .erte-split-main {
2431
+ width: 30px; height: auto; border: none; background: transparent;
2432
+ border-radius: var(--erte-radius) 0 0 var(--erte-radius);
2433
+ }
2434
+ .erte-split-wrap .erte-split-caret {
2435
+ position: relative; width: 22px; min-width: 0; height: auto; padding: 0;
2436
+ border: none; background: transparent;
2437
+ border-radius: 0 var(--erte-radius) var(--erte-radius) 0;
2438
+ justify-content: center;
2439
+ }
2440
+ /* Thin divider between the icon and the caret. */
2441
+ .erte-split-caret::before {
2442
+ content: ''; position: absolute; left: 0; top: 50%;
2443
+ transform: translateY(-50%);
2444
+ width: 1px; height: 16px; background: var(--erte-border);
2445
+ }
2446
+ .erte-split-caret .erte-select-caret { margin-left: 0; }
2447
+ /* Per-half hover is a subtle tint layered over the frame hover, so
2448
+ pointing at just the caret nudges that section a touch darker. */
2449
+ .erte-split-wrap .erte-split-main:hover,
2450
+ .erte-split-wrap .erte-split-caret:hover { background: rgba(60, 64, 67, 0.1); }
2451
+ /* List-on (aria-pressed) state stays visible inside the frame. */
2452
+ .erte-split-wrap .erte-split-main.is-active {
2453
+ background: var(--erte-active-bg); color: var(--erte-active-icon);
2454
+ }
2455
+ .erte-select-wrap.is-open .erte-select { background: var(--erte-hover-bg); border-color: #d0d0d0; }
2456
+ /* Open split-button: highlight the whole frame, not just the caret
2457
+ (overrides the generic is-open rule above for this wrap). */
2458
+ .erte-split-wrap.is-open { background: var(--erte-hover-bg); border-color: #d0d0d0; }
2459
+ .erte-split-wrap.is-open .erte-split-caret { background: transparent; }
2460
+ .erte-select-menu {
2461
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 50;
2462
+ min-width: 100%; max-height: 280px; overflow-y: auto;
2463
+ padding: 6px; background: #fff; border: 1px solid var(--erte-border);
2464
+ border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
2465
+ visibility: hidden; opacity: 0; transform: translateY(-4px); pointer-events: none;
2466
+ transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
2467
+ }
2468
+ .erte-select-menu--size { min-width: 96px; }
2469
+ /* Paragraph styles: show all 8 options without scrolling. */
2470
+ .erte-select-menu--block { max-height: none; min-width: 168px; }
2471
+ .erte-select-wrap.is-open .erte-select-menu {
2472
+ visibility: visible; opacity: 1; transform: translateY(0); pointer-events: auto;
2473
+ }
2474
+ .erte-option {
2475
+ display: block; width: 100%; text-align: left; padding: 8px 12px;
2476
+ border: none; background: transparent; border-radius: 6px; cursor: pointer;
2477
+ color: var(--erte-text); font-family: var(--erte-font); font-size: 14px;
2478
+ line-height: 1.3; white-space: nowrap;
2479
+ }
2480
+ .erte-option:hover { background: var(--erte-hover-bg); }
2481
+ .erte-option.is-selected { background: var(--erte-accent); color: #fff; }
2482
+ .erte-option--h1 { font-size: 24px; font-weight: 700; }
2483
+ .erte-option--h2 { font-size: 20px; font-weight: 700; }
2484
+ .erte-option--h3 { font-size: 17px; font-weight: 700; }
2485
+ .erte-option--h4 { font-size: 15px; font-weight: 700; }
2486
+ .erte-option--h5 { font-size: 13px; font-weight: 700; }
2487
+ .erte-option--h6 { font-size: 12px; font-weight: 700; }
2488
+ .erte-option--pre { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 13px; }
2489
+ .erte-step-btn { font-size: 18px; font-weight: 500; color: var(--erte-icon); }
2490
+
2491
+ /* Generic menu rows (alignment, indent, more) */
2492
+ .erte-menu-item {
2493
+ display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 12px 8px 10px;
2494
+ border: none; background: transparent; border-radius: 6px; cursor: pointer;
2495
+ color: var(--erte-text); font-family: var(--erte-font); font-size: 13px;
2496
+ line-height: 1.2; white-space: nowrap; text-align: left;
2497
+ }
2498
+ .erte-menu-item:hover { background: var(--erte-hover-bg); }
2499
+ .erte-menu-item.is-selected { background: var(--erte-accent); color: #fff; }
2500
+ .erte-menu-item.is-selected .erte-menu-item-lead { color: #fff; }
2501
+ .erte-menu-item.is-active { background: var(--erte-active-bg); font-weight: 600; }
2502
+ .erte-menu-item-lead {
2503
+ display: inline-flex; align-items: center; justify-content: center;
2504
+ width: 16px; height: 16px; flex: 0 0 16px; color: var(--erte-icon);
2505
+ }
2506
+ .erte-menu-item-lead svg { display: block; width: 16px; height: 16px; }
2507
+ .erte-menu-item-label { flex: 1; }
2508
+ .erte-plain-menu { min-width: 190px; }
2509
+
2510
+ /* List style picker (numbered + bullet marker grids) */
2511
+ .erte-list-menu { padding: 8px; min-width: 0; }
2512
+ .erte-list-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
2513
+ .erte-list-tile {
2514
+ display: flex; flex-direction: column; justify-content: center; gap: 4px;
2515
+ width: 62px; padding: 8px 8px; border: 1px solid var(--erte-border);
2516
+ background: #fff; border-radius: 8px; cursor: pointer;
2517
+ transition: border-color 0.12s ease, box-shadow 0.12s ease, background-color 0.12s ease;
2518
+ }
2519
+ .erte-list-tile:hover { background: var(--erte-hover-bg); border-color: #d0d0d0; }
2520
+ .erte-list-tile.is-selected {
2521
+ border-color: var(--erte-accent);
2522
+ box-shadow: inset 0 0 0 1px var(--erte-accent);
2523
+ background: rgba(210, 63, 0, 0.06);
2524
+ }
2525
+ .erte-list-row { display: flex; align-items: center; gap: 5px; }
2526
+ .erte-list-marker {
2527
+ flex: 0 0 18px; text-align: right; font-size: 10px; line-height: 1;
2528
+ color: #3c4043; font-family: var(--erte-font);
2529
+ }
2530
+ .erte-list-bar {
2531
+ flex: 1; height: 4px; border-radius: 2px; background: #d5dbe2;
2532
+ }
2533
+ .erte-list-tile.is-selected .erte-list-bar { background: #f0b49a; }
2534
+ .erte-list-custom {
2535
+ display: flex; align-items: center; gap: 6px; margin-top: 8px;
2536
+ padding-top: 8px; border-top: 1px solid var(--erte-border);
2537
+ }
2538
+ .erte-list-custom-input {
2539
+ width: 44px; height: 30px; padding: 0 8px; text-align: center;
2540
+ border: 1px solid var(--erte-border); border-radius: 6px;
2541
+ font-family: var(--erte-font); font-size: 14px;
2542
+ }
2543
+ .erte-list-custom-input:focus { outline: none; border-color: var(--erte-accent); }
2544
+ .erte-list-custom-apply {
2545
+ flex: 1; height: 30px; padding: 0 10px; border: 1px solid var(--erte-border);
2546
+ background: #fff; border-radius: 6px; cursor: pointer;
2547
+ font-family: var(--erte-font); font-size: 12px; font-weight: 500; color: #3c4043;
2548
+ transition: background-color 0.12s ease, border-color 0.12s ease;
2549
+ }
2550
+ .erte-list-custom-apply:hover { background: var(--erte-hover-bg); border-color: #d0d0d0; }
2551
+
2552
+ /* Colour tools (text colour + highlight split buttons) */
2553
+ .erte-color-group { gap: 2px; }
2554
+ .erte-color-wrap { position: relative; }
2555
+ .erte-color-btn { width: 30px; position: relative; }
2556
+ .erte-color-glyph { font-size: 15px; font-weight: 700; line-height: 1; transform: translateY(-2px); }
2557
+ .erte-color-btn svg { transform: translateY(-2px); }
2558
+ .erte-color-bar {
2559
+ position: absolute; left: 6px; right: 6px; bottom: 5px; height: 3px;
2560
+ border-radius: 2px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
2561
+ }
2562
+ /* "No colour" state: subtle neutral slot (never a colour swatch). */
2563
+ .erte-color-bar.is-transparent {
2564
+ background: #eceff2;
2565
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
2566
+ }
2567
+ .erte-color-menu { left: auto; right: 0; padding: 8px; width: max-content; }
2568
+ .erte-color-remove, .erte-color-custom {
2569
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 8px;
2570
+ border: none; background: transparent; border-radius: 6px; cursor: pointer;
2571
+ color: var(--erte-text); font-family: var(--erte-font); font-size: 13px;
2572
+ }
2573
+ .erte-color-remove:hover, .erte-color-custom:hover { background: var(--erte-hover-bg); }
2574
+ .erte-color-remove.is-selected { background: var(--erte-active-bg); font-weight: 600; }
2575
+ .erte-color-remove svg { width: 11px; height: 11px; }
2576
+ .erte-color-custom svg { width: 14px; height: 14px; }
2577
+ .erte-color-grid { display: grid; grid-template-columns: repeat(6, 22px); gap: 4px; padding: 8px 2px; }
2578
+ .erte-color-swatch {
2579
+ width: 22px; height: 22px; padding: 0; border: none; border-radius: 4px; cursor: pointer;
2580
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); transition: transform 0.1s ease;
2581
+ }
2582
+ .erte-color-swatch:hover { transform: scale(1.15); }
2583
+ .erte-color-swatch.is-selected,
2584
+ .erte-color-swatch:focus-visible,
2585
+ .erte-color-remove:focus-visible,
2586
+ .erte-color-custom:focus-visible { outline: 2px solid var(--erte-accent); outline-offset: 1px; }
2587
+
2588
+ /* Custom colour picker modal */
2589
+ .erte-picker-backdrop { position: fixed; inset: 0; z-index: 10000; background: rgba(15, 23, 42, 0.18); }
2590
+ .erte-picker {
2591
+ position: absolute; top: 56px; left: 50%; transform: translateX(-50%);
2592
+ width: 400px; max-width: calc(100% - 32px);
2593
+ background: #fff; border: 1px solid var(--erte-border); border-radius: 12px;
2594
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); padding: 16px;
2595
+ font-family: var(--erte-font); animation: erte-pop 0.14s ease;
2596
+ }
2597
+ @keyframes erte-pop {
2598
+ from { opacity: 0; transform: translateX(-50%) scale(0.97); }
2599
+ to { opacity: 1; transform: translateX(-50%) scale(1); }
2600
+ }
2601
+ .erte-picker-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
2602
+ .erte-picker-title { font-size: 14px; font-weight: 600; color: var(--erte-text); }
2603
+ .erte-picker-close {
2604
+ width: 26px; height: 26px; border: none; background: transparent; border-radius: 6px;
2605
+ font-size: 14px; color: var(--erte-icon); cursor: pointer;
2606
+ }
2607
+ .erte-picker-close:hover { background: var(--erte-hover-bg); }
2608
+ .erte-picker-body { display: flex; gap: 12px; }
2609
+ .erte-picker-sv {
2610
+ position: relative; width: 190px; height: 150px; border-radius: 8px;
2611
+ cursor: crosshair; touch-action: none; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
2612
+ }
2613
+ .erte-picker-sv-dot {
2614
+ position: absolute; width: 12px; height: 12px; border: 2px solid #fff; border-radius: 50%;
2615
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); transform: translate(-50%, -50%); pointer-events: none;
2616
+ }
2617
+ .erte-picker-hue {
2618
+ position: relative; width: 14px; height: 150px; border-radius: 7px; cursor: pointer; touch-action: none;
2619
+ background: linear-gradient(to bottom, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
2620
+ }
2621
+ .erte-picker-hue-thumb {
2622
+ position: absolute; left: -2px; right: -2px; height: 6px; border: 2px solid #fff; border-radius: 4px;
2623
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); transform: translateY(-50%); pointer-events: none;
2624
+ }
2625
+ .erte-picker-fields { display: flex; flex-direction: column; gap: 6px; flex: 1; }
2626
+ .erte-picker-field { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; color: var(--erte-icon); }
2627
+ .erte-picker-field input {
2628
+ flex: 1; width: 100%; height: 28px; padding: 0 8px;
2629
+ border: 1px solid var(--erte-border); border-radius: 6px;
2630
+ font-size: 13px; font-family: var(--erte-font);
2631
+ }
2632
+ .erte-picker-field input:focus { outline: none; border-color: var(--erte-accent); }
2633
+ .erte-picker-preview { margin-top: auto; height: 32px; border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); }
2634
+ .erte-picker-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
2635
+
2636
+ /* Table insert picker (hover grid) */
2637
+ .erte-table-menu { padding: 10px; }
2638
+ .erte-table-grid { display: grid; grid-template-columns: repeat(10, 16px); gap: 3px; }
2639
+ .erte-table-cell {
2640
+ width: 16px; height: 16px; padding: 0; border: none; border-radius: 4px;
2641
+ background: #f1f3f4; box-shadow: inset 0 0 0 1px #e3e6ea; cursor: pointer;
2642
+ transition: background-color 0.08s ease, box-shadow 0.08s ease;
2643
+ }
2644
+ .erte-table-cell.is-on {
2645
+ background: rgba(210, 63, 0, 0.14);
2646
+ box-shadow: inset 0 0 0 1px var(--erte-accent);
2647
+ }
2648
+ .erte-table-label { margin-top: 8px; text-align: center; font-size: 12px; color: #5f6368; }
2649
+
2650
+ /* Floating add row/column buttons beside the hovered table */
2651
+ .erte-table-add {
2652
+ position: absolute; z-index: 20;
2653
+ display: flex; align-items: center; justify-content: center;
2654
+ padding: 0; border: none; border-radius: 7px;
2655
+ background: #eaf1f9; color: #0e4678;
2656
+ font-size: 13px; font-weight: 600; font-family: var(--erte-font); line-height: 1;
2657
+ cursor: pointer; opacity: 0; visibility: hidden;
2658
+ transition:
2659
+ opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease;
2660
+ }
2661
+ .erte-table-add.is-visible { opacity: 1; visibility: visible; }
2662
+ .erte-table-add:hover { background: #d6e5f4; color: #0b3860; }
2663
+ .erte-table-add:active { background: #0e4678; color: #ffffff; }
2664
+
2665
+ .editor {
2666
+ padding: 24px 28px; min-height: 100%;
2667
+ border: none; outline: none;
2668
+ font-family: var(--erte-font); font-size: 16px; line-height: 1.7; color: var(--erte-text);
2669
+ }
2670
+ .editor a { color: var(--erte-accent); }
2671
+ .editor table { border-collapse: collapse; }
2672
+ /* Semantic block styles. Explicit px sizes + weights so a heading
2673
+ always renders as a heading (independent of any inherited
2674
+ font-size), matching modern editors. The block-style tool
2675
+ strips exported inline font-size/weight so these can apply. */
2676
+ .editor h1 { font-size: 30px; font-weight: 700; line-height: 1.25; margin: 0.5em 0; }
2677
+ .editor h2 { font-size: 25px; font-weight: 700; line-height: 1.28; margin: 0.5em 0; }
2678
+ .editor h3 { font-size: 21px; font-weight: 700; line-height: 1.3; margin: 0.5em 0; }
2679
+ .editor h4 { font-size: 18px; font-weight: 700; line-height: 1.35; margin: 0.55em 0; }
2680
+ .editor h5 { font-size: 16px; font-weight: 700; line-height: 1.4; margin: 0.6em 0; }
2681
+ .editor h6 { font-size: 14px; font-weight: 700; line-height: 1.4; margin: 0.6em 0; text-transform: uppercase; letter-spacing: 0.03em; }
2682
+ .editor p { margin: 0.5em 0; }
2683
+ .editor pre {
2684
+ font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
2685
+ font-size: 13px; line-height: 1.5; white-space: pre-wrap; margin: 0.5em 0;
2686
+ }
2687
+ /* Legal (multi-level) numbering: "1.", "1.1", "1.1.1" via counters.
2688
+ Inline list-style-type can't express this, so it's attribute-driven. */
2689
+ .editor ol[data-erte-list='legal'] { counter-reset: erte-legal; list-style: none; }
2690
+ .editor ol[data-erte-list='legal'] > li { counter-increment: erte-legal; }
2691
+ .editor ol[data-erte-list='legal'] > li::marker { content: counters(erte-legal, '.') '. '; }
2692
+ /* Custom bullet character (string list-style-type isn't universal). */
2693
+ .editor ul[data-erte-list='custom'] { list-style: none; }
2694
+
2695
+ /* Floating link popover (anchored below the toolbar link button) */
2696
+ .erte-link-popover {
2697
+ position: fixed; z-index: 9000;
2698
+ width: min(420px, calc(100vw - 16px));
2699
+ background: #fff; border: 1px solid var(--erte-border);
2700
+ border-radius: 14px; padding: 5px 6px;
2701
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.06);
2702
+ font-family: var(--erte-font);
2703
+ opacity: 0; transform: translateY(-6px) scale(0.98);
2704
+ transition: opacity 0.16s ease, transform 0.16s ease;
2705
+ }
2706
+ .erte-link-popover.is-open { opacity: 1; transform: translateY(0) scale(1); }
2707
+ .erte-link-row { display: flex; align-items: center; gap: 2px; }
2708
+ .erte-link-input {
2709
+ flex: 1; min-width: 0; height: 34px; padding: 0 10px;
2710
+ border: none; outline: none; background: transparent;
2711
+ color: var(--erte-text); font-family: var(--erte-font); font-size: 14px;
2712
+ }
2713
+ .erte-link-input::placeholder { color: #9aa0a6; }
2714
+ .erte-link-popover.is-invalid .erte-link-input { color: #d93025; }
2715
+ .erte-link-btn {
2716
+ display: inline-flex; align-items: center; justify-content: center;
2717
+ flex: 0 0 auto; width: 32px; height: 32px; padding: 0;
2718
+ border: none; background: transparent; border-radius: 9px;
2719
+ color: var(--erte-icon); cursor: pointer;
2720
+ transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
2721
+ }
2722
+ .erte-link-btn svg { display: block; width: 16px; height: 16px; }
2723
+ .erte-link-btn:hover:not(:disabled) { background: var(--erte-hover-bg); color: var(--erte-icon-hover); }
2724
+ .erte-link-btn:active:not(:disabled) { background: var(--erte-active-bg); }
2725
+ .erte-link-btn:disabled { opacity: 0.35; cursor: default; }
2726
+ .erte-link-btn:focus-visible { outline: 2px solid var(--erte-accent); outline-offset: 1px; }
2727
+ .erte-link-sep { flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: var(--erte-border); }
2728
+ .erte-link-error { padding: 2px 12px 7px; font-size: 12px; color: #d93025; }
2729
+
2730
+ /* Shared action-button styles (used by the colour picker) */
2731
+ .erte-modal-btn {
2732
+ height: 36px; padding: 0 16px; border-radius: 8px;
2733
+ font-size: 13px; font-weight: 500; cursor: pointer; font-family: var(--erte-font);
2734
+ transition: background-color 0.15s ease, border-color 0.15s ease;
2735
+ }
2736
+ .erte-modal-insert { background: var(--erte-accent); border: 1px solid var(--erte-accent); color: #fff; }
2737
+ .erte-modal-insert:hover { background: var(--erte-accent-hover); border-color: var(--erte-accent-hover); }
2738
+ .erte-modal-cancel { background: #fff; border: 1px solid var(--erte-border); color: #3c4043; }
2739
+ .erte-modal-cancel:hover { background: var(--erte-hover-bg); }
2740
+ </style>
2741
+ </head>
2742
+ <body contenteditable="false">
2743
+ <div class="toolbar" id="toolbar"></div>
2744
+ <div class="editor-scroll" id="editor-scroll">
2745
+ <div class="editor" id="editor" contenteditable="true" spellcheck="true"></div>
2746
+ </div>
2747
+ </body>
2748
+ </html>
2749
+ `);
2750
+ iframeDoc.close();
2751
+ setDoc(iframeDoc);
2752
+ }, []);
2753
+ react.useEffect(() => {
2754
+ if (!doc) return;
2755
+ const editor = doc.getElementById("editor");
2756
+ if (!editor) return;
2757
+ editor.innerHTML = applySpellcheckIgnore(mailContent ?? "", spellcheckIgnoreWords);
2758
+ }, [doc]);
2759
+ react.useEffect(() => {
2760
+ if (!doc || isInternalEdit.current) return;
2761
+ const editor = doc.getElementById("editor");
2762
+ if (!editor) return;
2763
+ const displayContent = applySpellcheckIgnore(mailContent ?? "", spellcheckIgnoreWords);
2764
+ if (editor.innerHTML !== displayContent) {
2765
+ editor.innerHTML = displayContent;
2766
+ }
2767
+ }, [mailContent, resetMailContent]);
2768
+ react.useEffect(() => {
2769
+ if (!doc) return;
2770
+ const editor = doc.getElementById("editor");
2771
+ if (!editor) return;
2772
+ const handleInput = () => {
2773
+ isInternalEdit.current = true;
2774
+ setMailContent(editor.innerHTML);
2775
+ setTimeout(() => {
2776
+ isInternalEdit.current = false;
2777
+ }, 100);
2778
+ };
2779
+ editor.addEventListener("input", handleInput);
2780
+ return () => editor.removeEventListener("input", handleInput);
2781
+ }, [doc]);
2782
+ react.useEffect(() => () => linkPopoverCloseRef.current?.(), []);
2783
+ react.useEffect(() => {
2784
+ if (!doc) return;
2785
+ const editor = doc.getElementById("editor");
2786
+ if (!editor) return;
2787
+ editor.innerHTML = applySpellcheckIgnore(mailContent ?? "", spellcheckIgnoreWords);
2788
+ }, [resetMailContent]);
2789
+ react.useEffect(() => {
2790
+ renderToolbar();
2791
+ }, [doc]);
2792
+ react.useEffect(() => {
2793
+ if (!doc) return;
2794
+ return initTableTools({ doc, onContentChange: propagateContent });
2795
+ }, [doc]);
2796
+ react.useEffect(() => {
2797
+ if (!doc) return;
2798
+ const handler = () => syncToolbarState();
2799
+ doc.addEventListener("selectionchange", handler);
2800
+ return () => doc.removeEventListener("selectionchange", handler);
2801
+ }, [doc]);
2802
+ react.useEffect(() => {
2803
+ if (!doc) return;
2804
+ const closeAll = () => {
2805
+ doc.querySelectorAll(".erte-select-wrap.is-open").forEach((wrap) => {
2806
+ wrap.classList.remove("is-open");
2807
+ wrap.querySelector(".erte-select")?.setAttribute("aria-expanded", "false");
2808
+ });
2809
+ };
2810
+ const onMouseDown = (event) => {
2811
+ const target = event.target;
2812
+ if (!target || !target.closest(".erte-select-wrap")) closeAll();
2813
+ };
2814
+ const onKeyDown = (event) => {
2815
+ if (event.key === "Escape") closeAll();
2816
+ };
2817
+ doc.addEventListener("mousedown", onMouseDown);
2818
+ doc.addEventListener("keydown", onKeyDown);
2819
+ return () => {
2820
+ doc.removeEventListener("mousedown", onMouseDown);
2821
+ doc.removeEventListener("keydown", onKeyDown);
2822
+ };
2823
+ }, [doc]);
2824
+ react.useEffect(() => {
2825
+ const win = doc?.defaultView;
2826
+ if (!win) return;
2827
+ const handler = () => updateToolbarRowStarts();
2828
+ win.addEventListener("resize", handler);
2829
+ return () => win.removeEventListener("resize", handler);
2830
+ }, [doc]);
2831
+ react.useEffect(() => () => resizeCleanupRef.current?.(), []);
2832
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2833
+ loading && /* @__PURE__ */ jsxRuntime.jsx(SpinnerLoader, {}),
2834
+ /* @__PURE__ */ jsxRuntime.jsxs(
2835
+ "div",
2836
+ {
2837
+ ref: containerRef,
2838
+ className: "erte-resizable",
2839
+ style: {
2840
+ position: "relative",
2841
+ boxSizing: "border-box",
2842
+ display: "grid",
2843
+ gridTemplateRows: "minmax(0, 1fr) 8px",
2844
+ width: size.width != null ? `${size.width}px` : "100%",
2845
+ height: size.height != null ? `${size.height}px` : modalHeight || "650px",
2846
+ maxWidth: "100%",
2847
+ border: "1px solid #e6e6e6",
2848
+ borderRadius: "10px",
2849
+ background: "#fff",
2850
+ overflow: "hidden"
2851
+ },
2852
+ children: [
2853
+ /* @__PURE__ */ jsxRuntime.jsx(
2854
+ "iframe",
2855
+ {
2856
+ ref: iframeRef,
2857
+ style: {
2858
+ gridRow: "1",
2859
+ display: "block",
2860
+ width: "100%",
2861
+ height: "100%",
2862
+ border: "none"
2863
+ },
2864
+ title: "iframe-editor"
2865
+ }
2866
+ ),
2867
+ /* @__PURE__ */ jsxRuntime.jsx(
2868
+ "span",
2869
+ {
2870
+ className: "erte-resize-handle erte-resize-handle--bottom",
2871
+ role: "separator",
2872
+ "aria-orientation": "horizontal",
2873
+ "aria-label": "Resize editor height",
2874
+ style: {
2875
+ gridRow: "2",
2876
+ position: "relative",
2877
+ cursor: "ns-resize",
2878
+ touchAction: "none"
2879
+ },
2880
+ onPointerDown: startResize({ h: null, v: "bottom" })
2881
+ }
2882
+ ),
2883
+ RESIZE_ZONES.map((zone) => /* @__PURE__ */ jsxRuntime.jsx(
2884
+ "span",
2885
+ {
2886
+ className: "erte-resize-zone",
2887
+ role: "separator",
2888
+ "aria-label": zone.label,
2889
+ style: {
2890
+ position: "absolute",
2891
+ zIndex: zone.edges.h && zone.edges.v ? 7 : 6,
2892
+ cursor: zone.cursor,
2893
+ touchAction: "none",
2894
+ ...zone.placement
2895
+ },
2896
+ onPointerDown: startResize(zone.edges)
2897
+ },
2898
+ zone.key
2899
+ ))
2900
+ ]
2901
+ }
2902
+ ),
2903
+ /* @__PURE__ */ jsxRuntime.jsx(ErrorToaster, { message: toastErrorMessage, hidden: isErrorToastHidden })
2904
+ ] });
2905
+ }
2906
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? react.useLayoutEffect : react.useEffect;
2907
+ exports.FlowTextEditor = FlowTextEditor;
2908
+ exports.cx = cx;
2909
+ exports.escapeRegExp = escapeRegExp;
2910
+ exports.useIsomorphicLayoutEffect = useIsomorphicLayoutEffect;