Skip to content

Commit 1a1f880

Browse files
committed
feat: formatting changes like ctrl + b, u, i were adding internal attributes in source code
1 parent 77f33c1 commit 1a1f880

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed

src/LiveDevelopment/BrowserScripts/RemoteFunctions.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4800,6 +4800,84 @@ function RemoteFunctions(config = {}) {
48004800
selection.addRange(range);
48014801
}
48024802

4803+
/**
4804+
* gets the current selection as character offsets relative to the element's text content
4805+
*
4806+
* @param {Element} element - the contenteditable element
4807+
* @returns {Object|null} selection info with startOffset, endOffset, selectedText or null
4808+
*/
4809+
function getSelectionInfo(element) {
4810+
const selection = window.getSelection();
4811+
if (!selection.rangeCount) {
4812+
return null;
4813+
}
4814+
4815+
const range = selection.getRangeAt(0);
4816+
4817+
// make sure selection is within the element we're editing
4818+
if (!element.contains(range.commonAncestorContainer)) {
4819+
return null;
4820+
}
4821+
4822+
// create a range from element start to selection start to calculate offset
4823+
const preSelectionRange = document.createRange();
4824+
preSelectionRange.selectNodeContents(element);
4825+
preSelectionRange.setEnd(range.startContainer, range.startOffset);
4826+
4827+
const startOffset = preSelectionRange.toString().length;
4828+
const selectedText = range.toString();
4829+
const endOffset = startOffset + selectedText.length;
4830+
4831+
return {
4832+
startOffset: startOffset,
4833+
endOffset: endOffset,
4834+
selectedText: selectedText
4835+
};
4836+
}
4837+
4838+
/**
4839+
* handles text formatting commands when user presses ctrl+b/i/u
4840+
* sends a message to backend to apply consistent formatting tags
4841+
* @param {Element} element - the contenteditable element
4842+
* @param {string} formatKey - the format key ('b', 'i', or 'u')
4843+
*/
4844+
function handleFormatting(element, formatKey) {
4845+
const selection = getSelectionInfo(element);
4846+
4847+
// need an actual selection, not just cursor position
4848+
if (!selection || selection.startOffset === selection.endOffset) {
4849+
return;
4850+
}
4851+
4852+
const formatCommand = {
4853+
'b': 'bold',
4854+
'i': 'italic',
4855+
'u': 'underline'
4856+
}[formatKey];
4857+
4858+
if (!formatCommand) {
4859+
return;
4860+
}
4861+
4862+
const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
4863+
if (!tagId) {
4864+
return;
4865+
}
4866+
4867+
// send formatting message to backend
4868+
window._Brackets_MessageBroker.send({
4869+
livePreviewEditEnabled: true,
4870+
livePreviewFormatCommand: formatCommand,
4871+
tagId: Number(tagId),
4872+
element: element,
4873+
selection: selection,
4874+
currentHTML: element.innerHTML
4875+
});
4876+
4877+
// exit edit mode after applying format
4878+
finishEditingCleanup(element);
4879+
}
4880+
48034881
// Function to handle direct editing of elements in the live preview
48044882
function startEditing(element) {
48054883
if (!isElementEditable(element)) {
@@ -4860,6 +4938,21 @@ function RemoteFunctions(config = {}) {
48604938
} else if ((event.key === " " || event.key === "Spacebar") && element.tagName.toLowerCase() === 'button') {
48614939
event.preventDefault();
48624940
document.execCommand("insertText", false, " ");
4941+
} else if ((event.ctrlKey || event.metaKey) && (event.key === 'b' || event.key === 'i' || event.key === 'u')) {
4942+
// handle formatting commands (ctrl+b/i/u)
4943+
event.preventDefault();
4944+
4945+
// check if user has typed text that hasn't been saved yet
4946+
const currentContent = element.textContent;
4947+
const hasTextChanges = (oldContent !== currentContent);
4948+
4949+
// so if user already has some text changes, we just save that
4950+
// we do formatting only when there are no text changes
4951+
if (hasTextChanges) {
4952+
finishEditing(element, true);
4953+
} else {
4954+
handleFormatting(element, event.key);
4955+
}
48634956
}
48644957
}
48654958

src/LiveDevelopment/LivePreviewEdit.js

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)