Skip to content

Commit e796670

Browse files
authored
fix(custom-class): preserve HTML structure in multiline selections (#594) (#595)
- Add 'mode' option to CustomClass: 'inline', 'block', 'auto' (default) - Implement smart block detection using DOM Range API and TreeWalker - Use extractContents + insertNode pattern instead of sel.toString() - Add toggle behavior for block mode (remove class if already present) - Add comprehensive unit tests for createCustomClass (6 new tests) Fixes #594
1 parent 35c22b7 commit e796670

3 files changed

Lines changed: 357 additions & 7 deletions

File tree

projects/angular-editor/src/lib/angular-editor.service.spec.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,146 @@ import {inject, TestBed} from '@angular/core/testing';
22

33
import {AngularEditorService} from './angular-editor.service';
44
import {HttpClientModule} from '@angular/common/http';
5+
import {CustomClass} from './config';
56

67
describe('AngularEditorService', () => {
8+
let service: AngularEditorService;
9+
let testContainer: HTMLDivElement;
10+
711
beforeEach(() => {
812
TestBed.configureTestingModule({
913
imports: [ HttpClientModule ],
1014
providers: [AngularEditorService]
1115
});
16+
service = TestBed.inject(AngularEditorService);
17+
18+
// Create a test container for DOM manipulation tests
19+
testContainer = document.createElement('div');
20+
testContainer.id = 'test-editor';
21+
testContainer.contentEditable = 'true';
22+
document.body.appendChild(testContainer);
1223
});
1324

14-
it('should be created', inject([AngularEditorService], (service: AngularEditorService) => {
15-
expect(service).toBeTruthy();
25+
afterEach(() => {
26+
// Clean up test container
27+
if (testContainer && testContainer.parentNode) {
28+
testContainer.parentNode.removeChild(testContainer);
29+
}
30+
});
31+
32+
it('should be created', inject([AngularEditorService], (svc: AngularEditorService) => {
33+
expect(svc).toBeTruthy();
1634
}));
35+
36+
describe('createCustomClass', () => {
37+
it('should not apply class when no selection', () => {
38+
const customClass: CustomClass = { name: 'Test', class: 'test-class' };
39+
service.savedSelection = null;
40+
41+
// Should not throw
42+
expect(() => service.createCustomClass(customClass)).not.toThrow();
43+
});
44+
45+
it('should not apply class when customClass is null', () => {
46+
const range = document.createRange();
47+
service.savedSelection = range;
48+
49+
expect(() => service.createCustomClass(null as any)).not.toThrow();
50+
});
51+
52+
it('should apply inline class to single text selection', () => {
53+
// Setup: single text node
54+
testContainer.innerHTML = '<p>Hello World</p>';
55+
const textNode = testContainer.querySelector('p')!.firstChild!;
56+
57+
// Create selection range for "World"
58+
const range = document.createRange();
59+
range.setStart(textNode, 6);
60+
range.setEnd(textNode, 11);
61+
62+
// Set selection
63+
const sel = window.getSelection()!;
64+
sel.removeAllRanges();
65+
sel.addRange(range);
66+
67+
service.savedSelection = range;
68+
service.selectedText = 'World';
69+
70+
const customClass: CustomClass = { name: 'Test', class: 'highlight', mode: 'inline' };
71+
service.createCustomClass(customClass);
72+
73+
// Verify span was created with class
74+
const span = testContainer.querySelector('span.highlight');
75+
expect(span).toBeTruthy();
76+
expect(span!.textContent).toBe('World');
77+
});
78+
79+
it('should apply block class to multiple paragraphs', () => {
80+
// Setup: multiple paragraphs
81+
testContainer.innerHTML = '<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>';
82+
const paragraphs = testContainer.querySelectorAll('p');
83+
84+
// Create selection range spanning all paragraphs
85+
const range = document.createRange();
86+
range.setStart(paragraphs[0].firstChild!, 0);
87+
range.setEnd(paragraphs[2].firstChild!, paragraphs[2].textContent!.length);
88+
89+
// Set selection
90+
const sel = window.getSelection()!;
91+
sel.removeAllRanges();
92+
sel.addRange(range);
93+
94+
service.savedSelection = range;
95+
service.selectedText = 'First paragraph\nSecond paragraph\nThird paragraph';
96+
97+
const customClass: CustomClass = { name: 'Test', class: 'block-highlight', mode: 'block' };
98+
service.createCustomClass(customClass);
99+
100+
// Verify class was applied to paragraphs
101+
const highlightedParagraphs = testContainer.querySelectorAll('p.block-highlight');
102+
expect(highlightedParagraphs.length).toBeGreaterThan(0);
103+
});
104+
105+
it('should use auto mode by default', () => {
106+
testContainer.innerHTML = '<p>Single paragraph</p>';
107+
const p = testContainer.querySelector('p')!;
108+
const textNode = p.firstChild!;
109+
110+
const range = document.createRange();
111+
range.setStart(textNode, 0);
112+
range.setEnd(textNode, textNode.textContent!.length);
113+
114+
const sel = window.getSelection()!;
115+
sel.removeAllRanges();
116+
sel.addRange(range);
117+
118+
service.savedSelection = range;
119+
service.selectedText = 'Single paragraph';
120+
121+
// No mode specified - should default to 'auto'
122+
const customClass: CustomClass = { name: 'Test', class: 'auto-class' };
123+
service.createCustomClass(customClass);
124+
125+
// For single block, auto mode should use inline wrapping
126+
const result = testContainer.querySelector('.auto-class');
127+
expect(result).toBeTruthy();
128+
});
129+
130+
it('should toggle class when applied twice in block mode', () => {
131+
testContainer.innerHTML = '<p class="toggle-class">Paragraph with class</p>';
132+
const p = testContainer.querySelector('p')!;
133+
const textNode = p.firstChild!;
134+
135+
const range = document.createRange();
136+
range.selectNodeContents(p);
137+
138+
service.savedSelection = range;
139+
140+
const customClass: CustomClass = { name: 'Test', class: 'toggle-class', mode: 'block' };
141+
service.createCustomClass(customClass);
142+
143+
// Class should be removed (toggled off)
144+
expect(p.classList.contains('toggle-class')).toBeFalse();
145+
});
146+
});
17147
});

projects/angular-editor/src/lib/angular-editor.service.ts

Lines changed: 202 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,212 @@ export class AngularEditorService {
179179
this.doc.execCommand('defaultParagraphSeparator', false, separator);
180180
}
181181

182-
createCustomClass(customClass: CustomClass) {
183-
let newTag = this.selectedText;
184-
if (customClass) {
185-
const tagName = customClass.tag ? customClass.tag : 'span';
186-
newTag = '<' + tagName + ' class="' + customClass.class + '">' + this.selectedText + '</' + tagName + '>';
182+
/**
183+
* Apply custom class to selection with enterprise-level HTML structure preservation.
184+
* Supports three modes:
185+
* - 'inline': Wrap selection in a single element (legacy behavior)
186+
* - 'block': Apply class to each block element in selection
187+
* - 'auto': Smart detection based on selection span (default)
188+
*
189+
* @param customClass The custom class configuration
190+
*/
191+
createCustomClass(customClass: CustomClass): void {
192+
if (!customClass || !this.savedSelection) {
193+
return;
194+
}
195+
196+
const mode = customClass.mode || 'auto';
197+
const range = this.savedSelection;
198+
199+
// Restore selection before applying
200+
this.restoreSelection();
201+
202+
if (mode === 'inline') {
203+
this.applyClassInline(range, customClass);
204+
} else if (mode === 'block') {
205+
this.applyClassToBlocks(range, customClass);
206+
} else {
207+
// Auto mode: detect if selection spans multiple blocks
208+
const blocks = this.getBlockElementsInRange(range);
209+
if (blocks.length > 1) {
210+
this.applyClassToBlocks(range, customClass);
211+
} else {
212+
this.applyClassInline(range, customClass);
213+
}
187214
}
215+
}
216+
217+
/**
218+
* Apply class inline by wrapping selection in a single element.
219+
* Uses extractContents + insertNode pattern for safety.
220+
*/
221+
private applyClassInline(range: Range, customClass: CustomClass): void {
222+
const tagName = customClass.tag || 'span';
223+
224+
try {
225+
// Create wrapper element
226+
const wrapper = this.doc.createElement(tagName);
227+
wrapper.className = customClass.class;
228+
229+
// Extract contents and wrap (safer than surroundContents)
230+
const contents = range.extractContents();
231+
wrapper.appendChild(contents);
232+
range.insertNode(wrapper);
233+
234+
// Normalize to merge adjacent text nodes
235+
if (wrapper.parentNode) {
236+
wrapper.parentNode.normalize();
237+
}
238+
239+
// Update selection to the new wrapper
240+
range.selectNodeContents(wrapper);
241+
const sel = this.doc.getSelection();
242+
sel.removeAllRanges();
243+
sel.addRange(range);
244+
} catch (e) {
245+
// Fallback to legacy method if DOM manipulation fails
246+
console.warn('applyClassInline failed, using fallback:', e);
247+
this.applyClassInlineFallback(customClass);
248+
}
249+
}
250+
251+
/**
252+
* Fallback method for inline class application (legacy behavior).
253+
*/
254+
private applyClassInlineFallback(customClass: CustomClass): void {
255+
const tagName = customClass.tag || 'span';
256+
const newTag = '<' + tagName + ' class="' + customClass.class + '">' + this.selectedText + '</' + tagName + '>';
188257
this.insertHtml(newTag);
189258
}
190259

260+
/**
261+
* Apply class to each block element in selection.
262+
* Preserves HTML structure by adding class to existing elements.
263+
*/
264+
private applyClassToBlocks(range: Range, customClass: CustomClass): void {
265+
const blocks = this.getBlockElementsInRange(range);
266+
267+
if (blocks.length === 0) {
268+
// No blocks found, fall back to inline
269+
this.applyClassInline(range, customClass);
270+
return;
271+
}
272+
273+
// Apply class to each block element
274+
blocks.forEach(block => {
275+
// Toggle class: remove if present, add if not
276+
if (block.classList.contains(customClass.class)) {
277+
block.classList.remove(customClass.class);
278+
} else {
279+
block.classList.add(customClass.class);
280+
}
281+
});
282+
283+
// Trigger change detection by moving cursor
284+
const sel = this.doc.getSelection();
285+
if (sel && blocks.length > 0) {
286+
// Reselect the original range
287+
sel.removeAllRanges();
288+
sel.addRange(range);
289+
}
290+
}
291+
292+
/**
293+
* Get all block-level elements within a range.
294+
* Returns elements that are fully or partially selected.
295+
*/
296+
private getBlockElementsInRange(range: Range): HTMLElement[] {
297+
const blockTags = [
298+
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
299+
'LI', 'BLOCKQUOTE', 'PRE', 'ADDRESS', 'ARTICLE',
300+
'ASIDE', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'HEADER',
301+
'MAIN', 'NAV', 'SECTION'
302+
];
303+
const blocks: HTMLElement[] = [];
304+
const seen = new Set<HTMLElement>();
305+
306+
// Get the common ancestor
307+
const container = range.commonAncestorContainer;
308+
const root = container.nodeType === Node.ELEMENT_NODE
309+
? container as HTMLElement
310+
: container.parentElement;
311+
312+
if (!root) {
313+
return blocks;
314+
}
315+
316+
// If root itself is a block element and fully contains the range
317+
if (blockTags.includes(root.tagName) && this.isNodeFullyInRange(root, range)) {
318+
return [root];
319+
}
320+
321+
// Find all block elements within the range using TreeWalker
322+
const walker = this.doc.createTreeWalker(
323+
root,
324+
NodeFilter.SHOW_ELEMENT,
325+
{
326+
acceptNode: (node: Element) => {
327+
if (!blockTags.includes(node.tagName)) {
328+
return NodeFilter.FILTER_SKIP;
329+
}
330+
// Check if node intersects with selection
331+
if (range.intersectsNode(node)) {
332+
return NodeFilter.FILTER_ACCEPT;
333+
}
334+
return NodeFilter.FILTER_SKIP;
335+
}
336+
}
337+
);
338+
339+
let node: Node | null;
340+
while ((node = walker.nextNode())) {
341+
const element = node as HTMLElement;
342+
// Avoid duplicates and nested blocks
343+
if (!seen.has(element) && !this.hasAncestorInSet(element, seen)) {
344+
seen.add(element);
345+
blocks.push(element);
346+
}
347+
}
348+
349+
// If no blocks found, check if we're inside a block
350+
if (blocks.length === 0) {
351+
let parent = root;
352+
while (parent && parent !== this.doc.body) {
353+
if (blockTags.includes(parent.tagName)) {
354+
blocks.push(parent);
355+
break;
356+
}
357+
parent = parent.parentElement;
358+
}
359+
}
360+
361+
return blocks;
362+
}
363+
364+
/**
365+
* Check if a node is fully contained within a range.
366+
*/
367+
private isNodeFullyInRange(node: Node, range: Range): boolean {
368+
const nodeRange = this.doc.createRange();
369+
nodeRange.selectNodeContents(node);
370+
return range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
371+
range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0;
372+
}
373+
374+
/**
375+
* Check if element has an ancestor in the given set.
376+
*/
377+
private hasAncestorInSet(element: HTMLElement, set: Set<HTMLElement>): boolean {
378+
let parent = element.parentElement;
379+
while (parent) {
380+
if (set.has(parent)) {
381+
return true;
382+
}
383+
parent = parent.parentElement;
384+
}
385+
return false;
386+
}
387+
191388
insertVideo(videoUrl: string) {
192389
if (videoUrl.match('www.youtube.com') || videoUrl.match('youtu.be')) {
193390
this.insertYouTubeVideoTag(videoUrl);

0 commit comments

Comments
 (0)