@overlap/rte 0.1.11 → 1.0.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.js CHANGED
@@ -106,7 +106,20 @@ const Dropdown = ({ icon, label, options, onSelect, currentValue, disabled, show
106
106
  setIsOpen(false);
107
107
  };
108
108
  const currentOption = options.find(opt => opt.value === currentValue);
109
- return (jsxRuntime.jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [jsxRuntime.jsxs("button", { type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: `rte-toolbar-button rte-dropdown-button ${currentOption ? 'rte-dropdown-button-has-value' : ''}`, title: label, "aria-label": label, children: [jsxRuntime.jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsxRuntime.jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxRuntime.jsxs("div", { className: "rte-dropdown-menu", children: [options.map((option) => (jsxRuntime.jsxs("button", { type: "button", className: `rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`, onClick: () => handleSelect(option.value), children: [option.color && (jsxRuntime.jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsxRuntime.jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsxRuntime.jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsxRuntime.jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsxRuntime.jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxRuntime.jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsxRuntime.jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsxRuntime.jsx("input", { type: "text", value: customColor, onChange: (e) => {
109
+ // Close on Escape key
110
+ React.useEffect(() => {
111
+ if (!isOpen)
112
+ return;
113
+ const handleKeyDown = (e) => {
114
+ if (e.key === "Escape") {
115
+ e.preventDefault();
116
+ setIsOpen(false);
117
+ }
118
+ };
119
+ document.addEventListener("keydown", handleKeyDown);
120
+ return () => document.removeEventListener("keydown", handleKeyDown);
121
+ }, [isOpen]);
122
+ return (jsxRuntime.jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [jsxRuntime.jsxs("button", { type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: `rte-toolbar-button rte-dropdown-button ${currentOption ? 'rte-dropdown-button-has-value' : ''}`, title: label, "aria-label": label, "aria-expanded": isOpen, "aria-haspopup": "listbox", children: [jsxRuntime.jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsxRuntime.jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxRuntime.jsxs("div", { className: "rte-dropdown-menu", role: "listbox", "aria-label": label, children: [options.map((option) => (jsxRuntime.jsxs("button", { type: "button", role: "option", "aria-selected": currentValue === option.value, className: `rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`, onClick: () => handleSelect(option.value), children: [option.color && (jsxRuntime.jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsxRuntime.jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsxRuntime.jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsxRuntime.jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsxRuntime.jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxRuntime.jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsxRuntime.jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsxRuntime.jsx("input", { type: "text", value: customColor, onChange: (e) => {
110
123
  const v = e.target.value;
111
124
  setCustomColor(v);
112
125
  }, placeholder: "#000000", maxLength: 7, onKeyDown: (e) => {
@@ -661,6 +674,168 @@ function useCheckbox({ editorRef, isUpdatingRef, pushToHistory, notifyChange, ge
661
674
  };
662
675
  }
663
676
 
677
+ /**
678
+ * Lightweight HTML sanitizer (no external dependencies).
679
+ *
680
+ * Uses an allowlist approach to strip dangerous elements and attributes
681
+ * from pasted/imported HTML before it enters the editor.
682
+ */
683
+ /** Tags that are always removed (including their content). */
684
+ const REMOVE_TAGS = new Set([
685
+ "script",
686
+ "style",
687
+ "iframe",
688
+ "object",
689
+ "embed",
690
+ "applet",
691
+ "form",
692
+ "input",
693
+ "textarea",
694
+ "select",
695
+ "button",
696
+ "noscript",
697
+ "meta",
698
+ "link",
699
+ "base",
700
+ "svg",
701
+ "math",
702
+ "template",
703
+ "details",
704
+ "video",
705
+ "audio",
706
+ "marquee",
707
+ ]);
708
+ /** Attributes that are always removed (event handlers, dangerous data-* attrs). */
709
+ const REMOVE_ATTRS_PATTERN = /^on|^data-(?!(attachment-id|placeholder)$)/i;
710
+ /** Specific dangerous attribute names. */
711
+ const REMOVE_ATTRS = new Set([
712
+ "srcdoc",
713
+ "formaction",
714
+ "xlink:href",
715
+ "ping",
716
+ ]);
717
+ /** Allowed URL schemes for href/src attributes. */
718
+ const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/(?!\/))/i;
719
+ /**
720
+ * Check if a URL is safe to set as href/src.
721
+ * Blocks javascript:, data:, vbscript:, and unknown schemes.
722
+ * Strips control characters before checking.
723
+ */
724
+ function isUrlSafe(url) {
725
+ if (!url)
726
+ return false;
727
+ // Strip control characters and whitespace that could obfuscate schemes
728
+ const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, "");
729
+ if (!cleaned)
730
+ return false;
731
+ // Block protocol-relative URLs (//evil.com)
732
+ if (cleaned.startsWith("//"))
733
+ return false;
734
+ // Must match an allowed scheme or be a relative path
735
+ return ALLOWED_SCHEMES.test(cleaned);
736
+ }
737
+ /**
738
+ * Sanitize an HTML string by stripping dangerous tags and attributes.
739
+ *
740
+ * @param html - Raw HTML string
741
+ * @returns Sanitized HTML string
742
+ */
743
+ function sanitizeHtml(html) {
744
+ // Use the browser's DOMParser to parse the HTML safely
745
+ const parser = new DOMParser();
746
+ const doc = parser.parseFromString(html, "text/html");
747
+ sanitizeNode(doc.body);
748
+ return doc.body.innerHTML;
749
+ }
750
+ /** Recursively sanitize a DOM node. */
751
+ function sanitizeNode(node) {
752
+ const childrenToRemove = [];
753
+ node.childNodes.forEach((child) => {
754
+ if (child.nodeType === Node.ELEMENT_NODE) {
755
+ const el = child;
756
+ const tag = el.tagName.toLowerCase();
757
+ // Remove dangerous tags entirely
758
+ if (REMOVE_TAGS.has(tag)) {
759
+ childrenToRemove.push(child);
760
+ return;
761
+ }
762
+ // Remove dangerous attributes
763
+ const attrsToRemove = [];
764
+ for (let i = 0; i < el.attributes.length; i++) {
765
+ const attr = el.attributes[i];
766
+ const name = attr.name.toLowerCase();
767
+ if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
768
+ attrsToRemove.push(attr.name);
769
+ continue;
770
+ }
771
+ // Validate URL attributes
772
+ if (name === "href" || name === "src" || name === "action" || name === "cite" || name === "poster") {
773
+ const value = attr.value.trim();
774
+ if (value) {
775
+ // Allow data:image/* for img src (matches contentToDOM behavior)
776
+ const isDataImage = name === "src" && el.tagName === "IMG" && value.startsWith("data:image/");
777
+ if (!isDataImage && !ALLOWED_SCHEMES.test(value)) {
778
+ attrsToRemove.push(attr.name);
779
+ }
780
+ }
781
+ }
782
+ // Remove dangerous URI schemes in any attribute value
783
+ const lowerValue = attr.value.toLowerCase().replace(/[\x00-\x1f\x7f\s]/g, "");
784
+ if (lowerValue.includes("javascript:") ||
785
+ lowerValue.includes("vbscript:") ||
786
+ lowerValue.includes("data:text/html")) {
787
+ attrsToRemove.push(attr.name);
788
+ }
789
+ }
790
+ attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
791
+ // Recurse into children
792
+ sanitizeNode(child);
793
+ }
794
+ });
795
+ // Remove marked children
796
+ childrenToRemove.forEach((child) => {
797
+ node.removeChild(child);
798
+ });
799
+ }
800
+
801
+ /** Allowed tag names that contentToDOM may create from JSON. */
802
+ const ALLOWED_CONTENT_TAGS = new Set([
803
+ "p", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
804
+ "ul", "ol", "li", "a", "strong", "em", "u", "s", "del",
805
+ "sub", "sup", "code", "pre", "blockquote", "br", "hr",
806
+ "img", "table", "thead", "tbody", "tr", "th", "td",
807
+ "b", "i", "strike",
808
+ ]);
809
+ /** Checks if an attribute key is safe to set on a DOM element. */
810
+ function isSafeAttribute(key) {
811
+ const lower = key.toLowerCase();
812
+ // Block all event handler attributes
813
+ if (lower.startsWith("on"))
814
+ return false;
815
+ // Block dangerous attributes
816
+ if (lower === "srcdoc" || lower === "formaction" || lower === "xlink:href" || lower === "ping")
817
+ return false;
818
+ return true;
819
+ }
820
+ /** Validates style values against expected patterns. */
821
+ function isSafeStyleValue(prop, value) {
822
+ const clean = value.trim().toLowerCase();
823
+ // Block expression(), url(), import, javascript, and other injection vectors
824
+ if (/expression\s*\(|url\s*\(|@import|javascript:|vbscript:|-moz-binding/i.test(clean)) {
825
+ return false;
826
+ }
827
+ switch (prop) {
828
+ case "fontSize":
829
+ return /^[\d.]+(px|em|rem|pt|%|vw|vh)$/i.test(clean);
830
+ case "color":
831
+ case "backgroundColor":
832
+ return /^(#[0-9a-f]{3,8}|rgb\([\d\s,.%]+\)|rgba\([\d\s,.%]+\)|[a-z]+)$/i.test(clean);
833
+ case "textAlign":
834
+ return /^(left|right|center|justify|start|end)$/.test(clean);
835
+ default:
836
+ return true;
837
+ }
838
+ }
664
839
  /**
665
840
  * Converts a DOM element (editor root) to EditorContent JSON.
666
841
  * Supports own format, Lexical HTML, and GitHub HTML.
@@ -981,8 +1156,13 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
981
1156
  if (node.type === "image") {
982
1157
  const img = document.createElement("img");
983
1158
  if (node.attributes) {
984
- if (node.attributes.src)
985
- img.setAttribute("src", node.attributes.src);
1159
+ if (node.attributes.src) {
1160
+ // Allow data:image/* for placeholders/uploads, otherwise validate
1161
+ const imgSrc = node.attributes.src;
1162
+ if (isUrlSafe(imgSrc) || imgSrc.startsWith("data:image/")) {
1163
+ img.setAttribute("src", imgSrc);
1164
+ }
1165
+ }
986
1166
  if (node.attributes.alt)
987
1167
  img.setAttribute("alt", node.attributes.alt);
988
1168
  if (node.attributes.uploading === "true") {
@@ -1009,23 +1189,32 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
1009
1189
  node.attributes["data-custom-link"] = "true";
1010
1190
  }
1011
1191
  }
1192
+ // Whitelist check: fall back to span for unknown/dangerous tags
1193
+ if (!ALLOWED_CONTENT_TAGS.has(tagName)) {
1194
+ tagName = "span";
1195
+ }
1012
1196
  const element = document.createElement(tagName);
1013
1197
  if (node.attributes) {
1014
1198
  Object.entries(node.attributes).forEach(([key, value]) => {
1015
1199
  if (key === "fontSize") {
1016
- element.style.fontSize = value;
1200
+ if (isSafeStyleValue("fontSize", value))
1201
+ element.style.fontSize = value;
1017
1202
  }
1018
1203
  else if (key === "color") {
1019
- element.style.color = value;
1204
+ if (isSafeStyleValue("color", value))
1205
+ element.style.color = value;
1020
1206
  }
1021
1207
  else if (key === "backgroundColor") {
1022
- element.style.backgroundColor = value;
1208
+ if (isSafeStyleValue("backgroundColor", value))
1209
+ element.style.backgroundColor = value;
1023
1210
  }
1024
1211
  else if (key === "textAlign") {
1025
- element.style.textAlign = value;
1212
+ if (isSafeStyleValue("textAlign", value))
1213
+ element.style.textAlign = value;
1026
1214
  }
1027
1215
  else if (key === "href" && tagName === "a") {
1028
- element.setAttribute("href", value);
1216
+ if (isUrlSafe(value))
1217
+ element.setAttribute("href", value);
1029
1218
  }
1030
1219
  else if (key === "target" && tagName === "a") {
1031
1220
  element.setAttribute("target", value);
@@ -1055,8 +1244,16 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
1055
1244
  element.className = value;
1056
1245
  }
1057
1246
  else if (key === "checkboxChecked") ;
1058
- else {
1059
- element.setAttribute(key, value);
1247
+ else if (isSafeAttribute(key)) {
1248
+ // Validate URL-like attribute values
1249
+ const lowerKey = key.toLowerCase();
1250
+ if (["cite", "action", "poster", "background"].includes(lowerKey)) {
1251
+ if (isUrlSafe(value))
1252
+ element.setAttribute(key, value);
1253
+ }
1254
+ else {
1255
+ element.setAttribute(key, value);
1256
+ }
1060
1257
  }
1061
1258
  });
1062
1259
  }
@@ -1103,10 +1300,12 @@ function contentToHTML(content) {
1103
1300
  /**
1104
1301
  * Converts an HTML string to EditorContent.
1105
1302
  * Supports Lexical, GitHub, and standard HTML formats.
1303
+ * Sanitizes HTML before parsing to prevent XSS.
1106
1304
  */
1107
1305
  function htmlToContent(htmlString) {
1306
+ const sanitized = sanitizeHtml(htmlString);
1108
1307
  const tempDiv = document.createElement("div");
1109
- tempDiv.innerHTML = htmlString;
1308
+ tempDiv.innerHTML = sanitized;
1110
1309
  return domToContent(tempDiv);
1111
1310
  }
1112
1311
 
@@ -1116,7 +1315,7 @@ function htmlToContent(htmlString) {
1116
1315
  * Detects URLs in text nodes and wraps them in <a> tags.
1117
1316
  * Triggered after space/enter when the preceding word looks like a URL.
1118
1317
  */
1119
- const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<>'"]+\.[^\s<>'"]+/i;
1318
+ const URL_REGEX = /^(?:https?:\/\/|www\.)[^\s<>'"]+\.[a-z]{2,}[^\s<>'"]*$/i;
1120
1319
  /**
1121
1320
  * Check if the word before the cursor is a URL and wrap it in an <a> tag.
1122
1321
  * Called on space/enter keypress in the editor.
@@ -1154,6 +1353,9 @@ function handleAutoLink(editor, e) {
1154
1353
  if (href.startsWith("www.")) {
1155
1354
  href = "https://" + href;
1156
1355
  }
1356
+ // Validate the URL is safe before creating a link
1357
+ if (!isUrlSafe(href))
1358
+ return false;
1157
1359
  // Create the link element
1158
1360
  const anchor = document.createElement("a");
1159
1361
  anchor.href = href;
@@ -1215,22 +1417,22 @@ function indentListItem(selection) {
1215
1417
  break;
1216
1418
  }
1217
1419
  }
1218
- // Max depth: 6 (wie in HTML Standard)
1420
+ // Max depth: 6 (as per HTML standard)
1219
1421
  if (depth >= 6)
1220
1422
  return false;
1221
- // Finde vorheriges List-Item
1423
+ // Find previous list item
1222
1424
  const previousItem = listItem.previousElementSibling;
1223
1425
  if (previousItem && previousItem.tagName === 'LI') {
1224
- // Erstelle verschachtelte Liste im vorherigen Item
1426
+ // Create nested list in the previous item
1225
1427
  let nestedList = previousItem.querySelector('ul, ol');
1226
1428
  if (!nestedList) {
1227
- // Erstelle neue verschachtelte Liste
1429
+ // Create new nested list
1228
1430
  nestedList = document.createElement(list.tagName.toLowerCase());
1229
1431
  previousItem.appendChild(nestedList);
1230
1432
  }
1231
- // Verschiebe aktuelles Item in verschachtelte Liste
1433
+ // Move current item into nested list
1232
1434
  nestedList.appendChild(listItem);
1233
- // Cursor setzen
1435
+ // Set cursor position
1234
1436
  const textNode = listItem.firstChild;
1235
1437
  if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1236
1438
  range.setStart(textNode, 0);
@@ -1251,9 +1453,9 @@ function indentListItem(selection) {
1251
1453
  return true;
1252
1454
  }
1253
1455
  else {
1254
- // Kein vorheriges Item - erstelle neue verschachtelte Liste im aktuellen Item
1456
+ // No previous item create new nested list in current item
1255
1457
  const nestedList = document.createElement(list.tagName.toLowerCase());
1256
- // Verschiebe alle nachfolgenden Items in die verschachtelte Liste
1458
+ // Move all following items into the nested list
1257
1459
  let nextSibling = listItem.nextElementSibling;
1258
1460
  while (nextSibling && nextSibling.tagName === 'LI') {
1259
1461
  const toMove = nextSibling;
@@ -1264,11 +1466,11 @@ function indentListItem(selection) {
1264
1466
  listItem.appendChild(nestedList);
1265
1467
  }
1266
1468
  else {
1267
- // Wenn keine nachfolgenden Items, erstelle leeres Sub-Item
1469
+ // If no following items, create empty sub-item
1268
1470
  const subItem = document.createElement('li');
1269
1471
  nestedList.appendChild(subItem);
1270
1472
  listItem.appendChild(nestedList);
1271
- // Cursor ins Sub-Item setzen
1473
+ // Set cursor in sub-item
1272
1474
  const newText = document.createTextNode('');
1273
1475
  subItem.appendChild(newText);
1274
1476
  range.setStart(newText, 0);
@@ -1298,17 +1500,17 @@ function outdentListItem(selection) {
1298
1500
  // Check if in nested list
1299
1501
  const parentListItem = list.parentElement;
1300
1502
  if (!parentListItem || parentListItem.tagName !== 'LI') {
1301
- // Bereits auf oberstem Level
1503
+ // Already at top level
1302
1504
  return false;
1303
1505
  }
1304
1506
  const parentList = parentListItem.parentElement;
1305
1507
  if (!parentList || (parentList.tagName !== 'UL' && parentList.tagName !== 'OL')) {
1306
1508
  return false;
1307
1509
  }
1308
- // Verschiebe Item auf oberes Level
1309
- // Finde Position nach dem Parent-Item
1510
+ // Move item to parent level
1511
+ // Find position after the parent item
1310
1512
  const insertAfter = parentListItem;
1311
- // Verschiebe Item und alle nachfolgenden Items aus der verschachtelten Liste
1513
+ // Move item and all following items out of the nested list
1312
1514
  const itemsToMove = [listItem];
1313
1515
  let nextSibling = listItem.nextElementSibling;
1314
1516
  while (nextSibling && nextSibling.tagName === 'LI') {
@@ -1319,11 +1521,11 @@ function outdentListItem(selection) {
1319
1521
  itemsToMove.forEach(item => {
1320
1522
  parentList.insertBefore(item, insertAfter.nextSibling);
1321
1523
  });
1322
- // Entferne leere verschachtelte Liste
1524
+ // Remove empty nested list
1323
1525
  if (list.children.length === 0) {
1324
1526
  list.remove();
1325
1527
  }
1326
- // Cursor setzen
1528
+ // Set cursor position
1327
1529
  const textNode = listItem.firstChild;
1328
1530
  if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1329
1531
  range.setStart(textNode, 0);
@@ -1857,7 +2059,7 @@ function navigateTableCell(direction) {
1857
2059
  /**
1858
2060
  * Hook that sets up input and keydown event listeners on the editor.
1859
2061
  */
1860
- function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
2062
+ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, mountedRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
1861
2063
  React.useEffect(() => {
1862
2064
  const editor = editorRef.current;
1863
2065
  if (!editor)
@@ -1865,11 +2067,15 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1865
2067
  let inputTimeout = null;
1866
2068
  const handleInput = () => {
1867
2069
  setTimeout(() => {
2070
+ if (!mountedRef.current)
2071
+ return;
1868
2072
  const content = domToContent(editor);
1869
2073
  notifyChange(content);
1870
2074
  if (inputTimeout)
1871
2075
  clearTimeout(inputTimeout);
1872
2076
  inputTimeout = setTimeout(() => {
2077
+ if (!mountedRef.current)
2078
+ return;
1873
2079
  const sel = serializeSelection(editor);
1874
2080
  historyRef.current.push(content, sel);
1875
2081
  inputTimeout = null;
@@ -1881,6 +2087,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1881
2087
  // Markdown-style shortcuts (e.g., # + space → heading)
1882
2088
  if (!isModifierPressed && handleMarkdownShortcut(editor, e)) {
1883
2089
  setTimeout(() => {
2090
+ if (!mountedRef.current)
2091
+ return;
1884
2092
  const content = domToContent(editor);
1885
2093
  notifyChange(content);
1886
2094
  }, 0);
@@ -1905,6 +2113,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1905
2113
  e.stopImmediatePropagation();
1906
2114
  navigateTableCell(e.shiftKey ? "prev" : "next");
1907
2115
  setTimeout(() => {
2116
+ if (!mountedRef.current)
2117
+ return;
1908
2118
  if (editor) {
1909
2119
  const content = domToContent(editor);
1910
2120
  notifyChange(content);
@@ -1936,6 +2146,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1936
2146
  indentListItem(selection);
1937
2147
  }
1938
2148
  setTimeout(() => {
2149
+ if (!mountedRef.current)
2150
+ return;
1939
2151
  if (editor) {
1940
2152
  const content = domToContent(editor);
1941
2153
  notifyChange(content);
@@ -2047,6 +2259,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
2047
2259
  document.execCommand(command, false);
2048
2260
  }
2049
2261
  setTimeout(() => {
2262
+ if (!mountedRef.current)
2263
+ return;
2050
2264
  if (editor) {
2051
2265
  const content = domToContent(editor);
2052
2266
  notifyChange(content);
@@ -2096,6 +2310,7 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
2096
2310
  // Normalize all checkbox lists (own + Lexical + GitHub formats)
2097
2311
  ensureAllCheckboxes(editor);
2098
2312
  // MutationObserver: ensure new checkbox list items get correct attributes
2313
+ let checkboxTimeout = null;
2099
2314
  const observer = new MutationObserver((mutations) => {
2100
2315
  if (isUpdatingRef.current)
2101
2316
  return;
@@ -2125,11 +2340,15 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
2125
2340
  break;
2126
2341
  }
2127
2342
  if (needsUpdate) {
2128
- setTimeout(() => ensureAllCheckboxes(editor), 0);
2343
+ if (checkboxTimeout)
2344
+ clearTimeout(checkboxTimeout);
2345
+ checkboxTimeout = setTimeout(() => ensureAllCheckboxes(editor), 0);
2129
2346
  }
2130
2347
  });
2131
2348
  observer.observe(editor, { childList: true, subtree: true });
2132
2349
  return () => {
2350
+ if (checkboxTimeout)
2351
+ clearTimeout(checkboxTimeout);
2133
2352
  observer.disconnect();
2134
2353
  };
2135
2354
  // Only run once on mount
@@ -2170,7 +2389,7 @@ function createInlinePlugin(name, command, icon, label) {
2170
2389
  },
2171
2390
  canExecute: (editor) => {
2172
2391
  // Formatting should also work without a selection
2173
- // (z.B. wenn Editor leer ist, wird beim Klick eine Selection erstellt)
2392
+ // (e.g. when the editor is empty, a selection is created on click)
2174
2393
  return true;
2175
2394
  },
2176
2395
  };
@@ -2941,7 +3160,7 @@ function getDisplayUrl(raw) {
2941
3160
  return raw;
2942
3161
  }
2943
3162
  /**
2944
- * Image-Plugin mit URL-Eingabe und File-Upload
3163
+ * Image plugin with URL input and file upload support.
2945
3164
  */
2946
3165
  function createImagePlugin(onImageUpload) {
2947
3166
  return {
@@ -2964,7 +3183,17 @@ function createImagePlugin(onImageUpload) {
2964
3183
  alert('Please enter an image URL');
2965
3184
  return;
2966
3185
  }
2967
- props.editorAPI.executeCommand('insertImage', src);
3186
+ // Validate URL safety
3187
+ const srcWithoutMeta = src.split('|__aid__:')[0];
3188
+ if (!isUrlSafe(srcWithoutMeta)) {
3189
+ alert('Invalid image URL');
3190
+ return;
3191
+ }
3192
+ // Append alt text metadata if provided
3193
+ const fullValue = altText.trim()
3194
+ ? `${src}|__alt__:${altText.trim()}`
3195
+ : src;
3196
+ props.editorAPI.executeCommand('insertImage', fullValue);
2968
3197
  // Close modal
2969
3198
  setShowModal(false);
2970
3199
  setRawUrl('');
@@ -3002,11 +3231,11 @@ function createImagePlugin(onImageUpload) {
3002
3231
  if (e.target === e.currentTarget) {
3003
3232
  setShowModal(false);
3004
3233
  }
3005
- }, children: jsxRuntime.jsxs("div", { className: "rte-image-modal", children: [jsxRuntime.jsxs("div", { className: "rte-image-modal-header", children: [jsxRuntime.jsx("h3", { children: "Insert Image" }), jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsxRuntime.jsx("div", { className: "rte-image-upload-section", children: jsxRuntime.jsxs("label", { className: "rte-image-upload-label", children: [jsxRuntime.jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsxRuntime.jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsxRuntime.jsx("span", { children: "Wird hochgeladen..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsxRuntime.jsx("span", { children: "Choose File" })] })) })] }) })), jsxRuntime.jsx("div", { className: "rte-image-url-section", children: jsxRuntime.jsxs("label", { children: ["Bild-URL", jsxRuntime.jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
3234
+ }, children: jsxRuntime.jsxs("div", { className: "rte-image-modal", children: [jsxRuntime.jsxs("div", { className: "rte-image-modal-header", children: [jsxRuntime.jsx("h3", { children: "Insert Image" }), jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsxRuntime.jsx("div", { className: "rte-image-upload-section", children: jsxRuntime.jsxs("label", { className: "rte-image-upload-label", children: [jsxRuntime.jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsxRuntime.jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsxRuntime.jsx("span", { children: "Uploading..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsxRuntime.jsx("span", { children: "Choose File" })] })) })] }) })), jsxRuntime.jsx("div", { className: "rte-image-url-section", children: jsxRuntime.jsxs("label", { children: ["Image URL", jsxRuntime.jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
3006
3235
  setImageUrl(e.target.value);
3007
3236
  // Clear raw URL when user manually edits
3008
3237
  setRawUrl('');
3009
- }, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsxRuntime.jsx("div", { className: "rte-image-alt-section", children: jsxRuntime.jsxs("label", { children: ["Alt-Text (optional)", jsxRuntime.jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsxRuntime.jsx("div", { className: "rte-image-preview", children: jsxRuntime.jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-footer", children: [jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Abbrechen" }), jsxRuntime.jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
3238
+ }, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsxRuntime.jsx("div", { className: "rte-image-alt-section", children: jsxRuntime.jsxs("label", { children: ["Alt Text (optional)", jsxRuntime.jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsxRuntime.jsx("div", { className: "rte-image-preview", children: jsxRuntime.jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxRuntime.jsxs("div", { className: "rte-image-modal-footer", children: [jsxRuntime.jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Cancel" }), jsxRuntime.jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
3010
3239
  },
3011
3240
  execute: (editor) => {
3012
3241
  // Handled via renderButton
@@ -3077,7 +3306,7 @@ function clearFormatting(selection) {
3077
3306
  }
3078
3307
  catch (error) {
3079
3308
  console.error('Error clearing formatting:', error);
3080
- // Fallback: Einfache Methode
3309
+ // Fallback: simple method
3081
3310
  document.execCommand('removeFormat', false);
3082
3311
  document.execCommand('unlink', false);
3083
3312
  }
@@ -3226,7 +3455,7 @@ class HistoryManager {
3226
3455
  // Limit history size
3227
3456
  if (this.history.length > this.maxHistorySize) {
3228
3457
  this.history.shift();
3229
- this.currentIndex--;
3458
+ this.currentIndex = Math.max(0, this.currentIndex - 1);
3230
3459
  }
3231
3460
  }
3232
3461
  undo() {
@@ -3273,98 +3502,6 @@ class HistoryManager {
3273
3502
  }
3274
3503
  }
3275
3504
 
3276
- /**
3277
- * Lightweight HTML sanitizer (no external dependencies).
3278
- *
3279
- * Uses an allowlist approach to strip dangerous elements and attributes
3280
- * from pasted/imported HTML before it enters the editor.
3281
- */
3282
- /** Tags that are always removed (including their content). */
3283
- const REMOVE_TAGS = new Set([
3284
- "script",
3285
- "style",
3286
- "iframe",
3287
- "object",
3288
- "embed",
3289
- "applet",
3290
- "form",
3291
- "input",
3292
- "textarea",
3293
- "select",
3294
- "button",
3295
- "noscript",
3296
- "meta",
3297
- "link",
3298
- "base",
3299
- ]);
3300
- /** Attributes that are always removed (event handlers, dangerous attrs). */
3301
- const REMOVE_ATTRS_PATTERN = /^on|^data-(?!attachment-id$|placeholder$)/i;
3302
- /** Specific dangerous attribute names. */
3303
- const REMOVE_ATTRS = new Set([
3304
- "srcdoc",
3305
- "formaction",
3306
- "xlink:href",
3307
- "ping",
3308
- ]);
3309
- /** Allowed URL schemes for href/src attributes. */
3310
- const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/)/i;
3311
- /**
3312
- * Sanitize an HTML string by stripping dangerous tags and attributes.
3313
- *
3314
- * @param html - Raw HTML string
3315
- * @returns Sanitized HTML string
3316
- */
3317
- function sanitizeHtml(html) {
3318
- // Use the browser's DOMParser to parse the HTML safely
3319
- const parser = new DOMParser();
3320
- const doc = parser.parseFromString(html, "text/html");
3321
- sanitizeNode(doc.body);
3322
- return doc.body.innerHTML;
3323
- }
3324
- /** Recursively sanitize a DOM node. */
3325
- function sanitizeNode(node) {
3326
- const childrenToRemove = [];
3327
- node.childNodes.forEach((child) => {
3328
- if (child.nodeType === Node.ELEMENT_NODE) {
3329
- const el = child;
3330
- const tag = el.tagName.toLowerCase();
3331
- // Remove dangerous tags entirely
3332
- if (REMOVE_TAGS.has(tag)) {
3333
- childrenToRemove.push(child);
3334
- return;
3335
- }
3336
- // Remove dangerous attributes
3337
- const attrsToRemove = [];
3338
- for (let i = 0; i < el.attributes.length; i++) {
3339
- const attr = el.attributes[i];
3340
- const name = attr.name.toLowerCase();
3341
- if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
3342
- attrsToRemove.push(attr.name);
3343
- continue;
3344
- }
3345
- // Validate URL attributes
3346
- if (name === "href" || name === "src" || name === "action") {
3347
- const value = attr.value.trim();
3348
- if (value && !ALLOWED_SCHEMES.test(value)) {
3349
- attrsToRemove.push(attr.name);
3350
- }
3351
- }
3352
- // Remove javascript: in any attribute value
3353
- if (attr.value.toLowerCase().includes("javascript:")) {
3354
- attrsToRemove.push(attr.name);
3355
- }
3356
- }
3357
- attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
3358
- // Recurse into children
3359
- sanitizeNode(child);
3360
- }
3361
- });
3362
- // Remove marked children
3363
- childrenToRemove.forEach((child) => {
3364
- node.removeChild(child);
3365
- });
3366
- }
3367
-
3368
3505
  const alignmentLabels = {
3369
3506
  left: "Left",
3370
3507
  center: "Center",
@@ -3582,9 +3719,9 @@ const FloatingLinkEditor = ({ linkData: initialData, options, anchorRect, isEdit
3582
3719
  const urlDisabledByCustom = customFields.some((f) => f.disablesUrl && !!data.custom[f.key]);
3583
3720
  const hasAdvancedSection = options.enableTarget || customFields.length > 0;
3584
3721
  const pos = computePosition(anchorRect);
3585
- return reactDom.createPortal(jsxRuntime.jsxs("div", { ref: dialogRef, className: "rte-link-dialog rte-link-dialog-floating", style: { top: pos.top, left: pos.left, position: "fixed" }, onKeyDown: handleKeyDown, onMouseDown: (e) => e.stopPropagation(), children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsxRuntime.jsx("input", { ref: urlInputRef, type: "url", className: "rte-link-dialog-input", value: data.url, onChange: (e) => setField("url", e.target.value), placeholder: "https://...", disabled: urlDisabledByCustom })] }), hasAdvancedSection && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxRuntime.jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Erweitert"] }) }), showAdvanced && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: field.label }), jsxRuntime.jsx("input", { type: "text", className: "rte-link-dialog-input", value: data.custom[field.key] || "", onChange: (e) => setCustom(field.key, e.target.value), placeholder: field.placeholder })] }, field.key))), options.enableTarget && (jsxRuntime.jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
3722
+ return reactDom.createPortal(jsxRuntime.jsxs("div", { ref: dialogRef, className: "rte-link-dialog rte-link-dialog-floating", style: { top: pos.top, left: pos.left, position: "fixed" }, onKeyDown: handleKeyDown, onMouseDown: (e) => e.stopPropagation(), children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsxRuntime.jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: "URL" }), jsxRuntime.jsx("input", { ref: urlInputRef, type: "url", className: "rte-link-dialog-input", value: data.url, onChange: (e) => setField("url", e.target.value), placeholder: "https://...", disabled: urlDisabledByCustom })] }), hasAdvancedSection && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxRuntime.jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Advanced"] }) }), showAdvanced && (jsxRuntime.jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxRuntime.jsxs("div", { className: "rte-link-dialog-field", children: [jsxRuntime.jsx("label", { className: "rte-link-dialog-label", children: field.label }), jsxRuntime.jsx("input", { type: "text", className: "rte-link-dialog-input", value: data.custom[field.key] || "", onChange: (e) => setCustom(field.key, e.target.value), placeholder: field.placeholder })] }, field.key))), options.enableTarget && (jsxRuntime.jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
3586
3723
  ? "_blank"
3587
- : "_self") }), jsxRuntime.jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxRuntime.jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Entfernen" })), jsxRuntime.jsx("div", { style: { flex: 1 } }), jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Abbrechen" }), jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-primary", onMouseDown: (e) => e.preventDefault(), onClick: () => onSave(data), disabled: !data.url.trim() || data.url.trim() === "https://", children: "Speichern" })] })] }), document.body);
3724
+ : "_self") }), jsxRuntime.jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxRuntime.jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove" })), jsxRuntime.jsx("div", { style: { flex: 1 } }), jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Cancel" }), jsxRuntime.jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-primary", onMouseDown: (e) => e.preventDefault(), onClick: () => onSave(data), disabled: !data.url.trim() || data.url.trim() === "https://", children: "Save" })] })] }), document.body);
3588
3725
  };
3589
3726
  /* ══════════════════════════════════════════════════════════════════════════
3590
3727
  Helpers
@@ -3631,6 +3768,9 @@ function applyLinkData(link, data, customFields) {
3631
3768
  href += data.custom[field.key];
3632
3769
  }
3633
3770
  }
3771
+ // Validate URL before setting href to prevent javascript: / data: injection
3772
+ if (!isUrlSafe(href))
3773
+ return;
3634
3774
  link.setAttribute("href", href);
3635
3775
  // Target
3636
3776
  if (data.target && data.target !== "_self") {
@@ -3706,7 +3846,7 @@ const LinkToolbarButton = (props) => {
3706
3846
  e.preventDefault();
3707
3847
  e.stopPropagation();
3708
3848
  const href = link.getAttribute("href");
3709
- if (href)
3849
+ if (href && isUrlSafe(href))
3710
3850
  window.open(href, "_blank");
3711
3851
  return;
3712
3852
  }
@@ -3746,6 +3886,8 @@ const LinkToolbarButton = (props) => {
3746
3886
  href += data.custom[field.key];
3747
3887
  }
3748
3888
  }
3889
+ if (!isUrlSafe(href))
3890
+ return;
3749
3891
  document.execCommand("createLink", false, href);
3750
3892
  const newLink = getSelectedLink();
3751
3893
  if (newLink) {
@@ -4309,7 +4451,7 @@ const LinkTooltip = ({ editorElement }) => {
4309
4451
  scheduleHide();
4310
4452
  };
4311
4453
  const handleOpen = () => {
4312
- if (state.href) {
4454
+ if (state.href && isUrlSafe(state.href)) {
4313
4455
  window.open(state.href, "_blank", "noopener,noreferrer");
4314
4456
  }
4315
4457
  };
@@ -4335,14 +4477,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4335
4477
  const toolbarRef = React.useRef(null);
4336
4478
  React.useEffect(() => {
4337
4479
  setIsClient(true);
4480
+ const timeoutIds = [];
4338
4481
  const handleSelectionChange = () => {
4339
4482
  setUpdateTrigger((prev) => prev + 1);
4340
4483
  };
4341
4484
  const handleMouseUp = () => {
4342
- setTimeout(handleSelectionChange, 10);
4485
+ timeoutIds.push(setTimeout(handleSelectionChange, 10));
4343
4486
  };
4344
4487
  const handleKeyUp = () => {
4345
- setTimeout(handleSelectionChange, 10);
4488
+ timeoutIds.push(setTimeout(handleSelectionChange, 10));
4346
4489
  };
4347
4490
  if (typeof document !== 'undefined') {
4348
4491
  document.addEventListener("selectionchange", handleSelectionChange);
@@ -4350,6 +4493,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4350
4493
  document.addEventListener("keyup", handleKeyUp);
4351
4494
  }
4352
4495
  return () => {
4496
+ timeoutIds.forEach(clearTimeout);
4353
4497
  if (typeof document !== 'undefined') {
4354
4498
  document.removeEventListener("selectionchange", handleSelectionChange);
4355
4499
  document.removeEventListener("mouseup", handleMouseUp);
@@ -4375,6 +4519,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4375
4519
  const clearFormattingPlugin = plugins.find((p) => p.name === "clearFormatting");
4376
4520
  // Roving tabindex keyboard navigation (ARIA toolbar pattern)
4377
4521
  const handleToolbarKeyDown = React.useCallback((e) => {
4522
+ // Escape: return focus to the editor
4523
+ if (e.key === "Escape") {
4524
+ e.preventDefault();
4525
+ const container = toolbarRef.current?.closest(".rte-container");
4526
+ const editor = container?.querySelector(".rte-editor");
4527
+ if (editor)
4528
+ editor.focus();
4529
+ return;
4530
+ }
4378
4531
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
4379
4532
  return;
4380
4533
  const toolbar = toolbarRef.current;
@@ -4406,7 +4559,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4406
4559
  }
4407
4560
  buttons[nextIndex].focus();
4408
4561
  }, []);
4409
- return (jsxRuntime.jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", children: [jsxRuntime.jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
4562
+ return (jsxRuntime.jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", "aria-orientation": "horizontal", children: [jsxRuntime.jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
4410
4563
  if (!plugin.renderButton)
4411
4564
  return null;
4412
4565
  const isActive = isClient && plugin.isActive
@@ -4449,6 +4602,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4449
4602
  const editorRef = React.useRef(null);
4450
4603
  const historyRef = React.useRef(new HistoryManager());
4451
4604
  const isUpdatingRef = React.useRef(false);
4605
+ const mountedRef = React.useRef(true);
4606
+ // Track mount status to guard async callbacks
4607
+ React.useEffect(() => {
4608
+ mountedRef.current = true;
4609
+ return () => {
4610
+ mountedRef.current = false;
4611
+ };
4612
+ }, []);
4452
4613
  // --- Plugins ---
4453
4614
  const plugins = React.useMemo(() => {
4454
4615
  // When settings is provided and plugins is not, use buildPluginsFromSettings
@@ -4459,7 +4620,12 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4459
4620
  };
4460
4621
  return buildPluginsFromSettings(settings, opts);
4461
4622
  }
4462
- const allPlugins = [...(providedPlugins || defaultPlugins)];
4623
+ // When plugins are explicitly provided, use them as-is
4624
+ if (providedPlugins) {
4625
+ return [...providedPlugins];
4626
+ }
4627
+ // Default plugins path: apply shorthand customizations
4628
+ const allPlugins = [...defaultPlugins];
4463
4629
  if (headings && headings.length > 0) {
4464
4630
  const blockFormatIndex = allPlugins.findIndex((p) => p.name === "blockFormat");
4465
4631
  if (blockFormatIndex !== -1) {
@@ -4574,12 +4740,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4574
4740
  return checkbox.insertCheckboxList(editor);
4575
4741
  }
4576
4742
  if (command === "insertImage" && value) {
4577
- return handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange);
4743
+ return handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange);
4578
4744
  }
4579
4745
  // General commands via document.execCommand
4580
4746
  ensureEditorFocused(editor);
4581
4747
  document.execCommand(command, false, value);
4582
4748
  setTimeout(() => {
4749
+ if (!mountedRef.current)
4750
+ return;
4583
4751
  if (editor && !isUpdatingRef.current) {
4584
4752
  ensureAllCheckboxes(editor);
4585
4753
  const content = domToContent(editor);
@@ -4620,6 +4788,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4620
4788
  const block = document.createElement(type);
4621
4789
  if (attributes) {
4622
4790
  Object.entries(attributes).forEach(([key, val]) => {
4791
+ // Filter out event handler attributes
4792
+ if (key.toLowerCase().startsWith("on"))
4793
+ return;
4623
4794
  block.setAttribute(key, val);
4624
4795
  });
4625
4796
  }
@@ -4642,6 +4813,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4642
4813
  const inline = document.createElement(type);
4643
4814
  if (attributes) {
4644
4815
  Object.entries(attributes).forEach(([key, val]) => {
4816
+ // Filter out event handler attributes
4817
+ if (key.toLowerCase().startsWith("on"))
4818
+ return;
4645
4819
  inline.setAttribute(key, val);
4646
4820
  });
4647
4821
  }
@@ -4728,6 +4902,8 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4728
4902
  historyRef.current.push(currentContent, sel);
4729
4903
  operation(selection);
4730
4904
  setTimeout(() => {
4905
+ if (!mountedRef.current)
4906
+ return;
4731
4907
  if (editor)
4732
4908
  notifyChange(domToContent(editor));
4733
4909
  }, 0);
@@ -4754,6 +4930,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4754
4930
  editorRef,
4755
4931
  historyRef,
4756
4932
  isUpdatingRef,
4933
+ mountedRef,
4757
4934
  notifyChange,
4758
4935
  handleCheckboxKeyDown: checkbox.handleCheckboxKeyDown,
4759
4936
  handleCheckboxEnter: checkbox.handleCheckboxEnter,
@@ -4850,6 +5027,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4850
5027
  }
4851
5028
  // Upload
4852
5029
  const url = await onImageUpload(file);
5030
+ // Guard against unmount during async upload
5031
+ if (!mountedRef.current)
5032
+ return;
4853
5033
  // Parse the "url|__aid__:attachmentId" convention
4854
5034
  let realUrl = url;
4855
5035
  if (url.includes("|__aid__:")) {
@@ -4860,6 +5040,11 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4860
5040
  placeholder.setAttribute("data-attachment-id", attachmentId);
4861
5041
  }
4862
5042
  }
5043
+ // Validate the returned URL before setting it
5044
+ if (!isUrlSafe(realUrl) && !realUrl.startsWith("data:image/")) {
5045
+ placeholder.remove();
5046
+ return;
5047
+ }
4863
5048
  // Replace placeholder with final image
4864
5049
  placeholder.src = realUrl;
4865
5050
  placeholder.removeAttribute("data-uploading");
@@ -4980,7 +5165,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4980
5165
  }, suppressContentEditableWarning: true }), !readOnly && (jsxRuntime.jsx(FloatingToolbar, { plugins: plugins, editorAPI: editorAPI, editorElement: editorRef.current })), jsxRuntime.jsx(LinkTooltip, { editorElement: editorRef.current }), showWordCount && (jsxRuntime.jsxs("div", { className: "rte-word-count", children: [wordCount.words, " words \u00B7 ", wordCount.characters, " characters"] }))] }));
4981
5166
  };
4982
5167
  // --- Helper: Insert Image ---
4983
- function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange) {
5168
+ function handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange) {
4984
5169
  let selection = window.getSelection();
4985
5170
  if (!selection)
4986
5171
  return false;
@@ -5002,7 +5187,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
5002
5187
  newRange.collapse(true);
5003
5188
  selection.removeAllRanges();
5004
5189
  selection.addRange(newRange);
5005
- saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
5190
+ saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
5006
5191
  return true;
5007
5192
  }
5008
5193
  selection.removeAllRanges();
@@ -5043,7 +5228,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
5043
5228
  newRange.collapse(true);
5044
5229
  selection.removeAllRanges();
5045
5230
  selection.addRange(newRange);
5046
- saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
5231
+ saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
5047
5232
  return true;
5048
5233
  }
5049
5234
  /**
@@ -5057,23 +5242,36 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
5057
5242
  function createImageElement(src) {
5058
5243
  const img = document.createElement("img");
5059
5244
  let realSrc = src;
5245
+ let altText = "";
5246
+ // Parse the "url|__alt__:altText" convention
5247
+ if (realSrc.includes("|__alt__:")) {
5248
+ const altIdx = realSrc.indexOf("|__alt__:");
5249
+ altText = realSrc.substring(altIdx + "|__alt__:".length);
5250
+ realSrc = realSrc.substring(0, altIdx);
5251
+ }
5060
5252
  // Parse the "url|__aid__:attachmentId" convention
5061
- if (src.includes("|__aid__:")) {
5062
- const idx = src.indexOf("|__aid__:");
5063
- realSrc = src.substring(0, idx);
5064
- const attachmentId = src.substring(idx + "|__aid__:".length);
5253
+ if (realSrc.includes("|__aid__:")) {
5254
+ const idx = realSrc.indexOf("|__aid__:");
5255
+ const attachmentId = realSrc.substring(idx + "|__aid__:".length);
5256
+ realSrc = realSrc.substring(0, idx);
5065
5257
  if (attachmentId) {
5066
5258
  img.setAttribute("data-attachment-id", attachmentId);
5067
5259
  }
5068
5260
  }
5261
+ // Validate URL safety — block javascript:, data:, etc.
5262
+ if (!isUrlSafe(realSrc) && !realSrc.startsWith("data:image/")) {
5263
+ realSrc = "";
5264
+ }
5069
5265
  img.setAttribute("src", realSrc);
5070
- img.setAttribute("alt", "");
5266
+ img.setAttribute("alt", altText);
5071
5267
  img.className = "rte-image";
5072
5268
  return img;
5073
5269
  }
5074
- function saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange) {
5270
+ function saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange) {
5075
5271
  isUpdatingRef.current = true;
5076
5272
  setTimeout(() => {
5273
+ if (!mountedRef.current)
5274
+ return;
5077
5275
  const content = domToContent(editor);
5078
5276
  const sel = serializeSelection(editor);
5079
5277
  historyRef.current.push(content, sel);
@@ -5203,14 +5401,14 @@ function createLinkPlugin() {
5203
5401
  // Check if a link already exists
5204
5402
  const existingLink = element?.closest("a");
5205
5403
  if (existingLink) {
5206
- // Link entfernen
5404
+ // Remove link
5207
5405
  const parent = existingLink.parentNode;
5208
5406
  if (parent) {
5209
5407
  while (existingLink.firstChild) {
5210
5408
  parent.insertBefore(existingLink.firstChild, existingLink);
5211
5409
  }
5212
5410
  parent.removeChild(existingLink);
5213
- // Content aktualisieren
5411
+ // Update content
5214
5412
  const editorEl = editor.getSelection()?.anchorNode;
5215
5413
  if (editorEl) {
5216
5414
  const content = editor.getContent();
@@ -5221,7 +5419,7 @@ function createLinkPlugin() {
5221
5419
  else {
5222
5420
  // Insert new link
5223
5421
  const url = prompt("Enter URL:");
5224
- if (url) {
5422
+ if (url && isUrlSafe(url)) {
5225
5423
  editor.executeCommand("createLink", url);
5226
5424
  }
5227
5425
  }
@@ -5388,6 +5586,7 @@ exports.htmlToContent = htmlToContent;
5388
5586
  exports.indentListItem = indentListItem;
5389
5587
  exports.indentListItemPlugin = indentListItemPlugin;
5390
5588
  exports.isCheckboxList = isCheckboxList;
5589
+ exports.isUrlSafe = isUrlSafe;
5391
5590
  exports.italicPlugin = italicPlugin;
5392
5591
  exports.linkPlugin = linkPlugin;
5393
5592
  exports.orderedListPlugin = orderedListPlugin;