@stackch/angular-richtext-editor 1.1.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.
@@ -143,7 +143,6 @@ class StackchRichtextEditorConfig {
143
143
  // i18n overrides (partial), default is English
144
144
  i18n;
145
145
  }
146
- 1;
147
146
  class StackchRichtextEditorI18n {
148
147
  // Generic
149
148
  placeholder = 'Write…';
@@ -327,6 +326,8 @@ class StackchRichtextEditor {
327
326
  get disabled() { return this._disabled; }
328
327
  _disabled = false;
329
328
  valueChange = new EventEmitter();
329
+ // Structured metrics event (future-proof)
330
+ metricsChange = new EventEmitter();
330
331
  editorRef;
331
332
  constructor(cdr) {
332
333
  this.cdr = cdr;
@@ -592,6 +593,8 @@ class StackchRichtextEditor {
592
593
  if (this.history.length === 0) {
593
594
  this.takeSnapshot('init');
594
595
  }
596
+ // Emit initial metrics when value is written programmatically
597
+ this.emitMetrics();
595
598
  }
596
599
  registerOnChange(fn) { this.onChange = fn; }
597
600
  registerOnTouched(fn) { this.onTouched = fn; }
@@ -705,7 +708,8 @@ class StackchRichtextEditor {
705
708
  // Firefox/Edge: wenn keine explizite Auswahl, erweitere auf nächstes Wort
706
709
  this.expandRangeToWord(r);
707
710
  }
708
- this.applyInlineStyle('color', color);
711
+ this.applySelectionStyles({ color });
712
+ this.saveSelection();
709
713
  }
710
714
  applyHighlight(color) {
711
715
  this.focusEditor();
@@ -717,8 +721,8 @@ class StackchRichtextEditor {
717
721
  if (r.collapsed) {
718
722
  this.expandRangeToWord(r);
719
723
  }
720
- // Some browsers use 'hiliteColor', others 'backColor'
721
- this.applyInlineStyle('backgroundColor', color);
724
+ this.applySelectionStyles({ backgroundColor: color });
725
+ this.saveSelection();
722
726
  }
723
727
  expandRangeToWord(range) {
724
728
  try {
@@ -799,12 +803,25 @@ class StackchRichtextEditor {
799
803
  const val = this.editorRef.nativeElement.innerHTML;
800
804
  this.onChange(val);
801
805
  this.valueChange.emit(val);
806
+ this.emitMetrics();
802
807
  this.updateInlineStates();
803
808
  }
809
+ emitMetrics() {
810
+ const editor = this.editorRef?.nativeElement;
811
+ if (!editor)
812
+ return;
813
+ const htmlLen = editor.innerHTML.length;
814
+ // textContent excludes markup; normalize null to empty string
815
+ const textLen = (editor.textContent || '').length;
816
+ // Structured metrics event
817
+ this.metricsChange.emit({ htmlLength: htmlLen, textLength: textLen });
818
+ }
804
819
  // Remove empty style attributes (style="") and unwrap spans without any attributes
805
820
  cleanupEmptyStylesAndSpans(root) {
821
+ console.log('[RTE] cleanupEmptyStylesAndSpans: start cleanup');
806
822
  const editor = root || this.editorRef.nativeElement;
807
823
  const toUnwrap = [];
824
+ const toRemove = [];
808
825
  const walker = document.createTreeWalker(editor, NodeFilter.SHOW_ELEMENT);
809
826
  while (walker.nextNode()) {
810
827
  const el = walker.currentNode;
@@ -817,16 +834,28 @@ class StackchRichtextEditor {
817
834
  el.removeAttribute('style');
818
835
  }
819
836
  }
820
- // Unwrap spans that have no attributes left
821
- if (el.tagName === 'SPAN' && el.attributes.length === 0) {
822
- toUnwrap.push(el);
837
+ if (el.tagName === 'SPAN') {
838
+ if (!el.firstChild) {
839
+ console.log('[RTE] cleanup: removing empty span', el);
840
+ toRemove.push(el);
841
+ continue;
842
+ }
843
+ if (el.attributes.length === 0) {
844
+ console.log('[RTE] cleanup: unwrapping style-free span', el);
845
+ toUnwrap.push(el);
846
+ }
823
847
  }
824
848
  }
825
849
  for (const span of toUnwrap) {
850
+ console.log('[RTE] cleanup: unwrap span', { span, childCount: span.childNodes.length });
826
851
  while (span.firstChild)
827
852
  span.parentNode?.insertBefore(span.firstChild, span);
828
853
  span.remove();
829
854
  }
855
+ for (const span of toRemove) {
856
+ console.log('[RTE] cleanup: remove span without children', span);
857
+ span.remove();
858
+ }
830
859
  }
831
860
  // History API
832
861
  get canUndo() { return this.historyIndex > 0; }
@@ -983,363 +1012,182 @@ class StackchRichtextEditor {
983
1012
  }
984
1013
  // Minimal inline style applier for inline styles
985
1014
  applyInlineStyle(cssProp, value) {
986
- this.focusEditor();
987
- this.restoreSelection();
988
- const sel = window.getSelection();
989
- if (!sel || sel.rangeCount === 0)
990
- return;
991
- const range = sel.getRangeAt(0);
992
- if (range.collapsed)
993
- return;
994
- const span = document.createElement('span');
995
- span.style[cssProp] = value;
996
- // Robuster als surroundContents: extract + wrap + insert
997
- const frag = range.extractContents();
998
- span.appendChild(frag);
999
- range.insertNode(span);
1000
- // Cursor hinter das eingefügte Element setzen
1001
- sel.removeAllRanges();
1002
- const after = document.createRange();
1003
- after.setStartAfter(span);
1004
- after.collapse(true);
1005
- sel.addRange(after);
1006
- this.emitValue();
1007
- this.takeSnapshot('style');
1015
+ // Delegate to generic, style-agnostic applier
1016
+ this.applySelectionStyles({ [cssProp]: value });
1008
1017
  }
1009
1018
  applyInlineStyleSmart(cssProp, value) {
1019
+ // Delegate to generic applier which handles selection/logging
1020
+ this.applySelectionStyles({ [cssProp]: value });
1021
+ }
1022
+ applySelectionStyles(styles) {
1010
1023
  this.focusEditor();
1011
1024
  this.restoreSelection();
1012
1025
  const sel = window.getSelection();
1013
- if (!sel || sel.rangeCount === 0)
1026
+ if (!sel || sel.rangeCount === 0) {
1027
+ console.warn('[RTE] applySelectionStyles: no selection available', { styles });
1014
1028
  return;
1015
- const range = sel.getRangeAt(0);
1016
- if (range.collapsed)
1029
+ }
1030
+ let range = sel.getRangeAt(0);
1031
+ if (range.collapsed) {
1032
+ console.warn('[RTE] applySelectionStyles: range is collapsed', { styles });
1017
1033
  return;
1018
- const isBlockTag = (tag) => /^(P|DIV|PRE|H1|H2|H3|H4|H5|H6|LI|TD|TH)$/i.test(tag);
1019
- const getBlockAncestor = (node) => {
1020
- const editor = this.editorRef.nativeElement;
1021
- let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
1022
- while (el && el !== editor && !isBlockTag(el.tagName))
1023
- el = el.parentElement;
1024
- return (el && el !== editor) ? el : null;
1025
- };
1026
- const startBlock = getBlockAncestor(range.startContainer);
1027
- const endBlock = getBlockAncestor(range.endContainer);
1028
- const crossesBlocks = !!startBlock && !!endBlock && startBlock !== endBlock;
1029
- if (!crossesBlocks) {
1030
- // Single block selection: keep using inline wrapper for precise range
1031
- this.applyInlineStyle(cssProp, value);
1034
+ }
1035
+ const entries = Object.entries(styles).filter(([, v]) => v != null && v !== '');
1036
+ if (entries.length === 0) {
1037
+ console.warn('[RTE] applySelectionStyles: no style entries to apply', { styles });
1032
1038
  return;
1033
1039
  }
1034
- // Multi-block selection: apply style to each intersecting block element (avoid invalid span structure)
1035
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1036
- ? range.commonAncestorContainer
1037
- : (range.commonAncestorContainer.parentElement || this.editorRef.nativeElement);
1038
- const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1039
- const styled = new Set();
1040
- while (walker.nextNode()) {
1041
- const el = walker.currentNode;
1042
- if (!isBlockTag(el.tagName))
1043
- continue;
1044
- try {
1045
- if (range.intersectsNode && range.intersectsNode(el))
1046
- styled.add(el);
1040
+ const propsToClear = Array.from(new Set(entries.map(([prop]) => this.toCssProperty(prop))));
1041
+ console.log('[RTE] applySelectionStyles: clearing props before apply', { propsToClear, entries });
1042
+ for (const cssProp of propsToClear) {
1043
+ const beforeClear = range.cloneRange();
1044
+ const clearedRange = this.removeInlineStyleInRange(range, cssProp, { suppressEmit: true });
1045
+ const refreshedSel = window.getSelection();
1046
+ if (refreshedSel && refreshedSel.rangeCount > 0) {
1047
+ range = refreshedSel.getRangeAt(0);
1047
1048
  }
1048
- catch { }
1049
- }
1050
- for (const el of styled) {
1051
- el.style[cssProp] = value;
1052
- }
1053
- this.emitValue();
1054
- this.takeSnapshot('style-blocks');
1055
- }
1056
- getIntersectingBlocks(range) {
1057
- const isBlockTag = (tag) => /^(P|DIV|PRE|H1|H2|H3|H4|H5|H6|LI|TD|TH)$/i.test(tag);
1058
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1059
- ? range.commonAncestorContainer
1060
- : (range.commonAncestorContainer.parentElement || this.editorRef.nativeElement);
1061
- const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1062
- const blocks = [];
1063
- while (walker.nextNode()) {
1064
- const el = walker.currentNode;
1065
- if (!isBlockTag(el.tagName))
1066
- continue;
1067
- try {
1068
- if (range.intersectsNode && range.intersectsNode(el))
1069
- blocks.push(el);
1049
+ else if (clearedRange) {
1050
+ range = clearedRange;
1070
1051
  }
1071
- catch { }
1072
- }
1073
- return blocks;
1074
- }
1075
- // Variant of applyInlineStyle that returns the created span and does not emit/snapshot by itself
1076
- wrapSelectionInline(cssProp, value) {
1077
- this.focusEditor();
1078
- this.restoreSelection();
1079
- const sel = window.getSelection();
1080
- if (!sel || sel.rangeCount === 0)
1081
- return null;
1082
- const range = sel.getRangeAt(0);
1083
- if (range.collapsed)
1084
- return null;
1085
- const span = document.createElement('span');
1086
- span.style[cssProp] = value;
1087
- const frag = range.extractContents();
1088
- span.appendChild(frag);
1089
- range.insertNode(span);
1090
- // Restore selection just after for continuity
1091
- sel.removeAllRanges();
1092
- const after = document.createRange();
1093
- after.setStartAfter(span);
1094
- after.collapse(true);
1095
- sel.addRange(after);
1096
- return span;
1097
- }
1098
- isBoldCarrier(el) {
1099
- if (!el)
1100
- return false;
1101
- const tag = el.tagName;
1102
- if (tag === 'B' || tag === 'STRONG')
1103
- return true;
1104
- const fw = el.style?.fontWeight || '';
1105
- if (fw && fw !== 'normal' && fw !== '400')
1106
- return true;
1107
- return false;
1108
- }
1109
- boldAncestorContainingRange(range) {
1110
- let el = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1111
- ? range.commonAncestorContainer
1112
- : range.commonAncestorContainer.parentElement;
1113
- const editor = this.editorRef.nativeElement;
1114
- while (el && el !== editor) {
1115
- if (this.isBoldCarrier(el)) {
1116
- // ensure el contains both boundary containers fully
1117
- const sc = range.startContainer;
1118
- const ec = range.endContainer;
1119
- if (el.contains(sc) && el.contains(ec))
1120
- return el;
1121
- }
1122
- el = el.parentElement;
1123
- }
1124
- return null;
1125
- }
1126
- ancestorContainingRange(range, isCarrier) {
1127
- let el = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1128
- ? range.commonAncestorContainer
1129
- : range.commonAncestorContainer.parentElement;
1130
- const editor = this.editorRef.nativeElement;
1131
- while (el && el !== editor) {
1132
- if (isCarrier(el)) {
1133
- const sc = range.startContainer;
1134
- const ec = range.endContainer;
1135
- if (el.contains(sc) && el.contains(ec))
1136
- return el;
1052
+ if (range.collapsed && !beforeClear.collapsed) {
1053
+ console.warn('[RTE] applySelectionStyles: range collapsed after clearing, restoring previous range');
1054
+ const sel = window.getSelection();
1055
+ if (sel) {
1056
+ sel.removeAllRanges();
1057
+ sel.addRange(beforeClear);
1058
+ }
1059
+ range = beforeClear;
1137
1060
  }
1138
- el = el.parentElement;
1139
1061
  }
1140
- return null;
1141
- }
1142
- splitCarrierAroundSelection(range, carrier) {
1143
- const parent = carrier.parentNode;
1144
- if (!parent)
1062
+ const segments = this.collectTextSegments(range);
1063
+ if (segments.length === 0) {
1064
+ console.warn('[RTE] applySelectionStyles: no text segments found', {
1065
+ entries,
1066
+ commonAncestor: range.commonAncestorContainer
1067
+ });
1145
1068
  return;
1146
- const rRight = document.createRange();
1147
- rRight.setStart(range.endContainer, range.endOffset);
1148
- rightGuard(carrier, rRight);
1149
- const rightFrag = rRight.extractContents();
1150
- const rLeft = document.createRange();
1151
- rLeft.setStart(carrier, 0);
1152
- leftGuard(range, rLeft);
1153
- const leftFrag = rLeft.extractContents();
1154
- if (leftFrag.childNodes.length) {
1155
- const leftClone = carrier.cloneNode(false);
1156
- leftClone.appendChild(leftFrag);
1157
- parent.insertBefore(leftClone, carrier);
1158
- }
1159
- let insertAfterRef = carrier;
1160
- if (rightFrag.childNodes.length) {
1161
- const rightClone = carrier.cloneNode(false);
1162
- rightClone.appendChild(rightFrag);
1163
- parent.insertBefore(rightClone, carrier.nextSibling);
1164
- insertAfterRef = rightClone;
1165
1069
  }
1166
- while (carrier.firstChild)
1167
- parent.insertBefore(carrier.firstChild, insertAfterRef);
1168
- carrier.remove();
1169
- function rightGuard(node, r) {
1170
- try {
1171
- r.setEnd(node, node.childNodes.length);
1172
- }
1173
- catch { }
1174
- }
1175
- function leftGuard(rng, r) {
1176
- try {
1177
- r.setEnd(rng.startContainer, rng.startOffset);
1070
+ console.log('[RTE] applySelectionStyles: applying styles to segments', {
1071
+ entries,
1072
+ segmentCount: segments.length
1073
+ });
1074
+ const wrappedSegments = [];
1075
+ for (let i = segments.length - 1; i >= 0; i--) {
1076
+ const wrapped = this.applyStylesToSegment(segments[i], entries);
1077
+ if (wrapped)
1078
+ wrappedSegments.unshift(wrapped);
1079
+ }
1080
+ if (wrappedSegments.length) {
1081
+ const first = wrappedSegments[0];
1082
+ const last = wrappedSegments[wrappedSegments.length - 1];
1083
+ const newRange = document.createRange();
1084
+ newRange.setStartBefore(first);
1085
+ newRange.setEndAfter(last);
1086
+ const selAfter = window.getSelection();
1087
+ if (selAfter) {
1088
+ selAfter.removeAllRanges();
1089
+ selAfter.addRange(newRange);
1178
1090
  }
1179
- catch { }
1091
+ this.saveSelectionFromRange(newRange);
1180
1092
  }
1093
+ this.emitValue();
1094
+ this.takeSnapshot('style');
1181
1095
  }
1182
- // Unwrap bold formatting only for the selected content inside a containing bold ancestor by splitting it into left/selection/right parts
1183
- deselectBoldBySplitting(range) {
1184
- const ancestor = this.boldAncestorContainingRange(range);
1185
- if (!ancestor)
1186
- return false;
1187
- this.splitCarrierAroundSelection(range, ancestor);
1188
- return true;
1189
- }
1190
- deselectItalicBySplitting(range) {
1191
- const anc = this.ancestorContainingRange(range, el => this.isItalicCarrier(el));
1192
- if (!anc)
1193
- return false;
1194
- this.splitCarrierAroundSelection(range, anc);
1195
- return true;
1196
- }
1197
- deselectUnderlineBySplitting(range) {
1198
- const anc = this.ancestorContainingRange(range, el => this.isUnderlineCarrier(el));
1199
- if (!anc)
1200
- return false;
1201
- this.splitCarrierAroundSelection(range, anc);
1202
- return true;
1203
- }
1204
- cleanupEmptyItalicSpans(root) {
1205
- const toRemove = [];
1206
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1207
- while (walker.nextNode()) {
1208
- const el = walker.currentNode;
1209
- const isItalic = (el.tagName === 'I' || el.tagName === 'EM' || (el.tagName === 'SPAN' && el.style.fontStyle && el.style.fontStyle !== 'normal'));
1210
- if (!isItalic)
1211
- continue;
1212
- const text = el.textContent ?? '';
1213
- const normalized = text.replace(/[\u00A0\u200B\s]/g, '');
1214
- const onlyWhitespace = !el.firstChild || (normalized.length === 0 && el.querySelectorAll('*').length === 0);
1215
- if (onlyWhitespace)
1216
- toRemove.push(el);
1096
+ collectTextSegments(range) {
1097
+ const segments = [];
1098
+ const root = range.commonAncestorContainer;
1099
+ if (root.nodeType === Node.TEXT_NODE) {
1100
+ const text = root;
1101
+ const start = text === range.startContainer ? range.startOffset : 0;
1102
+ const end = text === range.endContainer ? range.endOffset : text.data.length;
1103
+ if (start < end)
1104
+ segments.push({ node: text, start, end });
1105
+ return segments;
1217
1106
  }
1218
- for (const el of toRemove)
1219
- el.remove();
1220
- }
1221
- cleanupEmptyUnderlineSpans(root) {
1222
- const toRemove = [];
1223
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1107
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1224
1108
  while (walker.nextNode()) {
1225
- const el = walker.currentNode;
1226
- const isU = (el.tagName === 'U' || (el.tagName === 'SPAN' && typeof el.style.textDecoration === 'string' && el.style.textDecoration.includes('underline')));
1227
- if (!isU)
1109
+ const text = walker.currentNode;
1110
+ if (!text.data)
1228
1111
  continue;
1229
- const text = el.textContent ?? '';
1230
- const normalized = text.replace(/[\u00A0\u200B\s]/g, '');
1231
- const onlyWhitespace = !el.firstChild || (normalized.length === 0 && el.querySelectorAll('*').length === 0);
1232
- if (onlyWhitespace)
1233
- toRemove.push(el);
1112
+ if (!this.intersectsRange(range, text))
1113
+ continue;
1114
+ const start = text === range.startContainer ? range.startOffset : 0;
1115
+ const end = text === range.endContainer ? range.endOffset : text.data.length;
1116
+ if (start >= end)
1117
+ continue;
1118
+ segments.push({ node: text, start, end });
1234
1119
  }
1235
- for (const el of toRemove)
1236
- el.remove();
1120
+ return segments;
1237
1121
  }
1238
- // Remove bold wrappers and inline font-weight style inside a container
1239
- stripBoldWithin(container) {
1240
- const toProcess = [];
1241
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
1242
- while (walker.nextNode()) {
1243
- toProcess.push(walker.currentNode);
1122
+ intersectsRange(range, node) {
1123
+ try {
1124
+ return range.intersectsNode ? range.intersectsNode(node) : true;
1244
1125
  }
1245
- for (const el of toProcess) {
1246
- if (el.tagName === 'B' || el.tagName === 'STRONG') {
1247
- while (el.firstChild)
1248
- el.parentNode?.insertBefore(el.firstChild, el);
1249
- el.remove();
1250
- continue;
1251
- }
1252
- if (el.tagName === 'SPAN') {
1253
- if (el.style.fontWeight)
1254
- el.style.removeProperty('font-weight');
1255
- // Clean empty style spans
1256
- if (!el.getAttribute('style')) {
1257
- while (el.firstChild)
1258
- el.parentNode?.insertBefore(el.firstChild, el);
1259
- el.remove();
1260
- }
1261
- }
1126
+ catch {
1127
+ return false;
1262
1128
  }
1263
1129
  }
1264
- // Pull the given node out of any bold carrier ancestors by splitting them around the node
1265
- liftOutOfBoldAncestors(node) {
1266
- const editor = this.editorRef.nativeElement;
1267
- let currentBold = this.findClosest(node, 'b,strong,span[style*="font-weight"]');
1268
- while (currentBold && currentBold !== editor) {
1269
- this.splitOutOfAncestor(currentBold, node);
1270
- currentBold = this.findClosest(node, 'b,strong,span[style*="font-weight"]');
1271
- }
1272
- }
1273
- // Split arbitrary ancestor element so that `node` becomes a sibling outside of it, preserving order even if `node` is nested deeply
1274
- splitOutOfAncestor(ancestor, node) {
1275
- // First, climb from node up to direct child of ancestor, splitting wrappers after `node` at each level
1276
- let child = node;
1277
- let parent = child.parentElement;
1278
- while (parent && parent !== ancestor) {
1279
- const right = parent.cloneNode(false);
1280
- // move siblings after `child` into right clone
1281
- while (child.nextSibling)
1282
- right.appendChild(child.nextSibling);
1283
- // insert right after parent
1284
- parent.after(right);
1285
- // ascend
1286
- child = parent;
1287
- parent = child.parentElement;
1130
+ applyStylesToSegment(segment, entries) {
1131
+ const segRange = document.createRange();
1132
+ segRange.setStart(segment.node, segment.start);
1133
+ segRange.setEnd(segment.node, segment.end);
1134
+ const wrapper = document.createElement('span');
1135
+ for (const [prop, value] of entries) {
1136
+ wrapper.style.setProperty(this.toCssProperty(prop), value);
1288
1137
  }
1289
- if (!parent)
1290
- return;
1291
- // Now `child` is a direct child of ancestor
1292
- const before = ancestor.cloneNode(false);
1293
- while (ancestor.firstChild && ancestor.firstChild !== child) {
1294
- before.appendChild(ancestor.firstChild);
1295
- }
1296
- const after = ancestor.cloneNode(false);
1297
- while (child.nextSibling)
1298
- after.appendChild(child.nextSibling);
1299
- const gp = ancestor.parentNode;
1300
- if (!gp)
1301
- return;
1302
- if (before.childNodes.length)
1303
- gp.insertBefore(before, ancestor);
1304
- // move node out, place where ancestor was
1305
- gp.insertBefore(node, ancestor);
1306
- if (after.childNodes.length)
1307
- gp.insertBefore(after, ancestor);
1308
- ancestor.remove();
1309
- }
1310
- cleanupEmptyBoldSpans(root) {
1311
- const toRemove = [];
1312
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1313
- while (walker.nextNode()) {
1314
- const el = walker.currentNode;
1315
- const isBoldEl = (el.tagName === 'B' || el.tagName === 'STRONG' || (el.tagName === 'SPAN' && el.style.fontWeight && el.style.fontWeight !== 'normal' && el.style.fontWeight !== '400'));
1316
- if (!isBoldEl)
1317
- continue;
1318
- // remove if no children or only whitespace (including NBSP \u00A0 and zero-width space \u200B)
1319
- const text = el.textContent ?? '';
1320
- const normalized = text.replace(/[\u00A0\u200B\s]/g, '');
1321
- const onlyWhitespace = !el.firstChild || (normalized.length === 0 && el.querySelectorAll('*').length === 0);
1322
- if (onlyWhitespace) {
1323
- toRemove.push(el);
1324
- }
1138
+ try {
1139
+ segRange.surroundContents(wrapper);
1140
+ return this.mergeAdjacentStyledSpans(wrapper);
1325
1141
  }
1326
- for (const el of toRemove)
1327
- el.remove();
1142
+ catch (err) {
1143
+ console.error('[RTE] applyStylesToSegment: failed to surround contents', {
1144
+ error: err,
1145
+ segment,
1146
+ entries
1147
+ });
1148
+ }
1149
+ return null;
1328
1150
  }
1329
- maybeUnwrapNormalSpan(span) {
1151
+ toCssProperty(prop) {
1152
+ return prop.replace(/([A-Z])/g, '-$1').toLowerCase();
1153
+ }
1154
+ mergeAdjacentStyledSpans(span) {
1330
1155
  if (span.tagName !== 'SPAN')
1331
- return;
1332
- // If no bold applies from ancestors, drop the 400 style and unwrap if style empty
1333
- const hasBoldAbove = this.isNodeBold(span.parentElement);
1334
- if (!hasBoldAbove) {
1335
- if (span.style.fontWeight === '400')
1336
- span.style.removeProperty('font-weight');
1337
- if (!span.getAttribute('style')) {
1338
- while (span.firstChild)
1339
- span.parentNode?.insertBefore(span.firstChild, span);
1340
- span.remove();
1341
- }
1156
+ return span;
1157
+ let current = span;
1158
+ const normalize = (el) => el && el.tagName === 'SPAN' ? el : null;
1159
+ const styleText = (el) => (el ? el.getAttribute('style') || '' : '');
1160
+ // Merge with parent if identical
1161
+ let parent = normalize(current.parentElement);
1162
+ if (parent && styleText(parent) === styleText(current) && parent.attributes.length === current.attributes.length) {
1163
+ while (current.firstChild)
1164
+ parent.insertBefore(current.firstChild, current);
1165
+ current.remove();
1166
+ current = parent;
1167
+ }
1168
+ // Merge with previous sibling ignoring empty text nodes
1169
+ let prev = current.previousSibling;
1170
+ while (prev && prev.nodeType === Node.TEXT_NODE && !prev.data.trim())
1171
+ prev = prev.previousSibling;
1172
+ if (prev instanceof HTMLElement && prev.tagName === 'SPAN' && styleText(prev) === styleText(current) && prev.attributes.length === current.attributes.length) {
1173
+ while (current.firstChild)
1174
+ prev.appendChild(current.firstChild);
1175
+ current.remove();
1176
+ current = prev;
1177
+ }
1178
+ // Merge with next sibling ignoring empty text nodes
1179
+ let next = current.nextSibling;
1180
+ while (next && next.nodeType === Node.TEXT_NODE && !next.data.trim())
1181
+ next = next.nextSibling;
1182
+ if (next instanceof HTMLElement && next.tagName === 'SPAN' && styleText(next) === styleText(current) && next.attributes.length === current.attributes.length) {
1183
+ while (next.firstChild)
1184
+ current.appendChild(next.firstChild);
1185
+ next.remove();
1342
1186
  }
1187
+ return current;
1188
+ }
1189
+ saveSelectionFromRange(range) {
1190
+ this.savedRange = range.cloneRange();
1343
1191
  }
1344
1192
  wrapSelectionWith(tag, attrs) {
1345
1193
  const sel = window.getSelection();
@@ -1409,56 +1257,13 @@ class StackchRichtextEditor {
1409
1257
  catch { }
1410
1258
  }
1411
1259
  computeBoldAnyForRange(range) {
1412
- const editor = this.editorRef.nativeElement;
1413
- const root = this.editorRef.nativeElement;
1414
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1415
- while (walker.nextNode()) {
1416
- const n = walker.currentNode;
1417
- if (!n.data.trim())
1418
- continue;
1419
- try {
1420
- if (range.intersectsNode(n)) {
1421
- if (this.isNodeBold(n.parentElement))
1422
- return true;
1423
- }
1424
- }
1425
- catch { }
1426
- }
1427
- return false;
1260
+ return this.selectionHasStyle(range, 'font-weight');
1428
1261
  }
1429
1262
  computeItalicAnyForRange(range) {
1430
- const root = this.editorRef.nativeElement;
1431
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1432
- while (walker.nextNode()) {
1433
- const n = walker.currentNode;
1434
- if (!n.data.trim())
1435
- continue;
1436
- try {
1437
- if (range.intersectsNode(n)) {
1438
- if (this.isNodeItalic(n.parentElement))
1439
- return true;
1440
- }
1441
- }
1442
- catch { }
1443
- }
1444
- return false;
1263
+ return this.selectionHasStyle(range, 'font-style');
1445
1264
  }
1446
1265
  computeUnderlineAnyForRange(range) {
1447
- const root = this.editorRef.nativeElement;
1448
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1449
- while (walker.nextNode()) {
1450
- const n = walker.currentNode;
1451
- if (!n.data.trim())
1452
- continue;
1453
- try {
1454
- if (range.intersectsNode(n)) {
1455
- if (this.isNodeUnderline(n.parentElement))
1456
- return true;
1457
- }
1458
- }
1459
- catch { }
1460
- }
1461
- return false;
1266
+ return this.selectionHasStyle(range, 'text-decoration');
1462
1267
  }
1463
1268
  isNodeBold(node) {
1464
1269
  let el = node;
@@ -1499,120 +1304,249 @@ class StackchRichtextEditor {
1499
1304
  }
1500
1305
  return false;
1501
1306
  }
1502
- isItalicCarrier(el) {
1503
- if (!el)
1504
- return false;
1505
- const tag = el.tagName;
1506
- if (tag === 'I' || tag === 'EM')
1507
- return true;
1508
- const fs = el.style?.fontStyle || '';
1509
- return !!fs && fs !== 'normal';
1510
- }
1511
- isUnderlineCarrier(el) {
1512
- if (!el)
1513
- return false;
1514
- const tag = el.tagName;
1515
- if (tag === 'U')
1516
- return true;
1517
- const td = el.style?.textDecoration || '';
1518
- return typeof td === 'string' && td.includes('underline');
1519
- }
1520
- computeBoldActiveForRange(range) {
1307
+ removeInlineStyleInRange(range, cssPropKebab, options) {
1308
+ const shouldRestoreSelection = options?.restoreSelection !== false;
1309
+ this.isolateRangeFromStyleCarriers(range, cssPropKebab);
1521
1310
  const editor = this.editorRef.nativeElement;
1522
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1523
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1524
- const nodes = [];
1525
- while (walker.nextNode()) {
1526
- const n = walker.currentNode;
1527
- if (!n.data.trim())
1528
- continue;
1529
- try {
1530
- if (range.intersectsNode(n))
1531
- nodes.push(n);
1532
- }
1533
- catch { /* Safari may throw if node not in same tree */ }
1534
- }
1535
- if (nodes.length === 0)
1536
- return false;
1537
- return nodes.every(t => this.isNodeBold(t.parentElement));
1538
- }
1539
- computeItalicActiveForRange(range) {
1540
- const editor = this.editorRef.nativeElement;
1541
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1542
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1543
- const nodes = [];
1544
- while (walker.nextNode()) {
1545
- const n = walker.currentNode;
1546
- if (!n.data.trim())
1547
- continue;
1311
+ const selAfter = window.getSelection();
1312
+ const effectiveRange = selAfter && selAfter.rangeCount > 0 ? selAfter.getRangeAt(0) : range;
1313
+ const common = effectiveRange.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1314
+ ? effectiveRange.commonAncestorContainer
1315
+ : (effectiveRange.commonAncestorContainer.parentElement || editor);
1316
+ const affected = [];
1317
+ const intersects = (node) => {
1548
1318
  try {
1549
- if (range.intersectsNode(n))
1550
- nodes.push(n);
1319
+ return effectiveRange.intersectsNode ? effectiveRange.intersectsNode(node) : true;
1551
1320
  }
1552
- catch { }
1553
- }
1554
- if (nodes.length === 0)
1555
- return false;
1556
- return nodes.every(t => this.isNodeItalic(t.parentElement));
1557
- }
1558
- computeUnderlineActiveForRange(range) {
1559
- const editor = this.editorRef.nativeElement;
1560
- const root = this.isRangeInEditor(range) ? this.editorRef.nativeElement : range.commonAncestorContainer;
1561
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1562
- const nodes = [];
1563
- while (walker.nextNode()) {
1564
- const n = walker.currentNode;
1565
- if (!n.data.trim())
1566
- continue;
1567
- try {
1568
- if (range.intersectsNode(n))
1569
- nodes.push(n);
1321
+ catch {
1322
+ return false;
1570
1323
  }
1571
- catch { }
1324
+ };
1325
+ const matchesEl = (el) => this.isStyleCarrier(el, cssPropKebab);
1326
+ console.log('[RTE] removeInlineStyleInRange:start', {
1327
+ cssPropKebab,
1328
+ rangeCollapsed: effectiveRange.collapsed,
1329
+ commonTag: common.tagName,
1330
+ commonHasStyle: common.style ? common.style.getPropertyValue?.(cssPropKebab) : undefined
1331
+ });
1332
+ // Include the common element itself if it matches and intersects (TreeWalker won't visit the root)
1333
+ if (common instanceof HTMLElement && matchesEl(common) && intersects(common)) {
1334
+ affected.push(common);
1572
1335
  }
1573
- if (nodes.length === 0)
1574
- return false;
1575
- return nodes.every(t => this.isNodeUnderline(t.parentElement));
1576
- }
1577
- removeInlineStyleInRange(range, cssPropKebab) {
1578
- const editor = this.editorRef.nativeElement;
1579
- const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement || editor;
1580
- const affected = [];
1581
1336
  const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1582
1337
  while (walker.nextNode()) {
1583
1338
  const el = walker.currentNode;
1584
- let matches = false;
1585
- if (el.tagName === 'SPAN' && el.style && el.style.getPropertyValue(cssPropKebab))
1586
- matches = true;
1587
- if (cssPropKebab === 'font-weight' && (el.tagName === 'B' || el.tagName === 'STRONG'))
1588
- matches = true;
1589
- if (cssPropKebab === 'font-style' && (el.tagName === 'I' || el.tagName === 'EM'))
1590
- matches = true;
1591
- if (cssPropKebab === 'text-decoration' && el.tagName === 'U')
1592
- matches = true;
1593
- if (!matches)
1339
+ if (!matchesEl(el))
1594
1340
  continue;
1595
- try {
1596
- if (range.intersectsNode ? range.intersectsNode(el) : true)
1597
- affected.push(el);
1341
+ if (intersects(el)) {
1342
+ affected.push(el);
1343
+ console.log('[RTE] removeInlineStyleInRange:match', el.tagName, el.getAttribute('style'));
1598
1344
  }
1599
- catch { }
1600
1345
  }
1346
+ let removedTags = 0;
1347
+ let removedProps = 0;
1601
1348
  for (const el of affected) {
1602
1349
  if (el.tagName === 'B' || el.tagName === 'STRONG' || el.tagName === 'I' || el.tagName === 'EM' || el.tagName === 'U') {
1350
+ console.log('[RTE] unwrap tag', el.tagName);
1603
1351
  while (el.firstChild)
1604
1352
  el.parentNode?.insertBefore(el.firstChild, el);
1605
1353
  el.remove();
1354
+ removedTags++;
1606
1355
  continue;
1607
1356
  }
1357
+ console.log('[RTE] remove style', cssPropKebab, 'from', el.tagName, el.getAttribute('style'));
1608
1358
  el.style.removeProperty(cssPropKebab);
1359
+ if (!el.style.length) {
1360
+ console.log('[RTE] removeInlineStyleInRange: style attribute now empty, removing attribute', el);
1361
+ el.removeAttribute('style');
1362
+ }
1363
+ removedProps++;
1609
1364
  if (!el.getAttribute('style')) {
1365
+ console.log('[RTE] unwrap empty styled span', el.tagName);
1610
1366
  while (el.firstChild)
1611
1367
  el.parentNode?.insertBefore(el.firstChild, el);
1612
1368
  el.remove();
1613
1369
  }
1614
1370
  }
1615
- this.emitValue();
1371
+ this.pruneDanglingStyleCarriers(common, cssPropKebab);
1372
+ console.log('[RTE] removeInlineStyleInRange:done', { affected: affected.length, removedTags, removedProps });
1373
+ let resultRange = null;
1374
+ if (!options?.suppressEmit) {
1375
+ this.emitValue();
1376
+ resultRange = null;
1377
+ }
1378
+ else {
1379
+ const sel = window.getSelection();
1380
+ if (sel && sel.rangeCount > 0) {
1381
+ resultRange = sel.getRangeAt(0).cloneRange();
1382
+ }
1383
+ else if (shouldRestoreSelection) {
1384
+ resultRange = effectiveRange.cloneRange();
1385
+ }
1386
+ if (shouldRestoreSelection && resultRange) {
1387
+ const selRestore = window.getSelection();
1388
+ if (selRestore) {
1389
+ selRestore.removeAllRanges();
1390
+ selRestore.addRange(resultRange);
1391
+ this.saveSelectionFromRange(resultRange);
1392
+ }
1393
+ }
1394
+ }
1395
+ return resultRange;
1396
+ }
1397
+ pruneDanglingStyleCarriers(root, cssPropKebab) {
1398
+ const toRemove = [];
1399
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1400
+ if (root instanceof HTMLElement && this.isStyleCarrier(root, cssPropKebab) && !this.elementHasVisibleContent(root)) {
1401
+ toRemove.push(root);
1402
+ }
1403
+ while (walker.nextNode()) {
1404
+ const el = walker.currentNode;
1405
+ if (!this.isStyleCarrier(el, cssPropKebab))
1406
+ continue;
1407
+ if (this.elementHasVisibleContent(el))
1408
+ continue;
1409
+ toRemove.push(el);
1410
+ }
1411
+ for (const el of toRemove) {
1412
+ console.log('[RTE] pruneDanglingStyleCarriers: removing empty carrier', { tag: el.tagName, style: el.getAttribute('style') });
1413
+ if (el.tagName === 'B' || el.tagName === 'STRONG' || el.tagName === 'I' || el.tagName === 'EM' || el.tagName === 'U') {
1414
+ el.remove();
1415
+ continue;
1416
+ }
1417
+ el.remove();
1418
+ }
1419
+ }
1420
+ elementHasVisibleContent(node) {
1421
+ if (node.nodeType === Node.TEXT_NODE) {
1422
+ const text = node.data;
1423
+ for (let i = 0; i < text.length; i++) {
1424
+ const ch = text[i];
1425
+ if (ch === '\u00a0')
1426
+ return true;
1427
+ if (!/\s/.test(ch) && ch !== '\u200b' && ch !== '\u200c' && ch !== '\u200d' && ch !== '\ufeff') {
1428
+ return true;
1429
+ }
1430
+ }
1431
+ return false;
1432
+ }
1433
+ if (node.nodeType === Node.ELEMENT_NODE) {
1434
+ const el = node;
1435
+ if (el.tagName === 'BR')
1436
+ return true;
1437
+ for (const child of Array.from(node.childNodes)) {
1438
+ if (this.elementHasVisibleContent(child))
1439
+ return true;
1440
+ }
1441
+ }
1442
+ return false;
1443
+ }
1444
+ isolateRangeFromStyleCarriers(range, cssPropKebab) {
1445
+ let carrier = this.findCarrierContainingRange(range, cssPropKebab);
1446
+ while (carrier) {
1447
+ this.splitCarrierAroundSelection(range, carrier);
1448
+ carrier = this.findCarrierContainingRange(range, cssPropKebab);
1449
+ }
1450
+ }
1451
+ selectionHasStyle(range, cssPropKebab) {
1452
+ const editor = this.editorRef.nativeElement;
1453
+ const common = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1454
+ ? range.commonAncestorContainer
1455
+ : (range.commonAncestorContainer.parentElement || editor);
1456
+ const intersects = (node) => this.intersectsRange(range, node);
1457
+ if (common instanceof HTMLElement && this.isStyleCarrier(common, cssPropKebab) && intersects(common)) {
1458
+ return true;
1459
+ }
1460
+ const walker = document.createTreeWalker(common, NodeFilter.SHOW_ELEMENT);
1461
+ while (walker.nextNode()) {
1462
+ const el = walker.currentNode;
1463
+ if (!this.isStyleCarrier(el, cssPropKebab))
1464
+ continue;
1465
+ if (intersects(el))
1466
+ return true;
1467
+ }
1468
+ return false;
1469
+ }
1470
+ findCarrierContainingRange(range, cssPropKebab) {
1471
+ let el = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
1472
+ ? range.commonAncestorContainer
1473
+ : range.commonAncestorContainer.parentElement;
1474
+ const editor = this.editorRef.nativeElement;
1475
+ while (el && el !== editor) {
1476
+ if (this.isStyleCarrier(el, cssPropKebab)) {
1477
+ const sc = range.startContainer;
1478
+ const ec = range.endContainer;
1479
+ if (el.contains(sc) && el.contains(ec))
1480
+ return el;
1481
+ }
1482
+ el = el.parentElement;
1483
+ }
1484
+ return null;
1485
+ }
1486
+ isStyleCarrier(el, cssPropKebab) {
1487
+ const tag = el.tagName;
1488
+ if (cssPropKebab === 'font-weight') {
1489
+ if (tag === 'B' || tag === 'STRONG')
1490
+ return true;
1491
+ const fw = el.style?.fontWeight || '';
1492
+ return !!fw && fw !== 'normal' && fw !== '400';
1493
+ }
1494
+ if (cssPropKebab === 'font-style') {
1495
+ if (tag === 'I' || tag === 'EM')
1496
+ return true;
1497
+ const fs = el.style?.fontStyle || '';
1498
+ return !!fs && fs !== 'normal';
1499
+ }
1500
+ if (cssPropKebab === 'text-decoration') {
1501
+ if (tag === 'U')
1502
+ return true;
1503
+ const td = el.style?.textDecoration || el.style?.textDecorationLine || '';
1504
+ return typeof td === 'string' && td.includes('underline');
1505
+ }
1506
+ const inline = el.style?.getPropertyValue(cssPropKebab) || '';
1507
+ if (!inline)
1508
+ return false;
1509
+ if (cssPropKebab === 'color') {
1510
+ return inline !== 'inherit' && inline !== 'initial';
1511
+ }
1512
+ if (cssPropKebab === 'background-color') {
1513
+ return inline !== 'transparent' && inline !== 'initial';
1514
+ }
1515
+ return true;
1516
+ }
1517
+ splitCarrierAroundSelection(range, carrier) {
1518
+ const parent = carrier.parentNode;
1519
+ if (!parent)
1520
+ return;
1521
+ const rightRange = document.createRange();
1522
+ rightRange.setStart(range.endContainer, range.endOffset);
1523
+ try {
1524
+ rightRange.setEnd(carrier, carrier.childNodes.length);
1525
+ }
1526
+ catch { }
1527
+ const rightFrag = rightRange.extractContents();
1528
+ const leftRange = document.createRange();
1529
+ leftRange.setStart(carrier, 0);
1530
+ try {
1531
+ leftRange.setEnd(range.startContainer, range.startOffset);
1532
+ }
1533
+ catch { }
1534
+ const leftFrag = leftRange.extractContents();
1535
+ if (leftFrag.childNodes.length) {
1536
+ const leftClone = carrier.cloneNode(false);
1537
+ leftClone.appendChild(leftFrag);
1538
+ parent.insertBefore(leftClone, carrier);
1539
+ }
1540
+ let insertAfter = carrier;
1541
+ if (rightFrag.childNodes.length) {
1542
+ const rightClone = carrier.cloneNode(false);
1543
+ rightClone.appendChild(rightFrag);
1544
+ parent.insertBefore(rightClone, carrier.nextSibling);
1545
+ insertAfter = rightClone;
1546
+ }
1547
+ while (carrier.firstChild)
1548
+ parent.insertBefore(carrier.firstChild, insertAfter);
1549
+ carrier.remove();
1616
1550
  }
1617
1551
  toggleBold() {
1618
1552
  this.focusEditor();
@@ -1624,45 +1558,12 @@ class StackchRichtextEditor {
1624
1558
  if (range.collapsed)
1625
1559
  this.expandRangeToWord(range);
1626
1560
  const anyActive = this.computeBoldAnyForRange(range);
1627
- const blocks = this.getIntersectingBlocks(range);
1628
- const multiBlock = blocks.length > 1;
1561
+ console.log('[RTE] toggleBold', { anyActive, rangeCollapsed: range.collapsed });
1629
1562
  if (anyActive) {
1630
- if (multiBlock) {
1631
- for (const b of blocks)
1632
- b.style.removeProperty('font-weight');
1633
- this.emitValue();
1634
- this.updateInlineStates();
1635
- this.takeSnapshot('toggle-bold-blocks');
1636
- return;
1637
- }
1638
- // Preferred: split bold ancestor around selection so we keep surrounding text intact
1639
- const changed = this.deselectBoldBySplitting(range);
1640
- if (changed) {
1641
- this.cleanupEmptyBoldSpans(this.editorRef.nativeElement);
1642
- this.emitValue();
1643
- }
1644
- else {
1645
- // Fallback: previous neutralization approach for complex cases
1646
- const normal = this.wrapSelectionInline('fontWeight', '400');
1647
- if (normal) {
1648
- this.stripBoldWithin(normal);
1649
- this.liftOutOfBoldAncestors(normal);
1650
- this.cleanupEmptyBoldSpans(this.editorRef.nativeElement);
1651
- this.maybeUnwrapNormalSpan(normal);
1652
- this.emitValue();
1653
- }
1654
- }
1563
+ this.removeInlineStyleInRange(range, 'font-weight');
1655
1564
  }
1656
1565
  else {
1657
- if (multiBlock) {
1658
- for (const b of blocks)
1659
- b.style.fontWeight = 'bold';
1660
- this.emitValue();
1661
- this.updateInlineStates();
1662
- this.takeSnapshot('toggle-bold-blocks');
1663
- return;
1664
- }
1665
- this.applyInlineStyleSmart('fontWeight', 'bold');
1566
+ this.applySelectionStyles({ fontWeight: 'bold' });
1666
1567
  }
1667
1568
  this.updateInlineStates();
1668
1569
  this.takeSnapshot('toggle-bold');
@@ -1678,18 +1579,10 @@ class StackchRichtextEditor {
1678
1579
  this.expandRangeToWord(range);
1679
1580
  const anyActive = this.computeItalicAnyForRange(range);
1680
1581
  if (anyActive) {
1681
- const changed = this.deselectItalicBySplitting(range);
1682
- if (changed) {
1683
- this.cleanupEmptyItalicSpans(this.editorRef.nativeElement);
1684
- this.emitValue();
1685
- }
1686
- else {
1687
- this.removeInlineStyleInRange(range, 'font-style');
1688
- this.applyInlineStyle('fontStyle', 'normal');
1689
- }
1582
+ this.removeInlineStyleInRange(range, 'font-style');
1690
1583
  }
1691
1584
  else {
1692
- this.applyInlineStyleSmart('fontStyle', 'italic');
1585
+ this.applySelectionStyles({ fontStyle: 'italic' });
1693
1586
  }
1694
1587
  this.updateInlineStates();
1695
1588
  this.takeSnapshot('toggle-italic');
@@ -1705,18 +1598,10 @@ class StackchRichtextEditor {
1705
1598
  this.expandRangeToWord(range);
1706
1599
  const anyActive = this.computeUnderlineAnyForRange(range);
1707
1600
  if (anyActive) {
1708
- const changed = this.deselectUnderlineBySplitting(range);
1709
- if (changed) {
1710
- this.cleanupEmptyUnderlineSpans(this.editorRef.nativeElement);
1711
- this.emitValue();
1712
- }
1713
- else {
1714
- this.removeInlineStyleInRange(range, 'text-decoration');
1715
- this.applyInlineStyle('textDecoration', 'none');
1716
- }
1601
+ this.removeInlineStyleInRange(range, 'text-decoration');
1717
1602
  }
1718
1603
  else {
1719
- this.applyInlineStyleSmart('textDecoration', 'underline');
1604
+ this.applySelectionStyles({ textDecoration: 'underline' });
1720
1605
  }
1721
1606
  this.updateInlineStates();
1722
1607
  this.takeSnapshot('toggle-underline');
@@ -2062,7 +1947,7 @@ class StackchRichtextEditor {
2062
1947
  }
2063
1948
  }
2064
1949
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.4", ngImport: i0, type: StackchRichtextEditor, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
2065
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: StackchRichtextEditor, isStandalone: true, selector: "stackch-richtext-editor", 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: [
1950
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.4", type: StackchRichtextEditor, isStandalone: true, selector: "stackch-richtext-editor", 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: [
2066
1951
  {
2067
1952
  provide: NG_VALUE_ACCESSOR,
2068
1953
  useExisting: forwardRef(() => StackchRichtextEditor),
@@ -2097,6 +1982,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.4", ngImpor
2097
1982
  type: Input
2098
1983
  }], valueChange: [{
2099
1984
  type: Output
1985
+ }], metricsChange: [{
1986
+ type: Output
2100
1987
  }], editorRef: [{
2101
1988
  type: ViewChild,
2102
1989
  args: ['editor', { static: true }]