@@ -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