@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.
- package/README.md +0 -31
- package/fesm2022/stackch-angular-richtext-editor.mjs +412 -525
- package/fesm2022/stackch-angular-richtext-editor.mjs.map +1 -1
- package/index.d.ts +22 -22
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
721
|
-
this.
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
987
|
-
this.
|
|
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
|
-
|
|
1016
|
-
|
|
1029
|
+
}
|
|
1030
|
+
let range = sel.getRangeAt(0);
|
|
1031
|
+
if (range.collapsed) {
|
|
1032
|
+
console.warn('[RTE] applySelectionStyles: range is collapsed', { styles });
|
|
1017
1033
|
return;
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1091
|
+
this.saveSelectionFromRange(newRange);
|
|
1180
1092
|
}
|
|
1093
|
+
this.emitValue();
|
|
1094
|
+
this.takeSnapshot('style');
|
|
1181
1095
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
if (
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
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
|
|
1226
|
-
|
|
1227
|
-
if (!isU)
|
|
1109
|
+
const text = walker.currentNode;
|
|
1110
|
+
if (!text.data)
|
|
1228
1111
|
continue;
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1236
|
-
el.remove();
|
|
1120
|
+
return segments;
|
|
1237
1121
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
|
1523
|
-
const
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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.
|
|
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
|
-
|
|
1628
|
-
const multiBlock = blocks.length > 1;
|
|
1561
|
+
console.log('[RTE] toggleBold', { anyActive, rangeCollapsed: range.collapsed });
|
|
1629
1562
|
if (anyActive) {
|
|
1630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 }]
|