@stackch/angular-material-richtext-editor 1.0.0 → 1.2.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.
@@ -344,6 +344,8 @@ class StackchRichtextEditorMaterial {
344
344
  get disabled() { return this._disabled; }
345
345
  _disabled = false;
346
346
  valueChange = new EventEmitter();
347
+ // Structured metrics event (future-proof)
348
+ metricsChange = new EventEmitter();
347
349
  editorRef;
348
350
  constructor(cdr) {
349
351
  this.cdr = cdr;
@@ -607,6 +609,8 @@ class StackchRichtextEditorMaterial {
607
609
  if (this.history.length === 0) {
608
610
  this.takeSnapshot('init');
609
611
  }
612
+ // Emit initial metrics when value is written programmatically
613
+ this.emitMetrics();
610
614
  }
611
615
  registerOnChange(fn) { this.onChange = fn; }
612
616
  registerOnTouched(fn) { this.onTouched = fn; }
@@ -720,7 +724,8 @@ class StackchRichtextEditorMaterial {
720
724
  // Firefox/Edge: wenn keine explizite Auswahl, erweitere auf nächstes Wort
721
725
  this.expandRangeToWord(r);
722
726
  }
723
- this.applyInlineStyle('color', color);
727
+ this.applySelectionStyles({ color });
728
+ this.saveSelection();
724
729
  }
725
730
  applyHighlight(color) {
726
731
  this.focusEditor();
@@ -732,8 +737,8 @@ class StackchRichtextEditorMaterial {
732
737
  if (r.collapsed) {
733
738
  this.expandRangeToWord(r);
734
739
  }
735
- // Some browsers use 'hiliteColor', others 'backColor'
736
- this.applyInlineStyle('backgroundColor', color);
740
+ this.applySelectionStyles({ backgroundColor: color });
741
+ this.saveSelection();
737
742
  }
738
743
  expandRangeToWord(range) {
739
744
  try {
@@ -814,12 +819,23 @@ class StackchRichtextEditorMaterial {
814
819
  const val = this.editorRef.nativeElement.innerHTML;
815
820
  this.onChange(val);
816
821
  this.valueChange.emit(val);
822
+ this.emitMetrics();
817
823
  this.updateInlineStates();
818
824
  }
825
+ emitMetrics() {
826
+ const editor = this.editorRef?.nativeElement;
827
+ if (!editor)
828
+ return;
829
+ const htmlLen = editor.innerHTML.length;
830
+ const textLen = (editor.textContent || '').length;
831
+ this.metricsChange.emit({ htmlLength: htmlLen, textLength: textLen });
832
+ }
819
833
  // Remove empty style attributes (style="") and unwrap spans without any attributes
820
834
  cleanupEmptyStylesAndSpans(root) {
835
+ console.log('[RTE-material] cleanupEmptyStylesAndSpans: start cleanup');
821
836
  const editor = root || this.editorRef.nativeElement;
822
837
  const toUnwrap = [];
838
+ const toRemove = [];
823
839
  const walker = document.createTreeWalker(editor, NodeFilter.SHOW_ELEMENT);
824
840
  while (walker.nextNode()) {
825
841
  const el = walker.currentNode;
@@ -832,16 +848,28 @@ class StackchRichtextEditorMaterial {
832
848
  el.removeAttribute('style');
833
849
  }
834
850
  }
835
- // Unwrap spans that have no attributes left
836
- if (el.tagName === 'SPAN' && el.attributes.length === 0) {
837
- toUnwrap.push(el);
851
+ if (el.tagName === 'SPAN') {
852
+ if (!el.firstChild) {
853
+ console.log('[RTE-material] cleanup: removing empty span', el);
854
+ toRemove.push(el);
855
+ continue;
856
+ }
857
+ if (el.attributes.length === 0) {
858
+ console.log('[RTE-material] cleanup: unwrapping style-free span', el);
859
+ toUnwrap.push(el);
860
+ }
838
861
  }
839
862
  }
840
863
  for (const span of toUnwrap) {
864
+ console.log('[RTE-material] cleanup: unwrap span', { span, childCount: span.childNodes.length });
841
865
  while (span.firstChild)
842
866
  span.parentNode?.insertBefore(span.firstChild, span);
843
867
  span.remove();
844
868
  }
869
+ for (const span of toRemove) {
870
+ console.log('[RTE-material] cleanup: remove span without children', span);
871
+ span.remove();
872
+ }
845
873
  }
846
874
  // History API
847
875
  get canUndo() { return this.historyIndex > 0; }
@@ -998,117 +1026,176 @@ class StackchRichtextEditorMaterial {
998
1026
  }
999
1027
  // Minimal inline style applier for inline styles
1000
1028
  applyInlineStyle(cssProp, value) {
1001
- this.focusEditor();
1002
- this.restoreSelection();
1003
- const sel = window.getSelection();
1004
- if (!sel || sel.rangeCount === 0)
1005
- return;
1006
- const range = sel.getRangeAt(0);
1007
- if (range.collapsed)
1008
- return;
1009
- const span = document.createElement('span');
1010
- span.style[cssProp] = value;
1011
- // Robuster als surroundContents: extract + wrap + insert
1012
- const frag = range.extractContents();
1013
- span.appendChild(frag);
1014
- range.insertNode(span);
1015
- // Cursor hinter das eingefügte Element setzen
1016
- sel.removeAllRanges();
1017
- const after = document.createRange();
1018
- after.setStartAfter(span);
1019
- after.collapse(true);
1020
- sel.addRange(after);
1021
- this.emitValue();
1022
- this.takeSnapshot('style');
1029
+ // Delegate to a generic, style-agnostic applier
1030
+ this.applySelectionStyles({ [cssProp]: value });
1023
1031
  }
1024
1032
  applyInlineStyleSmart(cssProp, value) {
1033
+ // Use generic, which handles single vs. multi-block selection
1034
+ this.applySelectionStyles({ [cssProp]: value });
1035
+ }
1036
+ applySelectionStyles(styles) {
1025
1037
  this.focusEditor();
1026
1038
  this.restoreSelection();
1027
1039
  const sel = window.getSelection();
1028
- if (!sel || sel.rangeCount === 0)
1040
+ if (!sel || sel.rangeCount === 0) {
1041
+ console.warn('[RTE-material] applySelectionStyles: no selection available', { styles });
1029
1042
  return;
1030
- const range = sel.getRangeAt(0);
1031
- if (range.collapsed)
1043
+ }
1044
+ let range = sel.getRangeAt(0);
1045
+ if (range.collapsed) {
1046
+ console.warn('[RTE-material] applySelectionStyles: range is collapsed', { styles });
1032
1047
  return;
1033
- const isBlockTag = (tag) => /^(P|DIV|PRE|H1|H2|H3|H4|H5|H6|LI|TD|TH)$/i.test(tag);
1034
- const getBlockAncestor = (node) => {
1035
- const editor = this.editorRef.nativeElement;
1036
- let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
1037
- while (el && el !== editor && !isBlockTag(el.tagName))
1038
- el = el.parentElement;
1039
- return (el && el !== editor) ? el : null;
1040
- };
1041
- const startBlock = getBlockAncestor(range.startContainer);
1042
- const endBlock = getBlockAncestor(range.endContainer);
1043
- const crossesBlocks = !!startBlock && !!endBlock && startBlock !== endBlock;
1044
- if (!crossesBlocks) {
1045
- // Single block selection: keep using inline wrapper for precise range
1046
- this.applyInlineStyle(cssProp, value);
1048
+ }
1049
+ const entries = Object.entries(styles).filter(([, v]) => v != null && v !== '');
1050
+ if (entries.length === 0) {
1051
+ console.warn('[RTE-material] applySelectionStyles: no style entries to apply', { styles });
1047
1052
  return;
1048
1053
  }
1049
- // Multi-block selection: apply style to each intersecting block element (avoid invalid span structure)
1050
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1051
- ? range.commonAncestorContainer
1052
- : (range.commonAncestorContainer.parentElement || this.editorRef.nativeElement);
1053
- const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1054
- const styled = new Set();
1055
- while (walker.nextNode()) {
1056
- const el = walker.currentNode;
1057
- if (!isBlockTag(el.tagName))
1058
- continue;
1059
- try {
1060
- if (range.intersectsNode && range.intersectsNode(el))
1061
- styled.add(el);
1054
+ const propsToClear = Array.from(new Set(entries.map(([prop]) => this.toCssProperty(prop))));
1055
+ console.log('[RTE-material] applySelectionStyles: clearing props before apply', { propsToClear, entries });
1056
+ for (const cssProp of propsToClear) {
1057
+ const beforeClear = range.cloneRange();
1058
+ const clearedRange = this.removeInlineStyleInRange(range, cssProp, { suppressEmit: true });
1059
+ const refreshedSel = window.getSelection();
1060
+ if (refreshedSel && refreshedSel.rangeCount > 0) {
1061
+ range = refreshedSel.getRangeAt(0);
1062
+ }
1063
+ else if (clearedRange) {
1064
+ range = clearedRange;
1065
+ }
1066
+ if (range.collapsed && !beforeClear.collapsed) {
1067
+ console.warn('[RTE-material] applySelectionStyles: range collapsed after clearing, restoring previous range');
1068
+ const sel = window.getSelection();
1069
+ if (sel) {
1070
+ sel.removeAllRanges();
1071
+ sel.addRange(beforeClear);
1072
+ }
1073
+ range = beforeClear;
1062
1074
  }
1063
- catch { }
1064
1075
  }
1065
- for (const el of styled) {
1066
- el.style[cssProp] = value;
1076
+ const segments = this.collectTextSegments(range);
1077
+ if (segments.length === 0) {
1078
+ console.warn('[RTE-material] applySelectionStyles: no text segments found', {
1079
+ entries,
1080
+ commonAncestor: range.commonAncestorContainer
1081
+ });
1082
+ return;
1083
+ }
1084
+ console.log('[RTE-material] applySelectionStyles: applying styles to segments', {
1085
+ entries,
1086
+ segmentCount: segments.length
1087
+ });
1088
+ const wrappedSegments = [];
1089
+ for (let i = segments.length - 1; i >= 0; i--) {
1090
+ const wrapped = this.applyStylesToSegment(segments[i], entries);
1091
+ if (wrapped)
1092
+ wrappedSegments.unshift(wrapped);
1093
+ }
1094
+ if (wrappedSegments.length) {
1095
+ const first = wrappedSegments[0];
1096
+ const last = wrappedSegments[wrappedSegments.length - 1];
1097
+ const newRange = document.createRange();
1098
+ newRange.setStartBefore(first);
1099
+ newRange.setEndAfter(last);
1100
+ const selAfter = window.getSelection();
1101
+ if (selAfter) {
1102
+ selAfter.removeAllRanges();
1103
+ selAfter.addRange(newRange);
1104
+ }
1105
+ this.saveSelectionFromRange(newRange);
1067
1106
  }
1068
1107
  this.emitValue();
1069
- this.takeSnapshot('style-blocks');
1108
+ this.takeSnapshot('style');
1070
1109
  }
1071
- getIntersectingBlocks(range) {
1072
- const isBlockTag = (tag) => /^(P|DIV|PRE|H1|H2|H3|H4|H5|H6|LI|TD|TH)$/i.test(tag);
1073
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1074
- ? range.commonAncestorContainer
1075
- : (range.commonAncestorContainer.parentElement || this.editorRef.nativeElement);
1076
- const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1077
- const blocks = [];
1110
+ collectTextSegments(range) {
1111
+ const segments = [];
1112
+ const root = range.commonAncestorContainer;
1113
+ if (root.nodeType === Node.TEXT_NODE) {
1114
+ const text = root;
1115
+ const start = text === range.startContainer ? range.startOffset : 0;
1116
+ const end = text === range.endContainer ? range.endOffset : text.data.length;
1117
+ if (start < end)
1118
+ segments.push({ node: text, start, end });
1119
+ return segments;
1120
+ }
1121
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1078
1122
  while (walker.nextNode()) {
1079
- const el = walker.currentNode;
1080
- if (!isBlockTag(el.tagName))
1123
+ const text = walker.currentNode;
1124
+ if (!text.data)
1081
1125
  continue;
1082
- try {
1083
- if (range.intersectsNode && range.intersectsNode(el))
1084
- blocks.push(el);
1085
- }
1086
- catch { }
1126
+ if (!this.intersectsRange(range, text))
1127
+ continue;
1128
+ const start = text === range.startContainer ? range.startOffset : 0;
1129
+ const end = text === range.endContainer ? range.endOffset : text.data.length;
1130
+ if (start >= end)
1131
+ continue;
1132
+ segments.push({ node: text, start, end });
1087
1133
  }
1088
- return blocks;
1134
+ return segments;
1089
1135
  }
1090
- // Variant of applyInlineStyle that returns the created span and does not emit/snapshot by itself
1091
- wrapSelectionInline(cssProp, value) {
1092
- this.focusEditor();
1093
- this.restoreSelection();
1094
- const sel = window.getSelection();
1095
- if (!sel || sel.rangeCount === 0)
1096
- return null;
1097
- const range = sel.getRangeAt(0);
1098
- if (range.collapsed)
1099
- return null;
1100
- const span = document.createElement('span');
1101
- span.style[cssProp] = value;
1102
- const frag = range.extractContents();
1103
- span.appendChild(frag);
1104
- range.insertNode(span);
1105
- // Restore selection just after for continuity
1106
- sel.removeAllRanges();
1107
- const after = document.createRange();
1108
- after.setStartAfter(span);
1109
- after.collapse(true);
1110
- sel.addRange(after);
1111
- return span;
1136
+ intersectsRange(range, node) {
1137
+ try {
1138
+ return range.intersectsNode ? range.intersectsNode(node) : true;
1139
+ }
1140
+ catch {
1141
+ return false;
1142
+ }
1143
+ }
1144
+ applyStylesToSegment(segment, entries) {
1145
+ const segRange = document.createRange();
1146
+ segRange.setStart(segment.node, segment.start);
1147
+ segRange.setEnd(segment.node, segment.end);
1148
+ const wrapper = document.createElement('span');
1149
+ for (const [prop, value] of entries) {
1150
+ wrapper.style.setProperty(this.toCssProperty(prop), value);
1151
+ }
1152
+ try {
1153
+ segRange.surroundContents(wrapper);
1154
+ return this.mergeAdjacentStyledSpans(wrapper);
1155
+ }
1156
+ catch (err) {
1157
+ console.error('[RTE-material] applyStylesToSegment: failed to surround contents', {
1158
+ error: err,
1159
+ segment,
1160
+ entries
1161
+ });
1162
+ }
1163
+ return null;
1164
+ }
1165
+ toCssProperty(prop) {
1166
+ return prop.replace(/([A-Z])/g, '-$1').toLowerCase();
1167
+ }
1168
+ mergeAdjacentStyledSpans(span) {
1169
+ if (span.tagName !== 'SPAN')
1170
+ return span;
1171
+ let current = span;
1172
+ const normalize = (el) => el && el.tagName === 'SPAN' ? el : null;
1173
+ const styleText = (el) => (el ? el.getAttribute('style') || '' : '');
1174
+ let parent = normalize(current.parentElement);
1175
+ if (parent && styleText(parent) === styleText(current) && parent.attributes.length === current.attributes.length) {
1176
+ while (current.firstChild)
1177
+ parent.insertBefore(current.firstChild, current);
1178
+ current.remove();
1179
+ current = parent;
1180
+ }
1181
+ let prev = current.previousSibling;
1182
+ while (prev && prev.nodeType === Node.TEXT_NODE && !prev.data.trim())
1183
+ prev = prev.previousSibling;
1184
+ if (prev instanceof HTMLElement && prev.tagName === 'SPAN' && styleText(prev) === styleText(current) && prev.attributes.length === current.attributes.length) {
1185
+ while (current.firstChild)
1186
+ prev.appendChild(current.firstChild);
1187
+ current.remove();
1188
+ current = prev;
1189
+ }
1190
+ let next = current.nextSibling;
1191
+ while (next && next.nodeType === Node.TEXT_NODE && !next.data.trim())
1192
+ next = next.nextSibling;
1193
+ if (next instanceof HTMLElement && next.tagName === 'SPAN' && styleText(next) === styleText(current) && next.attributes.length === current.attributes.length) {
1194
+ while (next.firstChild)
1195
+ current.appendChild(next.firstChild);
1196
+ next.remove();
1197
+ }
1198
+ return current;
1112
1199
  }
1113
1200
  isBoldCarrier(el) {
1114
1201
  if (!el)
@@ -1154,45 +1241,21 @@ class StackchRichtextEditorMaterial {
1154
1241
  }
1155
1242
  return null;
1156
1243
  }
1157
- splitCarrierAroundSelection(range, carrier) {
1158
- const parent = carrier.parentNode;
1159
- if (!parent)
1160
- return;
1161
- const rRight = document.createRange();
1162
- rRight.setStart(range.endContainer, range.endOffset);
1163
- rightGuard(carrier, rRight);
1164
- const rightFrag = rRight.extractContents();
1165
- const rLeft = document.createRange();
1166
- rLeft.setStart(carrier, 0);
1167
- leftGuard(range, rLeft);
1168
- const leftFrag = rLeft.extractContents();
1169
- if (leftFrag.childNodes.length) {
1170
- const leftClone = carrier.cloneNode(false);
1171
- leftClone.appendChild(leftFrag);
1172
- parent.insertBefore(leftClone, carrier);
1173
- }
1174
- let insertAfterRef = carrier;
1175
- if (rightFrag.childNodes.length) {
1176
- const rightClone = carrier.cloneNode(false);
1177
- rightClone.appendChild(rightFrag);
1178
- parent.insertBefore(rightClone, carrier.nextSibling);
1179
- insertAfterRef = rightClone;
1180
- }
1181
- while (carrier.firstChild)
1182
- parent.insertBefore(carrier.firstChild, insertAfterRef);
1183
- carrier.remove();
1184
- function rightGuard(node, r) {
1185
- try {
1186
- r.setEnd(node, node.childNodes.length);
1187
- }
1188
- catch { }
1189
- }
1190
- function leftGuard(rng, r) {
1191
- try {
1192
- r.setEnd(rng.startContainer, rng.startOffset);
1193
- }
1194
- catch { }
1195
- }
1244
+ isItalicCarrier(el) {
1245
+ if (!el)
1246
+ return false;
1247
+ if (el.tagName === 'I' || el.tagName === 'EM')
1248
+ return true;
1249
+ const fs = el.style?.fontStyle || '';
1250
+ return !!fs && fs !== 'normal';
1251
+ }
1252
+ isUnderlineCarrier(el) {
1253
+ if (!el)
1254
+ return false;
1255
+ if (el.tagName === 'U')
1256
+ return true;
1257
+ const td = el.style?.textDecoration || el.style?.textDecorationLine || '';
1258
+ return typeof td === 'string' && td.includes('underline');
1196
1259
  }
1197
1260
  // Unwrap bold formatting only for the selected content inside a containing bold ancestor by splitting it into left/selection/right parts
1198
1261
  deselectBoldBySplitting(range) {
@@ -1295,8 +1358,15 @@ class StackchRichtextEditorMaterial {
1295
1358
  // move siblings after `child` into right clone
1296
1359
  while (child.nextSibling)
1297
1360
  right.appendChild(child.nextSibling);
1298
- // insert right after parent
1299
- parent.after(right);
1361
+ // insert right after parent (avoid Document-level issues)
1362
+ const pParent = parent.parentNode;
1363
+ if (pParent && pParent.nodeType !== Node.DOCUMENT_NODE) {
1364
+ pParent.insertBefore(right, parent.nextSibling);
1365
+ }
1366
+ else {
1367
+ while (right.firstChild)
1368
+ parent.appendChild(right.firstChild);
1369
+ }
1300
1370
  // ascend
1301
1371
  child = parent;
1302
1372
  parent = child.parentElement;
@@ -1314,6 +1384,19 @@ class StackchRichtextEditorMaterial {
1314
1384
  const gp = ancestor.parentNode;
1315
1385
  if (!gp)
1316
1386
  return;
1387
+ if (gp.nodeType === Node.DOCUMENT_NODE) {
1388
+ if (before.childNodes.length) {
1389
+ while (before.firstChild)
1390
+ ancestor.parentNode?.insertBefore(before.firstChild, ancestor);
1391
+ }
1392
+ ancestor.parentNode?.insertBefore(node, ancestor);
1393
+ if (after.childNodes.length) {
1394
+ while (after.firstChild)
1395
+ ancestor.parentNode?.insertBefore(after.firstChild, ancestor);
1396
+ }
1397
+ ancestor.remove();
1398
+ return;
1399
+ }
1317
1400
  if (before.childNodes.length)
1318
1401
  gp.insertBefore(before, ancestor);
1319
1402
  // move node out, place where ancestor was
@@ -1322,39 +1405,8 @@ class StackchRichtextEditorMaterial {
1322
1405
  gp.insertBefore(after, ancestor);
1323
1406
  ancestor.remove();
1324
1407
  }
1325
- cleanupEmptyBoldSpans(root) {
1326
- const toRemove = [];
1327
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1328
- while (walker.nextNode()) {
1329
- const el = walker.currentNode;
1330
- const isBoldEl = (el.tagName === 'B' || el.tagName === 'STRONG' || (el.tagName === 'SPAN' && el.style.fontWeight && el.style.fontWeight !== 'normal' && el.style.fontWeight !== '400'));
1331
- if (!isBoldEl)
1332
- continue;
1333
- // remove if no children or only whitespace (including NBSP \u00A0 and zero-width space \u200B)
1334
- const text = el.textContent ?? '';
1335
- const normalized = text.replace(/[\u00A0\u200B\s]/g, '');
1336
- const onlyWhitespace = !el.firstChild || (normalized.length === 0 && el.querySelectorAll('*').length === 0);
1337
- if (onlyWhitespace) {
1338
- toRemove.push(el);
1339
- }
1340
- }
1341
- for (const el of toRemove)
1342
- el.remove();
1343
- }
1344
- maybeUnwrapNormalSpan(span) {
1345
- if (span.tagName !== 'SPAN')
1346
- return;
1347
- // If no bold applies from ancestors, drop the 400 style and unwrap if style empty
1348
- const hasBoldAbove = this.isNodeBold(span.parentElement);
1349
- if (!hasBoldAbove) {
1350
- if (span.style.fontWeight === '400')
1351
- span.style.removeProperty('font-weight');
1352
- if (!span.getAttribute('style')) {
1353
- while (span.firstChild)
1354
- span.parentNode?.insertBefore(span.firstChild, span);
1355
- span.remove();
1356
- }
1357
- }
1408
+ saveSelectionFromRange(range) {
1409
+ this.savedRange = range.cloneRange();
1358
1410
  }
1359
1411
  wrapSelectionWith(tag, attrs) {
1360
1412
  const sel = window.getSelection();
@@ -1424,55 +1476,13 @@ class StackchRichtextEditorMaterial {
1424
1476
  catch { }
1425
1477
  }
1426
1478
  computeBoldAnyForRange(range) {
1427
- const root = this.editorRef.nativeElement;
1428
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1429
- while (walker.nextNode()) {
1430
- const n = walker.currentNode;
1431
- if (!n.data.trim())
1432
- continue;
1433
- try {
1434
- if (range.intersectsNode(n)) {
1435
- if (this.isNodeBold(n.parentElement))
1436
- return true;
1437
- }
1438
- }
1439
- catch { }
1440
- }
1441
- return false;
1479
+ return this.selectionHasStyle(range, 'font-weight');
1442
1480
  }
1443
1481
  computeItalicAnyForRange(range) {
1444
- const root = this.editorRef.nativeElement;
1445
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1446
- while (walker.nextNode()) {
1447
- const n = walker.currentNode;
1448
- if (!n.data.trim())
1449
- continue;
1450
- try {
1451
- if (range.intersectsNode(n)) {
1452
- if (this.isNodeItalic(n.parentElement))
1453
- return true;
1454
- }
1455
- }
1456
- catch { }
1457
- }
1458
- return false;
1482
+ return this.selectionHasStyle(range, 'font-style');
1459
1483
  }
1460
1484
  computeUnderlineAnyForRange(range) {
1461
- const root = this.editorRef.nativeElement;
1462
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1463
- while (walker.nextNode()) {
1464
- const n = walker.currentNode;
1465
- if (!n.data.trim())
1466
- continue;
1467
- try {
1468
- if (range.intersectsNode(n)) {
1469
- if (this.isNodeUnderline(n.parentElement))
1470
- return true;
1471
- }
1472
- }
1473
- catch { }
1474
- }
1475
- return false;
1485
+ return this.selectionHasStyle(range, 'text-decoration');
1476
1486
  }
1477
1487
  isNodeBold(node) {
1478
1488
  let el = node;
@@ -1513,117 +1523,248 @@ class StackchRichtextEditorMaterial {
1513
1523
  }
1514
1524
  return false;
1515
1525
  }
1516
- isItalicCarrier(el) {
1517
- if (!el)
1518
- return false;
1519
- const tag = el.tagName;
1520
- if (tag === 'I' || tag === 'EM')
1521
- return true;
1522
- const fs = el.style?.fontStyle || '';
1523
- return !!fs && fs !== 'normal';
1524
- }
1525
- isUnderlineCarrier(el) {
1526
- if (!el)
1527
- return false;
1528
- const tag = el.tagName;
1529
- if (tag === 'U')
1530
- return true;
1531
- const td = el.style?.textDecoration || '';
1532
- return typeof td === 'string' && td.includes('underline');
1533
- }
1534
- computeBoldActiveForRange(range) {
1535
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1536
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1537
- const nodes = [];
1538
- while (walker.nextNode()) {
1539
- const n = walker.currentNode;
1540
- if (!n.data.trim())
1541
- continue;
1542
- try {
1543
- if (range.intersectsNode(n))
1544
- nodes.push(n);
1545
- }
1546
- catch { /* Safari may throw if node not in same tree */ }
1547
- }
1548
- if (nodes.length === 0)
1549
- return false;
1550
- return nodes.every(t => this.isNodeBold(t.parentElement));
1551
- }
1552
- computeItalicActiveForRange(range) {
1553
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1554
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1555
- const nodes = [];
1556
- while (walker.nextNode()) {
1557
- const n = walker.currentNode;
1558
- if (!n.data.trim())
1559
- continue;
1526
+ removeInlineStyleInRange(range, cssPropKebab, options) {
1527
+ const shouldRestoreSelection = options?.restoreSelection !== false;
1528
+ this.isolateRangeFromStyleCarriers(range, cssPropKebab);
1529
+ const editor = this.editorRef.nativeElement;
1530
+ const selAfter = window.getSelection();
1531
+ const effectiveRange = selAfter && selAfter.rangeCount > 0 ? selAfter.getRangeAt(0) : range;
1532
+ const common = effectiveRange.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1533
+ ? effectiveRange.commonAncestorContainer
1534
+ : (effectiveRange.commonAncestorContainer.parentElement || editor);
1535
+ const affected = [];
1536
+ const intersects = (node) => {
1560
1537
  try {
1561
- if (range.intersectsNode(n))
1562
- nodes.push(n);
1538
+ return effectiveRange.intersectsNode ? effectiveRange.intersectsNode(node) : true;
1563
1539
  }
1564
- catch { }
1565
- }
1566
- if (nodes.length === 0)
1567
- return false;
1568
- return nodes.every(t => this.isNodeItalic(t.parentElement));
1569
- }
1570
- computeUnderlineActiveForRange(range) {
1571
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1572
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1573
- const nodes = [];
1574
- while (walker.nextNode()) {
1575
- const n = walker.currentNode;
1576
- if (!n.data.trim())
1577
- continue;
1578
- try {
1579
- if (range.intersectsNode(n))
1580
- nodes.push(n);
1540
+ catch {
1541
+ return false;
1581
1542
  }
1582
- catch { }
1543
+ };
1544
+ const matchesEl = (el) => this.isStyleCarrier(el, cssPropKebab);
1545
+ console.log('[RTE-material] removeInlineStyleInRange:start', {
1546
+ cssPropKebab,
1547
+ rangeCollapsed: effectiveRange.collapsed,
1548
+ commonTag: common.tagName,
1549
+ commonHasStyle: common.style ? common.style.getPropertyValue?.(cssPropKebab) : undefined
1550
+ });
1551
+ if (common instanceof HTMLElement && matchesEl(common) && intersects(common)) {
1552
+ affected.push(common);
1583
1553
  }
1584
- if (nodes.length === 0)
1585
- return false;
1586
- return nodes.every(t => this.isNodeUnderline(t.parentElement));
1587
- }
1588
- removeInlineStyleInRange(range, cssPropKebab) {
1589
- const editor = this.editorRef.nativeElement;
1590
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement || editor;
1591
- const affected = [];
1592
1554
  const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1593
1555
  while (walker.nextNode()) {
1594
1556
  const el = walker.currentNode;
1595
- let matches = false;
1596
- if (el.tagName === 'SPAN' && el.style && el.style.getPropertyValue(cssPropKebab))
1597
- matches = true;
1598
- if (cssPropKebab === 'font-weight' && (el.tagName === 'B' || el.tagName === 'STRONG'))
1599
- matches = true;
1600
- if (cssPropKebab === 'font-style' && (el.tagName === 'I' || el.tagName === 'EM'))
1601
- matches = true;
1602
- if (cssPropKebab === 'text-decoration' && el.tagName === 'U')
1603
- matches = true;
1604
- if (!matches)
1557
+ if (!matchesEl(el))
1605
1558
  continue;
1606
- try {
1607
- if (range.intersectsNode ? range.intersectsNode(el) : true)
1608
- affected.push(el);
1559
+ if (intersects(el)) {
1560
+ affected.push(el);
1561
+ console.log('[RTE-material] removeInlineStyleInRange:match', el.tagName, el.getAttribute('style'));
1609
1562
  }
1610
- catch { }
1611
1563
  }
1564
+ let removedTags = 0;
1565
+ let removedProps = 0;
1612
1566
  for (const el of affected) {
1613
1567
  if (el.tagName === 'B' || el.tagName === 'STRONG' || el.tagName === 'I' || el.tagName === 'EM' || el.tagName === 'U') {
1568
+ console.log('[RTE-material] unwrap tag', el.tagName);
1614
1569
  while (el.firstChild)
1615
1570
  el.parentNode?.insertBefore(el.firstChild, el);
1616
1571
  el.remove();
1572
+ removedTags++;
1617
1573
  continue;
1618
1574
  }
1575
+ console.log('[RTE-material] remove style', cssPropKebab, 'from', el.tagName, el.getAttribute('style'));
1619
1576
  el.style.removeProperty(cssPropKebab);
1577
+ if (!el.style.length) {
1578
+ console.log('[RTE-material] removeInlineStyleInRange: style attribute now empty, removing attribute', el);
1579
+ el.removeAttribute('style');
1580
+ }
1581
+ removedProps++;
1620
1582
  if (!el.getAttribute('style')) {
1583
+ console.log('[RTE-material] unwrap empty styled span', el.tagName);
1621
1584
  while (el.firstChild)
1622
1585
  el.parentNode?.insertBefore(el.firstChild, el);
1623
1586
  el.remove();
1624
1587
  }
1625
1588
  }
1626
- this.emitValue();
1589
+ this.pruneDanglingStyleCarriers(common, cssPropKebab);
1590
+ console.log('[RTE-material] removeInlineStyleInRange:done', { affected: affected.length, removedTags, removedProps });
1591
+ let resultRange = null;
1592
+ if (!options?.suppressEmit) {
1593
+ this.emitValue();
1594
+ resultRange = null;
1595
+ }
1596
+ else {
1597
+ const sel = window.getSelection();
1598
+ if (sel && sel.rangeCount > 0) {
1599
+ resultRange = sel.getRangeAt(0).cloneRange();
1600
+ }
1601
+ else if (shouldRestoreSelection) {
1602
+ resultRange = effectiveRange.cloneRange();
1603
+ }
1604
+ if (shouldRestoreSelection && resultRange) {
1605
+ const selRestore = window.getSelection();
1606
+ if (selRestore) {
1607
+ selRestore.removeAllRanges();
1608
+ selRestore.addRange(resultRange);
1609
+ this.saveSelectionFromRange(resultRange);
1610
+ }
1611
+ }
1612
+ }
1613
+ return resultRange;
1614
+ }
1615
+ pruneDanglingStyleCarriers(root, cssPropKebab) {
1616
+ const toRemove = [];
1617
+ if (root instanceof HTMLElement && this.isStyleCarrier(root, cssPropKebab) && !this.elementHasVisibleContent(root)) {
1618
+ toRemove.push(root);
1619
+ }
1620
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1621
+ while (walker.nextNode()) {
1622
+ const el = walker.currentNode;
1623
+ if (!this.isStyleCarrier(el, cssPropKebab))
1624
+ continue;
1625
+ if (this.elementHasVisibleContent(el))
1626
+ continue;
1627
+ toRemove.push(el);
1628
+ }
1629
+ for (const el of toRemove) {
1630
+ console.log('[RTE-material] pruneDanglingStyleCarriers: removing empty carrier', { tag: el.tagName, style: el.getAttribute('style') });
1631
+ if (el.tagName === 'B' || el.tagName === 'STRONG' || el.tagName === 'I' || el.tagName === 'EM' || el.tagName === 'U') {
1632
+ el.remove();
1633
+ continue;
1634
+ }
1635
+ el.remove();
1636
+ }
1637
+ }
1638
+ elementHasVisibleContent(node) {
1639
+ if (node.nodeType === Node.TEXT_NODE) {
1640
+ const text = node.data;
1641
+ for (let i = 0; i < text.length; i++) {
1642
+ const ch = text[i];
1643
+ if (ch === '\u00a0')
1644
+ return true;
1645
+ if (!/\s/.test(ch) && ch !== '\u200b' && ch !== '\u200c' && ch !== '\u200d' && ch !== '\ufeff') {
1646
+ return true;
1647
+ }
1648
+ }
1649
+ return false;
1650
+ }
1651
+ if (node.nodeType === Node.ELEMENT_NODE) {
1652
+ const el = node;
1653
+ if (el.tagName === 'BR')
1654
+ return true;
1655
+ for (const child of Array.from(node.childNodes)) {
1656
+ if (this.elementHasVisibleContent(child))
1657
+ return true;
1658
+ }
1659
+ }
1660
+ return false;
1661
+ }
1662
+ isolateRangeFromStyleCarriers(range, cssPropKebab) {
1663
+ let carrier = this.findCarrierContainingRange(range, cssPropKebab);
1664
+ while (carrier) {
1665
+ this.splitCarrierAroundSelection(range, carrier);
1666
+ carrier = this.findCarrierContainingRange(range, cssPropKebab);
1667
+ }
1668
+ }
1669
+ selectionHasStyle(range, cssPropKebab) {
1670
+ const editor = this.editorRef.nativeElement;
1671
+ const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1672
+ ? range.commonAncestorContainer
1673
+ : (range.commonAncestorContainer.parentElement || editor);
1674
+ const intersects = (node) => this.intersectsRange(range, node);
1675
+ if (common instanceof HTMLElement && this.isStyleCarrier(common, cssPropKebab) && intersects(common)) {
1676
+ return true;
1677
+ }
1678
+ const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1679
+ while (walker.nextNode()) {
1680
+ const el = walker.currentNode;
1681
+ if (!this.isStyleCarrier(el, cssPropKebab))
1682
+ continue;
1683
+ if (intersects(el))
1684
+ return true;
1685
+ }
1686
+ return false;
1687
+ }
1688
+ findCarrierContainingRange(range, cssPropKebab) {
1689
+ let el = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1690
+ ? range.commonAncestorContainer
1691
+ : range.commonAncestorContainer.parentElement;
1692
+ const editor = this.editorRef.nativeElement;
1693
+ while (el && el !== editor) {
1694
+ if (this.isStyleCarrier(el, cssPropKebab)) {
1695
+ const sc = range.startContainer;
1696
+ const ec = range.endContainer;
1697
+ if (el.contains(sc) && el.contains(ec))
1698
+ return el;
1699
+ }
1700
+ el = el.parentElement;
1701
+ }
1702
+ return null;
1703
+ }
1704
+ isStyleCarrier(el, cssPropKebab) {
1705
+ const tag = el.tagName;
1706
+ if (cssPropKebab === 'font-weight') {
1707
+ if (tag === 'B' || tag === 'STRONG')
1708
+ return true;
1709
+ const fw = el.style?.fontWeight || '';
1710
+ return !!fw && fw !== 'normal' && fw !== '400';
1711
+ }
1712
+ if (cssPropKebab === 'font-style') {
1713
+ if (tag === 'I' || tag === 'EM')
1714
+ return true;
1715
+ const fs = el.style?.fontStyle || '';
1716
+ return !!fs && fs !== 'normal';
1717
+ }
1718
+ if (cssPropKebab === 'text-decoration') {
1719
+ if (tag === 'U')
1720
+ return true;
1721
+ const td = el.style?.textDecoration || el.style?.textDecorationLine || '';
1722
+ return typeof td === 'string' && td.includes('underline');
1723
+ }
1724
+ const inline = el.style?.getPropertyValue(cssPropKebab) || '';
1725
+ if (!inline)
1726
+ return false;
1727
+ if (cssPropKebab === 'color') {
1728
+ return inline !== 'inherit' && inline !== 'initial';
1729
+ }
1730
+ if (cssPropKebab === 'background-color') {
1731
+ return inline !== 'transparent' && inline !== 'initial';
1732
+ }
1733
+ return true;
1734
+ }
1735
+ splitCarrierAroundSelection(range, carrier) {
1736
+ const parent = carrier.parentNode;
1737
+ if (!parent)
1738
+ return;
1739
+ const rightRange = document.createRange();
1740
+ rightRange.setStart(range.endContainer, range.endOffset);
1741
+ try {
1742
+ rightRange.setEnd(carrier, carrier.childNodes.length);
1743
+ }
1744
+ catch { }
1745
+ const rightFrag = rightRange.extractContents();
1746
+ const leftRange = document.createRange();
1747
+ leftRange.setStart(carrier, 0);
1748
+ try {
1749
+ leftRange.setEnd(range.startContainer, range.startOffset);
1750
+ }
1751
+ catch { }
1752
+ const leftFrag = leftRange.extractContents();
1753
+ if (leftFrag.childNodes.length) {
1754
+ const leftClone = carrier.cloneNode(false);
1755
+ leftClone.appendChild(leftFrag);
1756
+ parent.insertBefore(leftClone, carrier);
1757
+ }
1758
+ let insertAfter = carrier;
1759
+ if (rightFrag.childNodes.length) {
1760
+ const rightClone = carrier.cloneNode(false);
1761
+ rightClone.appendChild(rightFrag);
1762
+ parent.insertBefore(rightClone, carrier.nextSibling);
1763
+ insertAfter = rightClone;
1764
+ }
1765
+ while (carrier.firstChild)
1766
+ parent.insertBefore(carrier.firstChild, insertAfter);
1767
+ carrier.remove();
1627
1768
  }
1628
1769
  toggleBold() {
1629
1770
  this.focusEditor();
@@ -1635,45 +1776,11 @@ class StackchRichtextEditorMaterial {
1635
1776
  if (range.collapsed)
1636
1777
  this.expandRangeToWord(range);
1637
1778
  const anyActive = this.computeBoldAnyForRange(range);
1638
- const blocks = this.getIntersectingBlocks(range);
1639
- const multiBlock = blocks.length > 1;
1640
1779
  if (anyActive) {
1641
- if (multiBlock) {
1642
- for (const b of blocks)
1643
- b.style.removeProperty('font-weight');
1644
- this.emitValue();
1645
- this.updateInlineStates();
1646
- this.takeSnapshot('toggle-bold-blocks');
1647
- return;
1648
- }
1649
- // Preferred: split bold ancestor around selection so we keep surrounding text intact
1650
- const changed = this.deselectBoldBySplitting(range);
1651
- if (changed) {
1652
- this.cleanupEmptyBoldSpans(this.editorRef.nativeElement);
1653
- this.emitValue();
1654
- }
1655
- else {
1656
- // Fallback: previous neutralization approach for complex cases
1657
- const normal = this.wrapSelectionInline('fontWeight', '400');
1658
- if (normal) {
1659
- this.stripBoldWithin(normal);
1660
- this.liftOutOfBoldAncestors(normal);
1661
- this.cleanupEmptyBoldSpans(this.editorRef.nativeElement);
1662
- this.maybeUnwrapNormalSpan(normal);
1663
- this.emitValue();
1664
- }
1665
- }
1780
+ this.removeInlineStyleInRange(range, 'font-weight');
1666
1781
  }
1667
1782
  else {
1668
- if (multiBlock) {
1669
- for (const b of blocks)
1670
- b.style.fontWeight = 'bold';
1671
- this.emitValue();
1672
- this.updateInlineStates();
1673
- this.takeSnapshot('toggle-bold-blocks');
1674
- return;
1675
- }
1676
- this.applyInlineStyleSmart('fontWeight', 'bold');
1783
+ this.applySelectionStyles({ fontWeight: 'bold' });
1677
1784
  }
1678
1785
  this.updateInlineStates();
1679
1786
  this.takeSnapshot('toggle-bold');
@@ -1689,18 +1796,10 @@ class StackchRichtextEditorMaterial {
1689
1796
  this.expandRangeToWord(range);
1690
1797
  const anyActive = this.computeItalicAnyForRange(range);
1691
1798
  if (anyActive) {
1692
- const changed = this.deselectItalicBySplitting(range);
1693
- if (changed) {
1694
- this.cleanupEmptyItalicSpans(this.editorRef.nativeElement);
1695
- this.emitValue();
1696
- }
1697
- else {
1698
- this.removeInlineStyleInRange(range, 'font-style');
1699
- this.applyInlineStyle('fontStyle', 'normal');
1700
- }
1799
+ this.removeInlineStyleInRange(range, 'font-style');
1701
1800
  }
1702
1801
  else {
1703
- this.applyInlineStyleSmart('fontStyle', 'italic');
1802
+ this.applySelectionStyles({ fontStyle: 'italic' });
1704
1803
  }
1705
1804
  this.updateInlineStates();
1706
1805
  this.takeSnapshot('toggle-italic');
@@ -1716,18 +1815,10 @@ class StackchRichtextEditorMaterial {
1716
1815
  this.expandRangeToWord(range);
1717
1816
  const anyActive = this.computeUnderlineAnyForRange(range);
1718
1817
  if (anyActive) {
1719
- const changed = this.deselectUnderlineBySplitting(range);
1720
- if (changed) {
1721
- this.cleanupEmptyUnderlineSpans(this.editorRef.nativeElement);
1722
- this.emitValue();
1723
- }
1724
- else {
1725
- this.removeInlineStyleInRange(range, 'text-decoration');
1726
- this.applyInlineStyle('textDecoration', 'none');
1727
- }
1818
+ this.removeInlineStyleInRange(range, 'text-decoration');
1728
1819
  }
1729
1820
  else {
1730
- this.applyInlineStyleSmart('textDecoration', 'underline');
1821
+ this.applySelectionStyles({ textDecoration: 'underline' });
1731
1822
  }
1732
1823
  this.updateInlineStates();
1733
1824
  this.takeSnapshot('toggle-underline');
@@ -2073,7 +2164,7 @@ class StackchRichtextEditorMaterial {
2073
2164
  }
2074
2165
  }
2075
2166
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: StackchRichtextEditorMaterial, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
2076
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: StackchRichtextEditorMaterial, isStandalone: true, selector: "stackch-richtext-editor-material", inputs: { placeholder: "placeholder", showToolbar: "showToolbar", fonts: "fonts", fontSizes: "fontSizes", height: "height", minHeight: "minHeight", maxHeight: "maxHeight", disabled: "disabled", config: "config" }, outputs: { valueChange: "valueChange" }, host: { listeners: { "document:selectionchange": "onSelectionChange()", "document:click": "closeMenus()", "keydown": "onKeydown($event)" } }, providers: [
2167
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: StackchRichtextEditorMaterial, isStandalone: true, selector: "stackch-richtext-editor-material", inputs: { placeholder: "placeholder", showToolbar: "showToolbar", fonts: "fonts", fontSizes: "fontSizes", height: "height", minHeight: "minHeight", maxHeight: "maxHeight", disabled: "disabled", config: "config" }, outputs: { valueChange: "valueChange", metricsChange: "metricsChange" }, host: { listeners: { "document:selectionchange": "onSelectionChange()", "document:click": "closeMenus()", "keydown": "onKeydown($event)" } }, providers: [
2077
2168
  {
2078
2169
  provide: NG_VALUE_ACCESSOR,
2079
2170
  useExisting: forwardRef(() => StackchRichtextEditorMaterial),
@@ -2108,6 +2199,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImpor
2108
2199
  type: Input
2109
2200
  }], valueChange: [{
2110
2201
  type: Output
2202
+ }], metricsChange: [{
2203
+ type: Output
2111
2204
  }], editorRef: [{
2112
2205
  type: ViewChild,
2113
2206
  args: ['editor', { static: true }]