@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.esm.js CHANGED
@@ -102,7 +102,20 @@ const Dropdown = ({ icon, label, options, onSelect, currentValue, disabled, show
102
102
  setIsOpen(false);
103
103
  };
104
104
  const currentOption = options.find(opt => opt.value === currentValue);
105
- return (jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [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: [jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxs("div", { className: "rte-dropdown-menu", children: [options.map((option) => (jsxs("button", { type: "button", className: `rte-dropdown-item ${currentValue === option.value ? 'rte-dropdown-item-active' : ''}`, onClick: () => handleSelect(option.value), children: [option.color && (jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsx("input", { type: "text", value: customColor, onChange: (e) => {
105
+ // Close on Escape key
106
+ useEffect(() => {
107
+ if (!isOpen)
108
+ return;
109
+ const handleKeyDown = (e) => {
110
+ if (e.key === "Escape") {
111
+ e.preventDefault();
112
+ setIsOpen(false);
113
+ }
114
+ };
115
+ document.addEventListener("keydown", handleKeyDown);
116
+ return () => document.removeEventListener("keydown", handleKeyDown);
117
+ }, [isOpen]);
118
+ return (jsxs("div", { className: "rte-dropdown", ref: dropdownRef, onMouseDown: (e) => e.preventDefault(), children: [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: [jsx(Icon, { icon: icon, width: 18, height: 18 }), currentOption && (jsx("span", { className: "rte-dropdown-value", children: currentOption.label }))] }), isOpen && (jsxs("div", { className: "rte-dropdown-menu", role: "listbox", "aria-label": label, children: [options.map((option) => (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 && (jsx("span", { className: `rte-dropdown-color-preview ${currentValue === option.value ? 'active' : ''}`, style: { backgroundColor: option.color } })), option.preview && !option.headingPreview && (jsx("span", { className: "rte-dropdown-fontsize-preview", style: { fontSize: `${option.preview}px` }, children: "Aa" })), option.headingPreview && (jsx("span", { className: `rte-dropdown-heading-preview ${option.headingPreview}`, children: option.headingPreview === 'p' ? 'Normal' : option.headingPreview.toUpperCase() })), option.icon && jsx(Icon, { icon: option.icon, width: 16, height: 16 }), jsx("span", { style: { flex: 1, fontWeight: currentValue === option.value ? 600 : 400 }, children: option.label })] }, option.value))), showCustomColorInput && (jsxs("div", { className: "rte-color-custom-input", onMouseDown: (e) => e.stopPropagation(), children: [jsx("input", { type: "color", value: customColor, onChange: (e) => setCustomColor(e.target.value), title: "Pick a color" }), jsx("input", { type: "text", value: customColor, onChange: (e) => {
106
119
  const v = e.target.value;
107
120
  setCustomColor(v);
108
121
  }, placeholder: "#000000", maxLength: 7, onKeyDown: (e) => {
@@ -657,6 +670,168 @@ function useCheckbox({ editorRef, isUpdatingRef, pushToHistory, notifyChange, ge
657
670
  };
658
671
  }
659
672
 
673
+ /**
674
+ * Lightweight HTML sanitizer (no external dependencies).
675
+ *
676
+ * Uses an allowlist approach to strip dangerous elements and attributes
677
+ * from pasted/imported HTML before it enters the editor.
678
+ */
679
+ /** Tags that are always removed (including their content). */
680
+ const REMOVE_TAGS = new Set([
681
+ "script",
682
+ "style",
683
+ "iframe",
684
+ "object",
685
+ "embed",
686
+ "applet",
687
+ "form",
688
+ "input",
689
+ "textarea",
690
+ "select",
691
+ "button",
692
+ "noscript",
693
+ "meta",
694
+ "link",
695
+ "base",
696
+ "svg",
697
+ "math",
698
+ "template",
699
+ "details",
700
+ "video",
701
+ "audio",
702
+ "marquee",
703
+ ]);
704
+ /** Attributes that are always removed (event handlers, dangerous data-* attrs). */
705
+ const REMOVE_ATTRS_PATTERN = /^on|^data-(?!(attachment-id|placeholder)$)/i;
706
+ /** Specific dangerous attribute names. */
707
+ const REMOVE_ATTRS = new Set([
708
+ "srcdoc",
709
+ "formaction",
710
+ "xlink:href",
711
+ "ping",
712
+ ]);
713
+ /** Allowed URL schemes for href/src attributes. */
714
+ const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/(?!\/))/i;
715
+ /**
716
+ * Check if a URL is safe to set as href/src.
717
+ * Blocks javascript:, data:, vbscript:, and unknown schemes.
718
+ * Strips control characters before checking.
719
+ */
720
+ function isUrlSafe(url) {
721
+ if (!url)
722
+ return false;
723
+ // Strip control characters and whitespace that could obfuscate schemes
724
+ const cleaned = url.trim().replace(/[\x00-\x1f\x7f]/g, "");
725
+ if (!cleaned)
726
+ return false;
727
+ // Block protocol-relative URLs (//evil.com)
728
+ if (cleaned.startsWith("//"))
729
+ return false;
730
+ // Must match an allowed scheme or be a relative path
731
+ return ALLOWED_SCHEMES.test(cleaned);
732
+ }
733
+ /**
734
+ * Sanitize an HTML string by stripping dangerous tags and attributes.
735
+ *
736
+ * @param html - Raw HTML string
737
+ * @returns Sanitized HTML string
738
+ */
739
+ function sanitizeHtml(html) {
740
+ // Use the browser's DOMParser to parse the HTML safely
741
+ const parser = new DOMParser();
742
+ const doc = parser.parseFromString(html, "text/html");
743
+ sanitizeNode(doc.body);
744
+ return doc.body.innerHTML;
745
+ }
746
+ /** Recursively sanitize a DOM node. */
747
+ function sanitizeNode(node) {
748
+ const childrenToRemove = [];
749
+ node.childNodes.forEach((child) => {
750
+ if (child.nodeType === Node.ELEMENT_NODE) {
751
+ const el = child;
752
+ const tag = el.tagName.toLowerCase();
753
+ // Remove dangerous tags entirely
754
+ if (REMOVE_TAGS.has(tag)) {
755
+ childrenToRemove.push(child);
756
+ return;
757
+ }
758
+ // Remove dangerous attributes
759
+ const attrsToRemove = [];
760
+ for (let i = 0; i < el.attributes.length; i++) {
761
+ const attr = el.attributes[i];
762
+ const name = attr.name.toLowerCase();
763
+ if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
764
+ attrsToRemove.push(attr.name);
765
+ continue;
766
+ }
767
+ // Validate URL attributes
768
+ if (name === "href" || name === "src" || name === "action" || name === "cite" || name === "poster") {
769
+ const value = attr.value.trim();
770
+ if (value) {
771
+ // Allow data:image/* for img src (matches contentToDOM behavior)
772
+ const isDataImage = name === "src" && el.tagName === "IMG" && value.startsWith("data:image/");
773
+ if (!isDataImage && !ALLOWED_SCHEMES.test(value)) {
774
+ attrsToRemove.push(attr.name);
775
+ }
776
+ }
777
+ }
778
+ // Remove dangerous URI schemes in any attribute value
779
+ const lowerValue = attr.value.toLowerCase().replace(/[\x00-\x1f\x7f\s]/g, "");
780
+ if (lowerValue.includes("javascript:") ||
781
+ lowerValue.includes("vbscript:") ||
782
+ lowerValue.includes("data:text/html")) {
783
+ attrsToRemove.push(attr.name);
784
+ }
785
+ }
786
+ attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
787
+ // Recurse into children
788
+ sanitizeNode(child);
789
+ }
790
+ });
791
+ // Remove marked children
792
+ childrenToRemove.forEach((child) => {
793
+ node.removeChild(child);
794
+ });
795
+ }
796
+
797
+ /** Allowed tag names that contentToDOM may create from JSON. */
798
+ const ALLOWED_CONTENT_TAGS = new Set([
799
+ "p", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
800
+ "ul", "ol", "li", "a", "strong", "em", "u", "s", "del",
801
+ "sub", "sup", "code", "pre", "blockquote", "br", "hr",
802
+ "img", "table", "thead", "tbody", "tr", "th", "td",
803
+ "b", "i", "strike",
804
+ ]);
805
+ /** Checks if an attribute key is safe to set on a DOM element. */
806
+ function isSafeAttribute(key) {
807
+ const lower = key.toLowerCase();
808
+ // Block all event handler attributes
809
+ if (lower.startsWith("on"))
810
+ return false;
811
+ // Block dangerous attributes
812
+ if (lower === "srcdoc" || lower === "formaction" || lower === "xlink:href" || lower === "ping")
813
+ return false;
814
+ return true;
815
+ }
816
+ /** Validates style values against expected patterns. */
817
+ function isSafeStyleValue(prop, value) {
818
+ const clean = value.trim().toLowerCase();
819
+ // Block expression(), url(), import, javascript, and other injection vectors
820
+ if (/expression\s*\(|url\s*\(|@import|javascript:|vbscript:|-moz-binding/i.test(clean)) {
821
+ return false;
822
+ }
823
+ switch (prop) {
824
+ case "fontSize":
825
+ return /^[\d.]+(px|em|rem|pt|%|vw|vh)$/i.test(clean);
826
+ case "color":
827
+ case "backgroundColor":
828
+ return /^(#[0-9a-f]{3,8}|rgb\([\d\s,.%]+\)|rgba\([\d\s,.%]+\)|[a-z]+)$/i.test(clean);
829
+ case "textAlign":
830
+ return /^(left|right|center|justify|start|end)$/.test(clean);
831
+ default:
832
+ return true;
833
+ }
834
+ }
660
835
  /**
661
836
  * Converts a DOM element (editor root) to EditorContent JSON.
662
837
  * Supports own format, Lexical HTML, and GitHub HTML.
@@ -977,8 +1152,13 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
977
1152
  if (node.type === "image") {
978
1153
  const img = document.createElement("img");
979
1154
  if (node.attributes) {
980
- if (node.attributes.src)
981
- img.setAttribute("src", node.attributes.src);
1155
+ if (node.attributes.src) {
1156
+ // Allow data:image/* for placeholders/uploads, otherwise validate
1157
+ const imgSrc = node.attributes.src;
1158
+ if (isUrlSafe(imgSrc) || imgSrc.startsWith("data:image/")) {
1159
+ img.setAttribute("src", imgSrc);
1160
+ }
1161
+ }
982
1162
  if (node.attributes.alt)
983
1163
  img.setAttribute("alt", node.attributes.alt);
984
1164
  if (node.attributes.uploading === "true") {
@@ -1005,23 +1185,32 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
1005
1185
  node.attributes["data-custom-link"] = "true";
1006
1186
  }
1007
1187
  }
1188
+ // Whitelist check: fall back to span for unknown/dangerous tags
1189
+ if (!ALLOWED_CONTENT_TAGS.has(tagName)) {
1190
+ tagName = "span";
1191
+ }
1008
1192
  const element = document.createElement(tagName);
1009
1193
  if (node.attributes) {
1010
1194
  Object.entries(node.attributes).forEach(([key, value]) => {
1011
1195
  if (key === "fontSize") {
1012
- element.style.fontSize = value;
1196
+ if (isSafeStyleValue("fontSize", value))
1197
+ element.style.fontSize = value;
1013
1198
  }
1014
1199
  else if (key === "color") {
1015
- element.style.color = value;
1200
+ if (isSafeStyleValue("color", value))
1201
+ element.style.color = value;
1016
1202
  }
1017
1203
  else if (key === "backgroundColor") {
1018
- element.style.backgroundColor = value;
1204
+ if (isSafeStyleValue("backgroundColor", value))
1205
+ element.style.backgroundColor = value;
1019
1206
  }
1020
1207
  else if (key === "textAlign") {
1021
- element.style.textAlign = value;
1208
+ if (isSafeStyleValue("textAlign", value))
1209
+ element.style.textAlign = value;
1022
1210
  }
1023
1211
  else if (key === "href" && tagName === "a") {
1024
- element.setAttribute("href", value);
1212
+ if (isUrlSafe(value))
1213
+ element.setAttribute("href", value);
1025
1214
  }
1026
1215
  else if (key === "target" && tagName === "a") {
1027
1216
  element.setAttribute("target", value);
@@ -1051,8 +1240,16 @@ function contentToDOM(content, container, customLinkComponent, customHeadingRend
1051
1240
  element.className = value;
1052
1241
  }
1053
1242
  else if (key === "checkboxChecked") ;
1054
- else {
1055
- element.setAttribute(key, value);
1243
+ else if (isSafeAttribute(key)) {
1244
+ // Validate URL-like attribute values
1245
+ const lowerKey = key.toLowerCase();
1246
+ if (["cite", "action", "poster", "background"].includes(lowerKey)) {
1247
+ if (isUrlSafe(value))
1248
+ element.setAttribute(key, value);
1249
+ }
1250
+ else {
1251
+ element.setAttribute(key, value);
1252
+ }
1056
1253
  }
1057
1254
  });
1058
1255
  }
@@ -1099,10 +1296,12 @@ function contentToHTML(content) {
1099
1296
  /**
1100
1297
  * Converts an HTML string to EditorContent.
1101
1298
  * Supports Lexical, GitHub, and standard HTML formats.
1299
+ * Sanitizes HTML before parsing to prevent XSS.
1102
1300
  */
1103
1301
  function htmlToContent(htmlString) {
1302
+ const sanitized = sanitizeHtml(htmlString);
1104
1303
  const tempDiv = document.createElement("div");
1105
- tempDiv.innerHTML = htmlString;
1304
+ tempDiv.innerHTML = sanitized;
1106
1305
  return domToContent(tempDiv);
1107
1306
  }
1108
1307
 
@@ -1112,7 +1311,7 @@ function htmlToContent(htmlString) {
1112
1311
  * Detects URLs in text nodes and wraps them in <a> tags.
1113
1312
  * Triggered after space/enter when the preceding word looks like a URL.
1114
1313
  */
1115
- const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<>'"]+\.[^\s<>'"]+/i;
1314
+ const URL_REGEX = /^(?:https?:\/\/|www\.)[^\s<>'"]+\.[a-z]{2,}[^\s<>'"]*$/i;
1116
1315
  /**
1117
1316
  * Check if the word before the cursor is a URL and wrap it in an <a> tag.
1118
1317
  * Called on space/enter keypress in the editor.
@@ -1150,6 +1349,9 @@ function handleAutoLink(editor, e) {
1150
1349
  if (href.startsWith("www.")) {
1151
1350
  href = "https://" + href;
1152
1351
  }
1352
+ // Validate the URL is safe before creating a link
1353
+ if (!isUrlSafe(href))
1354
+ return false;
1153
1355
  // Create the link element
1154
1356
  const anchor = document.createElement("a");
1155
1357
  anchor.href = href;
@@ -1211,22 +1413,22 @@ function indentListItem(selection) {
1211
1413
  break;
1212
1414
  }
1213
1415
  }
1214
- // Max depth: 6 (wie in HTML Standard)
1416
+ // Max depth: 6 (as per HTML standard)
1215
1417
  if (depth >= 6)
1216
1418
  return false;
1217
- // Finde vorheriges List-Item
1419
+ // Find previous list item
1218
1420
  const previousItem = listItem.previousElementSibling;
1219
1421
  if (previousItem && previousItem.tagName === 'LI') {
1220
- // Erstelle verschachtelte Liste im vorherigen Item
1422
+ // Create nested list in the previous item
1221
1423
  let nestedList = previousItem.querySelector('ul, ol');
1222
1424
  if (!nestedList) {
1223
- // Erstelle neue verschachtelte Liste
1425
+ // Create new nested list
1224
1426
  nestedList = document.createElement(list.tagName.toLowerCase());
1225
1427
  previousItem.appendChild(nestedList);
1226
1428
  }
1227
- // Verschiebe aktuelles Item in verschachtelte Liste
1429
+ // Move current item into nested list
1228
1430
  nestedList.appendChild(listItem);
1229
- // Cursor setzen
1431
+ // Set cursor position
1230
1432
  const textNode = listItem.firstChild;
1231
1433
  if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1232
1434
  range.setStart(textNode, 0);
@@ -1247,9 +1449,9 @@ function indentListItem(selection) {
1247
1449
  return true;
1248
1450
  }
1249
1451
  else {
1250
- // Kein vorheriges Item - erstelle neue verschachtelte Liste im aktuellen Item
1452
+ // No previous item create new nested list in current item
1251
1453
  const nestedList = document.createElement(list.tagName.toLowerCase());
1252
- // Verschiebe alle nachfolgenden Items in die verschachtelte Liste
1454
+ // Move all following items into the nested list
1253
1455
  let nextSibling = listItem.nextElementSibling;
1254
1456
  while (nextSibling && nextSibling.tagName === 'LI') {
1255
1457
  const toMove = nextSibling;
@@ -1260,11 +1462,11 @@ function indentListItem(selection) {
1260
1462
  listItem.appendChild(nestedList);
1261
1463
  }
1262
1464
  else {
1263
- // Wenn keine nachfolgenden Items, erstelle leeres Sub-Item
1465
+ // If no following items, create empty sub-item
1264
1466
  const subItem = document.createElement('li');
1265
1467
  nestedList.appendChild(subItem);
1266
1468
  listItem.appendChild(nestedList);
1267
- // Cursor ins Sub-Item setzen
1469
+ // Set cursor in sub-item
1268
1470
  const newText = document.createTextNode('');
1269
1471
  subItem.appendChild(newText);
1270
1472
  range.setStart(newText, 0);
@@ -1294,17 +1496,17 @@ function outdentListItem(selection) {
1294
1496
  // Check if in nested list
1295
1497
  const parentListItem = list.parentElement;
1296
1498
  if (!parentListItem || parentListItem.tagName !== 'LI') {
1297
- // Bereits auf oberstem Level
1499
+ // Already at top level
1298
1500
  return false;
1299
1501
  }
1300
1502
  const parentList = parentListItem.parentElement;
1301
1503
  if (!parentList || (parentList.tagName !== 'UL' && parentList.tagName !== 'OL')) {
1302
1504
  return false;
1303
1505
  }
1304
- // Verschiebe Item auf oberes Level
1305
- // Finde Position nach dem Parent-Item
1506
+ // Move item to parent level
1507
+ // Find position after the parent item
1306
1508
  const insertAfter = parentListItem;
1307
- // Verschiebe Item und alle nachfolgenden Items aus der verschachtelten Liste
1509
+ // Move item and all following items out of the nested list
1308
1510
  const itemsToMove = [listItem];
1309
1511
  let nextSibling = listItem.nextElementSibling;
1310
1512
  while (nextSibling && nextSibling.tagName === 'LI') {
@@ -1315,11 +1517,11 @@ function outdentListItem(selection) {
1315
1517
  itemsToMove.forEach(item => {
1316
1518
  parentList.insertBefore(item, insertAfter.nextSibling);
1317
1519
  });
1318
- // Entferne leere verschachtelte Liste
1520
+ // Remove empty nested list
1319
1521
  if (list.children.length === 0) {
1320
1522
  list.remove();
1321
1523
  }
1322
- // Cursor setzen
1524
+ // Set cursor position
1323
1525
  const textNode = listItem.firstChild;
1324
1526
  if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1325
1527
  range.setStart(textNode, 0);
@@ -1853,7 +2055,7 @@ function navigateTableCell(direction) {
1853
2055
  /**
1854
2056
  * Hook that sets up input and keydown event listeners on the editor.
1855
2057
  */
1856
- function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
2058
+ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, mountedRef, notifyChange, handleCheckboxKeyDown, handleCheckboxEnter, undo, redo, }) {
1857
2059
  useEffect(() => {
1858
2060
  const editor = editorRef.current;
1859
2061
  if (!editor)
@@ -1861,11 +2063,15 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1861
2063
  let inputTimeout = null;
1862
2064
  const handleInput = () => {
1863
2065
  setTimeout(() => {
2066
+ if (!mountedRef.current)
2067
+ return;
1864
2068
  const content = domToContent(editor);
1865
2069
  notifyChange(content);
1866
2070
  if (inputTimeout)
1867
2071
  clearTimeout(inputTimeout);
1868
2072
  inputTimeout = setTimeout(() => {
2073
+ if (!mountedRef.current)
2074
+ return;
1869
2075
  const sel = serializeSelection(editor);
1870
2076
  historyRef.current.push(content, sel);
1871
2077
  inputTimeout = null;
@@ -1877,6 +2083,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1877
2083
  // Markdown-style shortcuts (e.g., # + space → heading)
1878
2084
  if (!isModifierPressed && handleMarkdownShortcut(editor, e)) {
1879
2085
  setTimeout(() => {
2086
+ if (!mountedRef.current)
2087
+ return;
1880
2088
  const content = domToContent(editor);
1881
2089
  notifyChange(content);
1882
2090
  }, 0);
@@ -1901,6 +2109,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1901
2109
  e.stopImmediatePropagation();
1902
2110
  navigateTableCell(e.shiftKey ? "prev" : "next");
1903
2111
  setTimeout(() => {
2112
+ if (!mountedRef.current)
2113
+ return;
1904
2114
  if (editor) {
1905
2115
  const content = domToContent(editor);
1906
2116
  notifyChange(content);
@@ -1932,6 +2142,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
1932
2142
  indentListItem(selection);
1933
2143
  }
1934
2144
  setTimeout(() => {
2145
+ if (!mountedRef.current)
2146
+ return;
1935
2147
  if (editor) {
1936
2148
  const content = domToContent(editor);
1937
2149
  notifyChange(content);
@@ -2043,6 +2255,8 @@ function useEditorEvents({ editorRef, historyRef, isUpdatingRef, notifyChange, h
2043
2255
  document.execCommand(command, false);
2044
2256
  }
2045
2257
  setTimeout(() => {
2258
+ if (!mountedRef.current)
2259
+ return;
2046
2260
  if (editor) {
2047
2261
  const content = domToContent(editor);
2048
2262
  notifyChange(content);
@@ -2092,6 +2306,7 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
2092
2306
  // Normalize all checkbox lists (own + Lexical + GitHub formats)
2093
2307
  ensureAllCheckboxes(editor);
2094
2308
  // MutationObserver: ensure new checkbox list items get correct attributes
2309
+ let checkboxTimeout = null;
2095
2310
  const observer = new MutationObserver((mutations) => {
2096
2311
  if (isUpdatingRef.current)
2097
2312
  return;
@@ -2121,11 +2336,15 @@ function useEditorInit({ editorRef, historyRef, isUpdatingRef, initialContent, c
2121
2336
  break;
2122
2337
  }
2123
2338
  if (needsUpdate) {
2124
- setTimeout(() => ensureAllCheckboxes(editor), 0);
2339
+ if (checkboxTimeout)
2340
+ clearTimeout(checkboxTimeout);
2341
+ checkboxTimeout = setTimeout(() => ensureAllCheckboxes(editor), 0);
2125
2342
  }
2126
2343
  });
2127
2344
  observer.observe(editor, { childList: true, subtree: true });
2128
2345
  return () => {
2346
+ if (checkboxTimeout)
2347
+ clearTimeout(checkboxTimeout);
2129
2348
  observer.disconnect();
2130
2349
  };
2131
2350
  // Only run once on mount
@@ -2166,7 +2385,7 @@ function createInlinePlugin(name, command, icon, label) {
2166
2385
  },
2167
2386
  canExecute: (editor) => {
2168
2387
  // Formatting should also work without a selection
2169
- // (z.B. wenn Editor leer ist, wird beim Klick eine Selection erstellt)
2388
+ // (e.g. when the editor is empty, a selection is created on click)
2170
2389
  return true;
2171
2390
  },
2172
2391
  };
@@ -2937,7 +3156,7 @@ function getDisplayUrl(raw) {
2937
3156
  return raw;
2938
3157
  }
2939
3158
  /**
2940
- * Image-Plugin mit URL-Eingabe und File-Upload
3159
+ * Image plugin with URL input and file upload support.
2941
3160
  */
2942
3161
  function createImagePlugin(onImageUpload) {
2943
3162
  return {
@@ -2960,7 +3179,17 @@ function createImagePlugin(onImageUpload) {
2960
3179
  alert('Please enter an image URL');
2961
3180
  return;
2962
3181
  }
2963
- props.editorAPI.executeCommand('insertImage', src);
3182
+ // Validate URL safety
3183
+ const srcWithoutMeta = src.split('|__aid__:')[0];
3184
+ if (!isUrlSafe(srcWithoutMeta)) {
3185
+ alert('Invalid image URL');
3186
+ return;
3187
+ }
3188
+ // Append alt text metadata if provided
3189
+ const fullValue = altText.trim()
3190
+ ? `${src}|__alt__:${altText.trim()}`
3191
+ : src;
3192
+ props.editorAPI.executeCommand('insertImage', fullValue);
2964
3193
  // Close modal
2965
3194
  setShowModal(false);
2966
3195
  setRawUrl('');
@@ -2998,11 +3227,11 @@ function createImagePlugin(onImageUpload) {
2998
3227
  if (e.target === e.currentTarget) {
2999
3228
  setShowModal(false);
3000
3229
  }
3001
- }, children: jsxs("div", { className: "rte-image-modal", children: [jsxs("div", { className: "rte-image-modal-header", children: [jsx("h3", { children: "Insert Image" }), jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsx("div", { className: "rte-image-upload-section", children: jsxs("label", { className: "rte-image-upload-label", children: [jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsx("span", { children: "Wird hochgeladen..." })] })) : (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsx("span", { children: "Choose File" })] })) })] }) })), jsx("div", { className: "rte-image-url-section", children: jsxs("label", { children: ["Bild-URL", jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
3230
+ }, children: jsxs("div", { className: "rte-image-modal", children: [jsxs("div", { className: "rte-image-modal-header", children: [jsx("h3", { children: "Insert Image" }), jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-close", "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 20, height: 20 }) })] }), jsxs("div", { className: "rte-image-modal-content", children: [onImageUpload && (jsx("div", { className: "rte-image-upload-section", children: jsxs("label", { className: "rte-image-upload-label", children: [jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileSelect, style: { display: 'none' } }), jsx("div", { className: "rte-image-upload-button", children: isUploading ? (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:loading", width: 24, height: 24, className: "rte-spin" }), jsx("span", { children: "Uploading..." })] })) : (jsxs(Fragment, { children: [jsx(IconWrapper, { icon: "mdi:upload", width: 24, height: 24 }), jsx("span", { children: "Choose File" })] })) })] }) })), jsx("div", { className: "rte-image-url-section", children: jsxs("label", { children: ["Image URL", jsx("input", { type: "url", value: imageUrl, onChange: (e) => {
3002
3231
  setImageUrl(e.target.value);
3003
3232
  // Clear raw URL when user manually edits
3004
3233
  setRawUrl('');
3005
- }, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsx("div", { className: "rte-image-alt-section", children: jsxs("label", { children: ["Alt-Text (optional)", jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsx("div", { className: "rte-image-preview", children: jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxs("div", { className: "rte-image-modal-footer", children: [jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Abbrechen" }), jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
3234
+ }, placeholder: "https://example.com/image.jpg", className: "rte-image-url-input" })] }) }), jsx("div", { className: "rte-image-alt-section", children: jsxs("label", { children: ["Alt Text (optional)", jsx("input", { type: "text", value: altText, onChange: (e) => setAltText(e.target.value), placeholder: "Image description", className: "rte-image-alt-input" })] }) }), imageUrl && (jsx("div", { className: "rte-image-preview", children: jsx("img", { src: imageUrl, alt: altText || 'Preview' }) }))] }), jsxs("div", { className: "rte-image-modal-footer", children: [jsx("button", { type: "button", onClick: () => setShowModal(false), className: "rte-image-modal-cancel", children: "Cancel" }), jsx("button", { type: "button", onClick: handleInsertImage, disabled: !imageUrl.trim() || isUploading, className: "rte-image-modal-insert", children: "Insert" })] })] }) }))] }));
3006
3235
  },
3007
3236
  execute: (editor) => {
3008
3237
  // Handled via renderButton
@@ -3073,7 +3302,7 @@ function clearFormatting(selection) {
3073
3302
  }
3074
3303
  catch (error) {
3075
3304
  console.error('Error clearing formatting:', error);
3076
- // Fallback: Einfache Methode
3305
+ // Fallback: simple method
3077
3306
  document.execCommand('removeFormat', false);
3078
3307
  document.execCommand('unlink', false);
3079
3308
  }
@@ -3222,7 +3451,7 @@ class HistoryManager {
3222
3451
  // Limit history size
3223
3452
  if (this.history.length > this.maxHistorySize) {
3224
3453
  this.history.shift();
3225
- this.currentIndex--;
3454
+ this.currentIndex = Math.max(0, this.currentIndex - 1);
3226
3455
  }
3227
3456
  }
3228
3457
  undo() {
@@ -3269,98 +3498,6 @@ class HistoryManager {
3269
3498
  }
3270
3499
  }
3271
3500
 
3272
- /**
3273
- * Lightweight HTML sanitizer (no external dependencies).
3274
- *
3275
- * Uses an allowlist approach to strip dangerous elements and attributes
3276
- * from pasted/imported HTML before it enters the editor.
3277
- */
3278
- /** Tags that are always removed (including their content). */
3279
- const REMOVE_TAGS = new Set([
3280
- "script",
3281
- "style",
3282
- "iframe",
3283
- "object",
3284
- "embed",
3285
- "applet",
3286
- "form",
3287
- "input",
3288
- "textarea",
3289
- "select",
3290
- "button",
3291
- "noscript",
3292
- "meta",
3293
- "link",
3294
- "base",
3295
- ]);
3296
- /** Attributes that are always removed (event handlers, dangerous attrs). */
3297
- const REMOVE_ATTRS_PATTERN = /^on|^data-(?!attachment-id$|placeholder$)/i;
3298
- /** Specific dangerous attribute names. */
3299
- const REMOVE_ATTRS = new Set([
3300
- "srcdoc",
3301
- "formaction",
3302
- "xlink:href",
3303
- "ping",
3304
- ]);
3305
- /** Allowed URL schemes for href/src attributes. */
3306
- const ALLOWED_SCHEMES = /^(https?:|mailto:|tel:|#|\/)/i;
3307
- /**
3308
- * Sanitize an HTML string by stripping dangerous tags and attributes.
3309
- *
3310
- * @param html - Raw HTML string
3311
- * @returns Sanitized HTML string
3312
- */
3313
- function sanitizeHtml(html) {
3314
- // Use the browser's DOMParser to parse the HTML safely
3315
- const parser = new DOMParser();
3316
- const doc = parser.parseFromString(html, "text/html");
3317
- sanitizeNode(doc.body);
3318
- return doc.body.innerHTML;
3319
- }
3320
- /** Recursively sanitize a DOM node. */
3321
- function sanitizeNode(node) {
3322
- const childrenToRemove = [];
3323
- node.childNodes.forEach((child) => {
3324
- if (child.nodeType === Node.ELEMENT_NODE) {
3325
- const el = child;
3326
- const tag = el.tagName.toLowerCase();
3327
- // Remove dangerous tags entirely
3328
- if (REMOVE_TAGS.has(tag)) {
3329
- childrenToRemove.push(child);
3330
- return;
3331
- }
3332
- // Remove dangerous attributes
3333
- const attrsToRemove = [];
3334
- for (let i = 0; i < el.attributes.length; i++) {
3335
- const attr = el.attributes[i];
3336
- const name = attr.name.toLowerCase();
3337
- if (REMOVE_ATTRS.has(name) || REMOVE_ATTRS_PATTERN.test(name)) {
3338
- attrsToRemove.push(attr.name);
3339
- continue;
3340
- }
3341
- // Validate URL attributes
3342
- if (name === "href" || name === "src" || name === "action") {
3343
- const value = attr.value.trim();
3344
- if (value && !ALLOWED_SCHEMES.test(value)) {
3345
- attrsToRemove.push(attr.name);
3346
- }
3347
- }
3348
- // Remove javascript: in any attribute value
3349
- if (attr.value.toLowerCase().includes("javascript:")) {
3350
- attrsToRemove.push(attr.name);
3351
- }
3352
- }
3353
- attrsToRemove.forEach((attrName) => el.removeAttribute(attrName));
3354
- // Recurse into children
3355
- sanitizeNode(child);
3356
- }
3357
- });
3358
- // Remove marked children
3359
- childrenToRemove.forEach((child) => {
3360
- node.removeChild(child);
3361
- });
3362
- }
3363
-
3364
3501
  const alignmentLabels = {
3365
3502
  left: "Left",
3366
3503
  center: "Center",
@@ -3578,9 +3715,9 @@ const FloatingLinkEditor = ({ linkData: initialData, options, anchorRect, isEdit
3578
3715
  const urlDisabledByCustom = customFields.some((f) => f.disablesUrl && !!data.custom[f.key]);
3579
3716
  const hasAdvancedSection = options.enableTarget || customFields.length > 0;
3580
3717
  const pos = computePosition(anchorRect);
3581
- return createPortal(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: [jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: "URL" }), 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 && (jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Erweitert"] }) }), showAdvanced && (jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: field.label }), 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 && (jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
3718
+ return createPortal(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: [jsx("button", { type: "button", className: "rte-link-dialog-close", onClick: onClose, onMouseDown: (e) => e.preventDefault(), "aria-label": "Close", children: jsx(IconWrapper, { icon: "mdi:close", width: 16, height: 16 }) }), jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: "URL" }), 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 && (jsxs("div", { className: "rte-link-dialog-advanced-section", children: [jsx("button", { type: "button", className: "rte-link-dialog-toggle", onClick: () => setShowAdvanced(!showAdvanced), onMouseDown: (e) => e.preventDefault(), children: jsxs("span", { children: [showAdvanced ? "▾" : "▸", " Advanced"] }) }), showAdvanced && (jsxs("div", { className: "rte-link-dialog-advanced", children: [customFields.map((field) => (jsxs("div", { className: "rte-link-dialog-field", children: [jsx("label", { className: "rte-link-dialog-label", children: field.label }), 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 && (jsxs("label", { className: "rte-link-dialog-checkbox-row", children: [jsx("input", { type: "checkbox", checked: data.target === "_blank", onChange: (e) => setField("target", e.target.checked
3582
3719
  ? "_blank"
3583
- : "_self") }), jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Entfernen" })), jsx("div", { style: { flex: 1 } }), jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Abbrechen" }), 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);
3720
+ : "_self") }), jsx("span", { children: "Open in new tab" })] }))] }))] })), jsxs("div", { className: "rte-link-dialog-actions", children: [isEditing && (jsx("button", { type: "button", className: "rte-link-dialog-btn rte-link-dialog-btn-danger", onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove" })), jsx("div", { style: { flex: 1 } }), jsx("button", { type: "button", className: "rte-link-dialog-btn", onMouseDown: (e) => e.preventDefault(), onClick: onClose, children: "Cancel" }), 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);
3584
3721
  };
3585
3722
  /* ══════════════════════════════════════════════════════════════════════════
3586
3723
  Helpers
@@ -3627,6 +3764,9 @@ function applyLinkData(link, data, customFields) {
3627
3764
  href += data.custom[field.key];
3628
3765
  }
3629
3766
  }
3767
+ // Validate URL before setting href to prevent javascript: / data: injection
3768
+ if (!isUrlSafe(href))
3769
+ return;
3630
3770
  link.setAttribute("href", href);
3631
3771
  // Target
3632
3772
  if (data.target && data.target !== "_self") {
@@ -3702,7 +3842,7 @@ const LinkToolbarButton = (props) => {
3702
3842
  e.preventDefault();
3703
3843
  e.stopPropagation();
3704
3844
  const href = link.getAttribute("href");
3705
- if (href)
3845
+ if (href && isUrlSafe(href))
3706
3846
  window.open(href, "_blank");
3707
3847
  return;
3708
3848
  }
@@ -3742,6 +3882,8 @@ const LinkToolbarButton = (props) => {
3742
3882
  href += data.custom[field.key];
3743
3883
  }
3744
3884
  }
3885
+ if (!isUrlSafe(href))
3886
+ return;
3745
3887
  document.execCommand("createLink", false, href);
3746
3888
  const newLink = getSelectedLink();
3747
3889
  if (newLink) {
@@ -4305,7 +4447,7 @@ const LinkTooltip = ({ editorElement }) => {
4305
4447
  scheduleHide();
4306
4448
  };
4307
4449
  const handleOpen = () => {
4308
- if (state.href) {
4450
+ if (state.href && isUrlSafe(state.href)) {
4309
4451
  window.open(state.href, "_blank", "noopener,noreferrer");
4310
4452
  }
4311
4453
  };
@@ -4331,14 +4473,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4331
4473
  const toolbarRef = useRef(null);
4332
4474
  useEffect(() => {
4333
4475
  setIsClient(true);
4476
+ const timeoutIds = [];
4334
4477
  const handleSelectionChange = () => {
4335
4478
  setUpdateTrigger((prev) => prev + 1);
4336
4479
  };
4337
4480
  const handleMouseUp = () => {
4338
- setTimeout(handleSelectionChange, 10);
4481
+ timeoutIds.push(setTimeout(handleSelectionChange, 10));
4339
4482
  };
4340
4483
  const handleKeyUp = () => {
4341
- setTimeout(handleSelectionChange, 10);
4484
+ timeoutIds.push(setTimeout(handleSelectionChange, 10));
4342
4485
  };
4343
4486
  if (typeof document !== 'undefined') {
4344
4487
  document.addEventListener("selectionchange", handleSelectionChange);
@@ -4346,6 +4489,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4346
4489
  document.addEventListener("keyup", handleKeyUp);
4347
4490
  }
4348
4491
  return () => {
4492
+ timeoutIds.forEach(clearTimeout);
4349
4493
  if (typeof document !== 'undefined') {
4350
4494
  document.removeEventListener("selectionchange", handleSelectionChange);
4351
4495
  document.removeEventListener("mouseup", handleMouseUp);
@@ -4371,6 +4515,15 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4371
4515
  const clearFormattingPlugin = plugins.find((p) => p.name === "clearFormatting");
4372
4516
  // Roving tabindex keyboard navigation (ARIA toolbar pattern)
4373
4517
  const handleToolbarKeyDown = useCallback((e) => {
4518
+ // Escape: return focus to the editor
4519
+ if (e.key === "Escape") {
4520
+ e.preventDefault();
4521
+ const container = toolbarRef.current?.closest(".rte-container");
4522
+ const editor = container?.querySelector(".rte-editor");
4523
+ if (editor)
4524
+ editor.focus();
4525
+ return;
4526
+ }
4374
4527
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
4375
4528
  return;
4376
4529
  const toolbar = toolbarRef.current;
@@ -4402,7 +4555,7 @@ const Toolbar = ({ plugins, editorAPI, className, }) => {
4402
4555
  }
4403
4556
  buttons[nextIndex].focus();
4404
4557
  }, []);
4405
- return (jsxs("div", { ref: toolbarRef, className: `rte-toolbar rte-toolbar-sticky ${className || ""}`, onMouseDown: (e) => e.preventDefault(), onKeyDown: handleToolbarKeyDown, role: "toolbar", "aria-label": "Text formatting", children: [jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
4558
+ return (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: [jsx("div", { className: "rte-toolbar-left", children: leftPlugins.map((plugin) => {
4406
4559
  if (!plugin.renderButton)
4407
4560
  return null;
4408
4561
  const isActive = isClient && plugin.isActive
@@ -4445,6 +4598,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4445
4598
  const editorRef = useRef(null);
4446
4599
  const historyRef = useRef(new HistoryManager());
4447
4600
  const isUpdatingRef = useRef(false);
4601
+ const mountedRef = useRef(true);
4602
+ // Track mount status to guard async callbacks
4603
+ useEffect(() => {
4604
+ mountedRef.current = true;
4605
+ return () => {
4606
+ mountedRef.current = false;
4607
+ };
4608
+ }, []);
4448
4609
  // --- Plugins ---
4449
4610
  const plugins = useMemo(() => {
4450
4611
  // When settings is provided and plugins is not, use buildPluginsFromSettings
@@ -4455,7 +4616,12 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4455
4616
  };
4456
4617
  return buildPluginsFromSettings(settings, opts);
4457
4618
  }
4458
- const allPlugins = [...(providedPlugins || defaultPlugins)];
4619
+ // When plugins are explicitly provided, use them as-is
4620
+ if (providedPlugins) {
4621
+ return [...providedPlugins];
4622
+ }
4623
+ // Default plugins path: apply shorthand customizations
4624
+ const allPlugins = [...defaultPlugins];
4459
4625
  if (headings && headings.length > 0) {
4460
4626
  const blockFormatIndex = allPlugins.findIndex((p) => p.name === "blockFormat");
4461
4627
  if (blockFormatIndex !== -1) {
@@ -4570,12 +4736,14 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4570
4736
  return checkbox.insertCheckboxList(editor);
4571
4737
  }
4572
4738
  if (command === "insertImage" && value) {
4573
- return handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange);
4739
+ return handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange);
4574
4740
  }
4575
4741
  // General commands via document.execCommand
4576
4742
  ensureEditorFocused(editor);
4577
4743
  document.execCommand(command, false, value);
4578
4744
  setTimeout(() => {
4745
+ if (!mountedRef.current)
4746
+ return;
4579
4747
  if (editor && !isUpdatingRef.current) {
4580
4748
  ensureAllCheckboxes(editor);
4581
4749
  const content = domToContent(editor);
@@ -4616,6 +4784,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4616
4784
  const block = document.createElement(type);
4617
4785
  if (attributes) {
4618
4786
  Object.entries(attributes).forEach(([key, val]) => {
4787
+ // Filter out event handler attributes
4788
+ if (key.toLowerCase().startsWith("on"))
4789
+ return;
4619
4790
  block.setAttribute(key, val);
4620
4791
  });
4621
4792
  }
@@ -4638,6 +4809,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4638
4809
  const inline = document.createElement(type);
4639
4810
  if (attributes) {
4640
4811
  Object.entries(attributes).forEach(([key, val]) => {
4812
+ // Filter out event handler attributes
4813
+ if (key.toLowerCase().startsWith("on"))
4814
+ return;
4641
4815
  inline.setAttribute(key, val);
4642
4816
  });
4643
4817
  }
@@ -4724,6 +4898,8 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4724
4898
  historyRef.current.push(currentContent, sel);
4725
4899
  operation(selection);
4726
4900
  setTimeout(() => {
4901
+ if (!mountedRef.current)
4902
+ return;
4727
4903
  if (editor)
4728
4904
  notifyChange(domToContent(editor));
4729
4905
  }, 0);
@@ -4750,6 +4926,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4750
4926
  editorRef,
4751
4927
  historyRef,
4752
4928
  isUpdatingRef,
4929
+ mountedRef,
4753
4930
  notifyChange,
4754
4931
  handleCheckboxKeyDown: checkbox.handleCheckboxKeyDown,
4755
4932
  handleCheckboxEnter: checkbox.handleCheckboxEnter,
@@ -4846,6 +5023,9 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4846
5023
  }
4847
5024
  // Upload
4848
5025
  const url = await onImageUpload(file);
5026
+ // Guard against unmount during async upload
5027
+ if (!mountedRef.current)
5028
+ return;
4849
5029
  // Parse the "url|__aid__:attachmentId" convention
4850
5030
  let realUrl = url;
4851
5031
  if (url.includes("|__aid__:")) {
@@ -4856,6 +5036,11 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4856
5036
  placeholder.setAttribute("data-attachment-id", attachmentId);
4857
5037
  }
4858
5038
  }
5039
+ // Validate the returned URL before setting it
5040
+ if (!isUrlSafe(realUrl) && !realUrl.startsWith("data:image/")) {
5041
+ placeholder.remove();
5042
+ return;
5043
+ }
4859
5044
  // Replace placeholder with final image
4860
5045
  placeholder.src = realUrl;
4861
5046
  placeholder.removeAttribute("data-uploading");
@@ -4976,7 +5161,7 @@ const Editor = ({ initialContent, onChange, plugins: providedPlugins, placeholde
4976
5161
  }, suppressContentEditableWarning: true }), !readOnly && (jsx(FloatingToolbar, { plugins: plugins, editorAPI: editorAPI, editorElement: editorRef.current })), jsx(LinkTooltip, { editorElement: editorRef.current }), showWordCount && (jsxs("div", { className: "rte-word-count", children: [wordCount.words, " words \u00B7 ", wordCount.characters, " characters"] }))] }));
4977
5162
  };
4978
5163
  // --- Helper: Insert Image ---
4979
- function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChange) {
5164
+ function handleInsertImage(editor, value, isUpdatingRef, historyRef, mountedRef, notifyChange) {
4980
5165
  let selection = window.getSelection();
4981
5166
  if (!selection)
4982
5167
  return false;
@@ -4998,7 +5183,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
4998
5183
  newRange.collapse(true);
4999
5184
  selection.removeAllRanges();
5000
5185
  selection.addRange(newRange);
5001
- saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
5186
+ saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
5002
5187
  return true;
5003
5188
  }
5004
5189
  selection.removeAllRanges();
@@ -5039,7 +5224,7 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
5039
5224
  newRange.collapse(true);
5040
5225
  selection.removeAllRanges();
5041
5226
  selection.addRange(newRange);
5042
- saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange);
5227
+ saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange);
5043
5228
  return true;
5044
5229
  }
5045
5230
  /**
@@ -5053,23 +5238,36 @@ function handleInsertImage(editor, value, isUpdatingRef, historyRef, notifyChang
5053
5238
  function createImageElement(src) {
5054
5239
  const img = document.createElement("img");
5055
5240
  let realSrc = src;
5241
+ let altText = "";
5242
+ // Parse the "url|__alt__:altText" convention
5243
+ if (realSrc.includes("|__alt__:")) {
5244
+ const altIdx = realSrc.indexOf("|__alt__:");
5245
+ altText = realSrc.substring(altIdx + "|__alt__:".length);
5246
+ realSrc = realSrc.substring(0, altIdx);
5247
+ }
5056
5248
  // Parse the "url|__aid__:attachmentId" convention
5057
- if (src.includes("|__aid__:")) {
5058
- const idx = src.indexOf("|__aid__:");
5059
- realSrc = src.substring(0, idx);
5060
- const attachmentId = src.substring(idx + "|__aid__:".length);
5249
+ if (realSrc.includes("|__aid__:")) {
5250
+ const idx = realSrc.indexOf("|__aid__:");
5251
+ const attachmentId = realSrc.substring(idx + "|__aid__:".length);
5252
+ realSrc = realSrc.substring(0, idx);
5061
5253
  if (attachmentId) {
5062
5254
  img.setAttribute("data-attachment-id", attachmentId);
5063
5255
  }
5064
5256
  }
5257
+ // Validate URL safety — block javascript:, data:, etc.
5258
+ if (!isUrlSafe(realSrc) && !realSrc.startsWith("data:image/")) {
5259
+ realSrc = "";
5260
+ }
5065
5261
  img.setAttribute("src", realSrc);
5066
- img.setAttribute("alt", "");
5262
+ img.setAttribute("alt", altText);
5067
5263
  img.className = "rte-image";
5068
5264
  return img;
5069
5265
  }
5070
- function saveAndNotify(editor, isUpdatingRef, historyRef, notifyChange) {
5266
+ function saveAndNotify(editor, isUpdatingRef, historyRef, mountedRef, notifyChange) {
5071
5267
  isUpdatingRef.current = true;
5072
5268
  setTimeout(() => {
5269
+ if (!mountedRef.current)
5270
+ return;
5073
5271
  const content = domToContent(editor);
5074
5272
  const sel = serializeSelection(editor);
5075
5273
  historyRef.current.push(content, sel);
@@ -5199,14 +5397,14 @@ function createLinkPlugin() {
5199
5397
  // Check if a link already exists
5200
5398
  const existingLink = element?.closest("a");
5201
5399
  if (existingLink) {
5202
- // Link entfernen
5400
+ // Remove link
5203
5401
  const parent = existingLink.parentNode;
5204
5402
  if (parent) {
5205
5403
  while (existingLink.firstChild) {
5206
5404
  parent.insertBefore(existingLink.firstChild, existingLink);
5207
5405
  }
5208
5406
  parent.removeChild(existingLink);
5209
- // Content aktualisieren
5407
+ // Update content
5210
5408
  const editorEl = editor.getSelection()?.anchorNode;
5211
5409
  if (editorEl) {
5212
5410
  const content = editor.getContent();
@@ -5217,7 +5415,7 @@ function createLinkPlugin() {
5217
5415
  else {
5218
5416
  // Insert new link
5219
5417
  const url = prompt("Enter URL:");
5220
- if (url) {
5418
+ if (url && isUrlSafe(url)) {
5221
5419
  editor.executeCommand("createLink", url);
5222
5420
  }
5223
5421
  }
@@ -5344,5 +5542,5 @@ const orderedListPlugin = {
5344
5542
  },
5345
5543
  };
5346
5544
 
5347
- export { Dropdown, Editor, FloatingToolbar, HistoryManager, TableContextMenuProvider, Toolbar, advancedLinkPlugin, alignmentPlugin, blockquotePlugin, boldPlugin, buildPluginsFromSettings, clearFormattingPlugin, codeInlinePlugin, contentToDOM, contentToHTML, createAdvancedLinkPlugin, createAlignmentPlugin, createBackgroundColorPlugin, createBlockFormatPlugin, createEmptyContent, createFontSizePlugin, createHeadingsPlugin, createImagePlugin, createLinkPlugin, createTextColorPlugin, Editor as default, defaultEditorSettings, defaultPlugins, domToContent, ensureAllCheckboxes, findClosestCheckboxList, getCurrentBackgroundColor, getCurrentFontSize, getCurrentHeading, getCurrentTextColor, horizontalRulePlugin, htmlToContent, indentListItem, indentListItemPlugin, isCheckboxList, italicPlugin, linkPlugin, orderedListPlugin, outdentListItem, outdentListItemPlugin, redoPlugin, restoreSerializedSelection, sanitizeHtml, serializeSelection, strikethroughPlugin, subscriptPlugin, superscriptPlugin, tablePlugin, underlinePlugin, undoPlugin, unorderedListPlugin };
5545
+ export { Dropdown, Editor, FloatingToolbar, HistoryManager, TableContextMenuProvider, Toolbar, advancedLinkPlugin, alignmentPlugin, blockquotePlugin, boldPlugin, buildPluginsFromSettings, clearFormattingPlugin, codeInlinePlugin, contentToDOM, contentToHTML, createAdvancedLinkPlugin, createAlignmentPlugin, createBackgroundColorPlugin, createBlockFormatPlugin, createEmptyContent, createFontSizePlugin, createHeadingsPlugin, createImagePlugin, createLinkPlugin, createTextColorPlugin, Editor as default, defaultEditorSettings, defaultPlugins, domToContent, ensureAllCheckboxes, findClosestCheckboxList, getCurrentBackgroundColor, getCurrentFontSize, getCurrentHeading, getCurrentTextColor, horizontalRulePlugin, htmlToContent, indentListItem, indentListItemPlugin, isCheckboxList, isUrlSafe, italicPlugin, linkPlugin, orderedListPlugin, outdentListItem, outdentListItemPlugin, redoPlugin, restoreSerializedSelection, sanitizeHtml, serializeSelection, strikethroughPlugin, subscriptPlugin, superscriptPlugin, tablePlugin, underlinePlugin, undoPlugin, unorderedListPlugin };
5348
5546
  //# sourceMappingURL=index.esm.js.map