@@ -184,6 +184,274 @@ define(function (require, exports, module) {
184184 ) . join ( "" ) ;
185185 }
186186
187+ /**
188+ * builds a map of character positions to text nodes so we can find which nodes contain a selection
189+ * @param {Element } element - the element to map
190+ * @returns {Array } array of objects: { node, startOffset, endOffset }
191+ */
192+ function _buildTextNodeMap ( element ) {
193+ const textNodeMap = [ ] ;
194+ let currentOffset = 0 ;
195+
196+ function traverse ( node ) {
197+ if ( node . nodeType === Node . TEXT_NODE ) {
198+ const textLength = node . nodeValue . length ;
199+ textNodeMap . push ( {
200+ node : node ,
201+ startOffset : currentOffset ,
202+ endOffset : currentOffset + textLength
203+ } ) ;
204+ currentOffset += textLength ;
205+ } else if ( node . nodeType === Node . ELEMENT_NODE ) {
206+ // recursively traverse child nodes
207+ for ( let child of node . childNodes ) {
208+ traverse ( child ) ;
209+ }
210+ }
211+ }
212+
213+ traverse ( element ) ;
214+ return textNodeMap ;
215+ }
216+
217+ /**
218+ * finds text nodes that overlap with the user's selection
219+ * @param {Array } textNodeMap - the text node map from _buildTextNodeMap
220+ * @param {Number } startOffset - selection start offset
221+ * @param {Number } endOffset - selection end offset
222+ * @returns {Array } array of objects: { node, localStart, localEnd, parentElement }
223+ */
224+ function _findSelectionNodes ( textNodeMap , startOffset , endOffset ) {
225+ const selectionNodes = [ ] ;
226+
227+ for ( let entry of textNodeMap ) {
228+ // check if this text node overlaps with the selection range
229+ if ( entry . endOffset > startOffset && entry . startOffset < endOffset ) {
230+ const localStart = Math . max ( 0 , startOffset - entry . startOffset ) ;
231+ const localEnd = Math . min ( entry . node . nodeValue . length , endOffset - entry . startOffset ) ;
232+
233+ selectionNodes . push ( {
234+ node : entry . node ,
235+ localStart : localStart ,
236+ localEnd : localEnd ,
237+ parentElement : entry . node . parentElement
238+ } ) ;
239+ }
240+ }
241+
242+ return selectionNodes ;
243+ }
244+
245+ /**
246+ * converts format command string to the actual HTML tag name we want to use
247+ * @param {string } formatCommand - "bold", "italic", or "underline"
248+ * @returns {string } tag name: "b", "i", or "u"
249+ */
250+ function _getFormatTag ( formatCommand ) {
251+ const tagMap = {
252+ 'bold' : 'b' ,
253+ 'italic' : 'i' ,
254+ 'underline' : 'u'
255+ } ;
256+ return tagMap [ formatCommand ] || null ;
257+ }
258+
259+ /**
260+ * checks if a text node is already wrapped in a formatting tag by checking ancestors
261+ * we stop at contenteditable boundary since that's the element being edited
262+ * @param {Node } node - the text node to check
263+ * @param {string } tagName - the tag name to look for (lowercase)
264+ * @returns {Element|null } the wrapping element if found, null otherwise
265+ */
266+ function _isNodeWrappedInTag ( node , tagName ) {
267+ let parent = node . parentElement ;
268+ while ( parent ) {
269+ if ( parent . tagName && parent . tagName . toLowerCase ( ) === tagName ) {
270+ return parent ;
271+ }
272+ // stop at the editable element boundary
273+ if ( parent . hasAttribute ( 'contenteditable' ) ) {
274+ break ;
275+ }
276+ parent = parent . parentElement ;
277+ }
278+ return null ;
279+ }
280+
281+ /**
282+ * wraps a portion of a text node in a formatting tag, splitting the text node if needed
283+ * @param {Node } textNode - the text node to wrap
284+ * @param {string } tagName - the formatting tag name (b, i, u)
285+ * @param {Number } start - start offset within the text node
286+ * @param {Number } end - end offset within the text node
287+ */
288+ function _wrapTextInTag ( textNode , tagName , start , end ) {
289+ const text = textNode . nodeValue ;
290+ const before = text . substring ( 0 , start ) ;
291+ const selected = text . substring ( start , end ) ;
292+ const after = text . substring ( end ) ;
293+
294+ const parent = textNode . parentNode ;
295+ const formatElement = document . createElement ( tagName ) ;
296+ formatElement . textContent = selected ;
297+
298+ // replace the text node with before + formatted + after
299+ const fragment = document . createDocumentFragment ( ) ;
300+ if ( before ) {
301+ fragment . appendChild ( document . createTextNode ( before ) ) ;
302+ }
303+ fragment . appendChild ( formatElement ) ;
304+ if ( after ) {
305+ fragment . appendChild ( document . createTextNode ( after ) ) ;
306+ }
307+
308+ parent . replaceChild ( fragment , textNode ) ;
309+ }
310+
311+ /**
312+ * removes a formatting tag by moving its children to its parent
313+ * @param {Element } formatElement - the formatting element to unwrap
314+ */
315+ function _unwrapFormattingTag ( formatElement ) {
316+ const parent = formatElement . parentNode ;
317+ while ( formatElement . firstChild ) {
318+ parent . insertBefore ( formatElement . firstChild , formatElement ) ;
319+ }
320+ parent . removeChild ( formatElement ) ;
321+ }
322+
323+ /**
324+ * applies or removes formatting on the selected text nodes (toggle behavior)
325+ * if all nodes are wrapped in the format tag, we remove it. otherwise we add it.
326+ * @param {Array } selectionNodes - array of selected node info from _findSelectionNodes
327+ * @param {string } formatTag - the format tag to apply/remove (b, i, or u)
328+ */
329+ function _applyFormatToNodes ( selectionNodes , formatTag ) {
330+ // check if all selected nodes are already wrapped in the format tag
331+ const allWrapped = selectionNodes . every ( nodeInfo =>
332+ _isNodeWrappedInTag ( nodeInfo . node , formatTag )
333+ ) ;
334+
335+ if ( allWrapped ) {
336+ // remove formatting (toggle OFF)
337+ selectionNodes . forEach ( nodeInfo => {
338+ const wrapper = _isNodeWrappedInTag ( nodeInfo . node , formatTag ) ;
339+ if ( wrapper ) {
340+ _unwrapFormattingTag ( wrapper ) ;
341+ }
342+ } ) ;
343+ } else {
344+ // apply formatting (toggle ON)
345+ selectionNodes . forEach ( nodeInfo => {
346+ const { node, localStart, localEnd } = nodeInfo ;
347+
348+ // skip if already wrapped
349+ if ( ! _isNodeWrappedInTag ( node , formatTag ) ) {
350+ // check if we need to format the entire node or just a portion
351+ if ( localStart === 0 && localEnd === node . nodeValue . length ) {
352+ // format entire node
353+ const formatElement = document . createElement ( formatTag ) ;
354+ const parent = node . parentNode ;
355+ parent . insertBefore ( formatElement , node ) ;
356+ formatElement . appendChild ( node ) ;
357+ } else {
358+ // format partial node
359+ _wrapTextInTag ( node , formatTag , localStart , localEnd ) ;
360+ }
361+ }
362+ } ) ;
363+ }
364+ }
365+
366+ /**
367+ * handles text formatting (bold, italic, underline) for selected text in live preview
368+ * this is called when user presses ctrl+b/i/u in contenteditable mode
369+ * @param {Object } message - message from frontend with format command and selection info
370+ */
371+ function _applyFormattingToSource ( message ) {
372+ const editor = _getEditorAndValidate ( message . tagId ) ;
373+ if ( ! editor ) {
374+ return ;
375+ }
376+
377+ const range = _getElementRange ( editor , message . tagId ) ;
378+ if ( ! range ) {
379+ return ;
380+ }
381+
382+ const { startPos, endPos } = range ;
383+ const elementText = editor . document . getRange ( startPos , endPos ) ;
384+
385+ // parse the HTML from source using DOMParser
386+ const parser = new DOMParser ( ) ;
387+ const doc = parser . parseFromString ( elementText , "text/html" ) ;
388+ const targetElement = doc . body . firstElementChild ;
389+
390+ if ( ! targetElement ) {
391+ return ;
392+ }
393+
394+ // if targetElement itself is an inline formatting tag (b, i, u, etc), we need to wrap it
395+ // because when we toggle it off, the element gets removed and we lose the reference
396+ const isInlineFormatTag = [ 'b' , 'i' , 'u' , 'strong' , 'em' ] . includes (
397+ targetElement . tagName . toLowerCase ( )
398+ ) ;
399+
400+ let workingElement = targetElement ;
401+ let wrapperElement = null ;
402+
403+ if ( isInlineFormatTag ) {
404+ // wrap in temporary container so we don't lose content when element is removed
405+ wrapperElement = doc . createElement ( 'div' ) ;
406+ wrapperElement . appendChild ( targetElement ) ;
407+ workingElement = wrapperElement . firstElementChild ;
408+ }
409+
410+ // build text node map for finding which nodes contain the selection
411+ const textNodeMap = _buildTextNodeMap ( workingElement ) ;
412+
413+ // validate selection bounds
414+ if ( ! message . selection || message . selection . startOffset >= message . selection . endOffset ) {
415+ return ;
416+ }
417+
418+ if ( message . selection . endOffset > textNodeMap [ textNodeMap . length - 1 ] ?. endOffset ) {
419+ return ;
420+ }
421+
422+ // find which text nodes contain the selection
423+ const selectionNodes = _findSelectionNodes (
424+ textNodeMap ,
425+ message . selection . startOffset ,
426+ message . selection . endOffset
427+ ) ;
428+
429+ if ( selectionNodes . length === 0 ) {
430+ return ;
431+ }
432+
433+ // get the format tag and apply/remove it
434+ const formatTag = _getFormatTag ( message . livePreviewFormatCommand ) ;
435+ if ( ! formatTag ) {
436+ return ;
437+ }
438+
439+ _applyFormatToNodes ( selectionNodes , formatTag ) ;
440+
441+ // serialize and replace in editor
442+ let updatedHTML ;
443+ if ( wrapperElement ) {
444+ // if we wrapped the element, get the wrapper's innerHTML
445+ updatedHTML = wrapperElement . innerHTML ;
446+ } else {
447+ updatedHTML = workingElement . outerHTML ;
448+ }
449+
450+ editor . document . batchOperation ( function ( ) {
451+ editor . document . replaceRange ( updatedHTML , startPos , endPos ) ;
452+ } ) ;
453+ }
454+
187455 /**
188456 * helper function to get editor and validate basic requirements
189457 * @param {Number } tagId - the data-brackets-id of the element
@@ -1543,6 +1811,8 @@ define(function (require, exports, module) {
15431811 _pasteElementFromClipboard ( message . tagId ) ;
15441812 } else if ( message . livePreviewTextEdit ) {
15451813 _editTextInSource ( message ) ;
1814+ } else if ( message . livePreviewFormatCommand ) {
1815+ _applyFormattingToSource ( message ) ;
15461816 } else if ( message . livePreviewHyperlinkEdit ) {
15471817 _updateHyperlinkHref ( message . tagId , message . newHref ) ;
15481818 } else if ( message . AISend ) {
0 commit comments