@@ -4,46 +4,139 @@ define(function (require, exports, module) {
44 const CodeMirror = require ( "thirdparty/CodeMirror/lib/codemirror" ) ;
55
66 /**
7- * this is a helper function to find the content boundaries in HTML
8- * @param {string } html - The HTML string to parse
9- * @return {Object } - Object with openTag and closeTag properties
7+ * This function is to sync text content changes between the original source code
8+ * and the live preview DOM after a text edit operation
9+ *
10+ * @param {String } oldContent - the original source code from the editor
11+ * @param {String } newContent - the DOM element's outerHTML after editing in live preview
12+ * @returns {String } - the updated content that should replace the original code in the editor
13+ *
14+ * NOTE: This function is a bit complex to read, read this jsdoc to understand the flow:
15+ *
16+ * First, we parse both the old and new content using DOMParser to get proper HTML DOM structures
17+ * Then we compare each element and text node between the old and new content
18+ *
19+ * the main goal is that we ONLY want to update text content, and not element nodes or their attributes
20+ * because if we allow element/attribute changes, the browser might try to fix the HTML
21+ * to make it syntactically correct or to make it efficient, which would mess up the user's original code
22+ * We don't want that - we need to respect how the user wrote their code
23+ * For example: if user wrote <div style="color: red blue green yellow"></div>
24+ * The browser sees this is invalid CSS and would remove the color attribute entirely
25+ * We want to keep that invalid code as it is because it's what the user wanted to do
26+ *
27+ * Here's how the comparison works:
28+ * - if both nodes are text: update the old text with the new text
29+ * - if both nodes are elements: we recursively check their children (for nested content)
30+ * - if old is text, new is element: replace text with element (like when user adds <br>)
31+ * - if old is element, new is text: replace element with text (like when user removes <br>)
32+ * note: when adding new elements (like <br> tags), we only copy the tag name and content,
33+ * never the attributes, to avoid internal Phoenix properties leaking into user's code
1034 */
11- function _findContentBoundaries ( html ) {
12- const openTagEnd = html . indexOf ( ">" ) + 1 ;
13- const closeTagStart = html . lastIndexOf ( "<" ) ;
14-
15- if ( openTagEnd > 0 && closeTagStart >= openTagEnd ) {
16- return {
17- openTag : html . substring ( 0 , openTagEnd ) ,
18- closeTag : html . substring ( closeTagStart )
19- } ;
35+ function _syncTextContentChanges ( oldContent , newContent ) {
36+ const parser = new DOMParser ( ) ;
37+ const oldDoc = parser . parseFromString ( oldContent , "text/html" ) ;
38+ const newDoc = parser . parseFromString ( newContent , "text/html" ) ;
39+
40+ // as DOM parser will add the complete html structure with the HTML tags and all,
41+ // so we just need to get the main content
42+ const oldRoot = oldDoc . body ;
43+ const newRoot = newDoc . body ;
44+
45+ // here oldNode and newNode are full HTML elements which are direct children of the body tag
46+ function syncText ( oldNode , newNode ) {
47+ if ( ! oldNode || ! newNode ) {
48+ return ;
49+ }
50+
51+ // if both the oldNode and newNode has text, replace the old node's text content with the new one
52+ if ( oldNode . nodeType === Node . TEXT_NODE && newNode . nodeType === Node . TEXT_NODE ) {
53+ oldNode . nodeValue = newNode . nodeValue ;
54+
55+ } else if (
56+ // if both have element node, then we recursively get their child elements
57+ // this is so that we can get & update the text content in deeply nested DOM
58+ oldNode . nodeType === Node . ELEMENT_NODE &&
59+ newNode . nodeType === Node . ELEMENT_NODE
60+ ) {
61+
62+ const oldChildren = oldNode . childNodes ;
63+ const newChildren = newNode . childNodes ;
64+
65+ const minLength = Math . min ( oldChildren . length , newChildren . length ) ;
66+
67+ for ( let i = 0 ; i < minLength ; i ++ ) {
68+ syncText ( oldChildren [ i ] , newChildren [ i ] ) ;
69+ }
70+
71+ // append if there are any new nodes, this is mainly when <br> tags needs to be inserted
72+ // as user pressed shift + enter to create empty lines in the new content
73+ for ( let i = minLength ; i < newChildren . length ; i ++ ) {
74+ const newChild = newChildren [ i ] ;
75+ let cleanChild ;
76+
77+ if ( newChild . nodeType === Node . ELEMENT_NODE ) {
78+ // only the element name and not its attributes
79+ // this is to prevent internal properties like data-brackets-id, etc to appear in users code
80+ cleanChild = document . createElement ( newChild . tagName ) ;
81+ cleanChild . innerHTML = newChild . innerHTML ;
82+ } else {
83+ // for text nodes, comment nodes, etc. clone normally
84+ cleanChild = newChild . cloneNode ( true ) ;
85+ }
86+
87+ oldNode . appendChild ( cleanChild ) ;
88+ }
89+
90+ // remove extra old nodes (maybe extra <br>'s were removed)
91+ for ( let i = oldChildren . length - 1 ; i >= newChildren . length ; i -- ) {
92+ oldNode . removeChild ( oldChildren [ i ] ) ;
93+ }
94+
95+ } else if ( oldNode . nodeType === Node . TEXT_NODE && newNode . nodeType === Node . ELEMENT_NODE ) {
96+ // when old has text node and new has element node
97+ // this generally happens when we remove the complete content which results in empty <br> tag
98+ // for ex: <div>hello</div>, here if we remove the 'hello' from live preview then result will be
99+ // <div><br></div>
100+ const replacement = document . createElement ( newNode . tagName ) ;
101+ replacement . innerHTML = newNode . innerHTML ;
102+ oldNode . parentNode . replaceChild ( replacement , oldNode ) ;
103+ } else if ( oldNode . nodeType === Node . ELEMENT_NODE && newNode . nodeType === Node . TEXT_NODE ) {
104+ // this is opposite of previous one when earlier it was just <br> or some tag
105+ // and now we add text content in that
106+ const replacement = document . createTextNode ( newNode . nodeValue ) ;
107+ oldNode . parentNode . replaceChild ( replacement , oldNode ) ;
108+ }
109+ }
110+
111+ const oldEls = oldRoot . children ;
112+ const newEls = newRoot . children ;
113+
114+ for ( let i = 0 ; i < Math . min ( oldEls . length , newEls . length ) ; i ++ ) {
115+ syncText ( oldEls [ i ] , newEls [ i ] ) ;
20116 }
21117
22- return null ;
118+ return oldRoot . innerHTML ;
23119 }
24120
25121 /**
26122 * this function handles the text edit in the source code when user updates the text in the live preview
123+ *
27124 * @param {Object } message - the message object
28- * {
29- * livePreviewEditEnabled: true,
30- element: the DOM element that was modified,
31- oldContent: the text that was present before the edit,
32- newContent: the new text,
33- tagId: data-brackets-id of the DOM element,
34- livePreviewTextEdit: true
35- }
36- *
37- * The logic is: get the text in the editor using the tagId. split that text using the old content
38- * join the text back and add the new content in between
39- */
125+ * - livePreviewEditEnabled: true
126+ * - livePreviewTextEdit: true
127+ * - element: element
128+ * - newContent: element.outerHTML (the edited content from live preview)
129+ * - tagId: Number (data-brackets-id of the edited element)
130+ * - isEditSuccessful: boolean (false when user pressed Escape to cancel, otherwise true always)
131+ */
40132 function _editTextInSource ( message ) {
41133 const currLiveDoc = LiveDevMultiBrowser . getCurrentLiveDoc ( ) ;
42134 if ( ! currLiveDoc || ! currLiveDoc . editor || ! message . tagId ) {
43135 return ;
44136 }
45137
46138 const editor = currLiveDoc . editor ;
139+
47140 // get the start range from the getPositionFromTagId function
48141 // and we get the end range from the findMatchingTag function
49142 // NOTE: we cannot get the end range from getPositionFromTagId
@@ -62,20 +155,22 @@ define(function (require, exports, module) {
62155 const endPos = endRange . close . to ;
63156
64157 const text = editor . getTextBetween ( startPos , endPos ) ;
65- let splittedText ;
66158
67- // we need to find the content boundaries to find exactly where the content starts and where it ends
68- const boundaries = _findContentBoundaries ( text ) ;
69- if ( boundaries ) {
70- splittedText = [ boundaries . openTag , boundaries . closeTag ] ;
71- }
159+ // if the edit was cancelled (mainly by pressing Escape key)
160+ // we just replace the same text with itself
161+ // this is a quick trick because as the code is changed for that element in the file,
162+ // the live preview for that element gets refreshed and the changes are discarded in the live preview
163+ if ( ! message . isEditSuccessful ) {
164+ editor . replaceRange ( text , startPos , endPos ) ;
165+ } else {
72166
73- // if the text split was done successfully, apply the edit
74- if ( splittedText && splittedText . length === 2 ) {
75- const finalText = splittedText [ 0 ] + message . newContent + splittedText [ 1 ] ;
167+ // if the edit operation was successful, we call a helper function that
168+ // is responsible to provide the actual content that needs to be written in the editor
169+ //
170+ // text: the actual current source code in the editor
171+ // message.newContent: the new content in the live preview after the edit operation
172+ const finalText = _syncTextContentChanges ( text , message . newContent ) ;
76173 editor . replaceRange ( finalText , startPos , endPos ) ;
77- } else {
78- console . error ( "Live preview text edit operation failed." ) ;
79174 }
80175 }
81176
0 commit comments