-
-
Notifications
You must be signed in to change notification settings - Fork 195
Expand file tree
/
Copy pathRemoteFunctions.js
More file actions
1445 lines (1291 loc) · 57.1 KB
/
RemoteFunctions.js
File metadata and controls
1445 lines (1291 loc) · 57.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// this is a single file sent to browser preview. keep this light. add features as extensions
// Please do not add any license header in this file as it will end up in distribution bin as is.
/**
* RemoteFunctions define the functions to be executed in the browser. This
* modules should define a single function that returns an object of all
* exported functions.
*/
// eslint-disable-next-line no-unused-vars
function RemoteFunctions(config = {}) {
const GLOBALS = {
// given to internal elements like info box, tool box, image gallery and all other phcode internal elements
// to distinguish between phoenix internal vs user created elements
PHCODE_INTERNAL_ATTR: "data-phcode-internal-c15r5a9",
DATA_BRACKETS_ID_ATTR: "data-brackets-id", // data attribute used to track elements for live preview operations
HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview
};
// this is for bidirectional communication between phoenix and live preview
const PhoenixComm = window._Brackets_LiveDev_PhoenixComm;
PhoenixComm && PhoenixComm.registerLpFn("PH_Hello", function(param) {
// this is just a test function here to check if live preview. fn call is working correctly.
console.log("Hello World", param);
});
const MessageBroker = window._Brackets_MessageBroker; // to be used by plugins.
const SHARED_STATE = {
__description: "Use this to keep shared state for Live Preview Edit instead of window.*",
_suppressDOMEditDismissal: false,
_suppressDOMEditDismissalTimeout: null
};
let _hoverHighlight;
let _clickHighlight;
let _cssSelectorHighlight; // temporary highlight for CSS selector matches in edit mode
let _hoverLockTimer = null;
let _cssSelectorHighlightTimer = null;
// this will store the element that was clicked previously (before the new click)
// we need this so that we can remove click styling from the previous element when a new element is clicked
let previouslySelectedElement = null;
// Expose the currently selected element globally for external access
window.__current_ph_lp_selected = null;
const COLORS = {
highlightPadding: "rgba(147, 196, 125, 0.55)",
highlightMargin: "rgba(246, 178, 107, 0.66)",
outlineEditable: "#4285F4",
outlineNonEditable: "#3C3F41"
};
// the following fucntions can be in the handler and live preview will call those functions when the below
// events happen
const allowedHandlerFns = [
"dismiss", // when handler gets this event, it should dismiss all ui it renders in the live preview
"createToolBox",
"createInfoBox",
"createHoverBox",
"createMoreOptionsDropdown",
// render an icon or html when the selected element toolbox appears in edit mode.
"renderToolBoxItem",
"redraw",
"onElementSelected", // an item is selected in live preview
"onElementCleanup",
"onNonEditableElementClick", // called when user clicks on a non-editable element
"handleConfigChange",
// below function gets called to render the dropdown when user clicks on the ... menu in the tool box,
// the handler should retrun html tor ender the dropdown item.
"renderDropdownItems",
// called when an item is selected from the more options dropdown
"handleDropdownClick",
"reRegisterEventHandlers",
"handleClick", // handle click on an icon in the tool box.
// when escape key is presses in the editor, we may need to dismiss the live edit boxes.
"handleEscapePress",
// interaction blocks acts as 'kill switch' to block all kinds of click handlers
// this is done so that links or buttons doesn't perform their natural operation in edit mode
"registerInteractionBlocker", // to block
"unregisterInteractionBlocker", // to unblock
"udpateHotCornerState" // to update the hot corner button when state changes
];
const _toolHandlers = new Map();
function registerToolHandler(handlerName, handler) {
if(_toolHandlers.get(handlerName)) {
console.error(`lp: Tool handler '${handlerName}' already registered. Ignoring new registration`);
return;
}
if (!handler || typeof handler !== "object") {
console.error(`lp: Tool handler '${handlerName}' value is invalid ${JSON.stringify(handler)}.`);
return;
}
handler.handlerName = handlerName;
for (const key of Object.keys(handler)) {
if (key !== "handlerName" && !allowedHandlerFns.includes(key)) {
console.warn(`lp: Tool handler '${handlerName}' has unknown property '${key}'`,
`should be one of ${allowedHandlerFns.join(",")}`);
}
}
_toolHandlers.set(handlerName, handler);
}
function getToolHandler(handlerName) {
return _toolHandlers.get(handlerName);
}
function getAllToolHandlers() {
return Array.from(_toolHandlers.values());
}
/**
* check if an element is inspectable.
* inspectable elements are those which doesn't have GLOBALS.DATA_BRACKETS_ID_ATTR ('data-brackets-id'),
* this normally happens when content is DOM content is inserted by some scripting language
*/
function isElementInspectable(element, onlyHighlight = false) {
if(config.mode !== 'edit' && !onlyHighlight) {
return false;
}
if(element && // element should exist
element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag
element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag
// this attribute is used by phoenix internal elements
!element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`) &&
!_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all
return true;
}
return false;
}
/**
* This is a checker function for editable elements, it makes sure that the element satisfies all the required check
* - When onlyHighlight is false → config.mode must be 'edit'
* - When onlyHighlight is true → config.mode can be any mode (doesn't matter)
* @param {DOMElement} element
* @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check
* @returns {boolean} - True if the element is editable else false
*/
function isElementEditable(element, onlyHighlight = false) {
// for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id
return isElementInspectable(element, onlyHighlight) && element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
}
/**
* this function calc the screen offset of an element
*
* @param {DOMElement} element
* @returns {{left: number, top: number}}
*/
function screenOffset(element) {
const elemBounds = element.getBoundingClientRect();
const body = window.document.body;
let offsetTop;
let offsetLeft;
if (window.getComputedStyle(body).position === "static") {
offsetLeft = elemBounds.left + window.pageXOffset;
offsetTop = elemBounds.top + window.pageYOffset;
} else {
const bodyBounds = body.getBoundingClientRect();
offsetLeft = elemBounds.left - bodyBounds.left;
offsetTop = elemBounds.top - bodyBounds.top;
}
return { left: offsetLeft, top: offsetTop };
}
const LivePreviewView = {
registerToolHandler: registerToolHandler,
getToolHandler: getToolHandler,
getAllToolHandlers: getAllToolHandlers,
isElementEditable: isElementEditable,
isElementInspectable: isElementInspectable,
isElementVisible: isElementVisible,
screenOffset: screenOffset,
selectElement: selectElement,
brieflyDisableHoverListeners: brieflyDisableHoverListeners,
handleElementClick: handleElementClick,
cleanupPreviousElementState: cleanupPreviousElementState,
disableHoverListeners: disableHoverListeners,
enableHoverListeners: enableHoverListeners,
redrawHighlights: redrawHighlights,
redrawEverything: redrawEverything
};
/**
* @type {DOMEditHandler}
*/
var _editHandler;
// the below code comment is replaced by added scripts for extensibility
// DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS
// helper function to check if an element is inside the HEAD tag
// we need this because we don't wanna trigger the element highlights on head tag and its children,
// except for <style> tags which should be allowed
function _isInsideHeadTag(element) {
let parent = element;
while (parent && parent !== window.document) {
if (parent.tagName.toLowerCase() === "head") {
// allow <style> tags inside <head>
return element.tagName.toLowerCase() !== "style";
}
parent = parent.parentElement;
}
return false;
}
// set an event on a element
function _trigger(element, name, value, autoRemove) {
var key = "data-ld-" + name;
if (value !== undefined && value !== null) {
element.setAttribute(key, value);
if (autoRemove) {
window.setTimeout(element.removeAttribute.bind(element, key));
}
} else {
element.removeAttribute(key);
}
}
// Checks if the element is in Viewport in the client browser
function isInViewport(element) {
var rect = element.getBoundingClientRect();
var html = window.document.documentElement;
return (
rect.top >= 0 &&
rect.bottom <= (window.innerHeight || html.clientHeight)
);
}
// Checks if an element is actually visible to the user (not hidden, collapsed, or off-screen)
function isElementVisible(element) {
// Check if element has zero dimensions (indicates it's hidden or collapsed)
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return false;
}
// Check computed styles for visibility
const computedStyle = window.getComputedStyle(element);
if (computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden' ||
computedStyle.opacity === '0') {
return false;
}
// Check if any parent element is hidden
let parent = element.parentElement;
while (parent && parent !== document.body) {
const parentStyle = window.getComputedStyle(parent);
if (parentStyle.display === 'none' ||
parentStyle.visibility === 'hidden') {
return false;
}
parent = parent.parentElement;
}
return true;
}
// returns the distance from the top of the closest relatively positioned parent element
function getDocumentOffsetTop(element) {
return element.offsetTop + (element.offsetParent ? getDocumentOffsetTop(element.offsetParent) : 0);
}
function Highlight(trigger) {
this.trigger = !!trigger;
this.elements = [];
this.selector = "";
this._divs = [];
}
Highlight.prototype = {
add: function (element) {
if (this.elements.includes(element) || element === window.document) {
return;
}
if (this.trigger) {
_trigger(element, "highlight", 1);
}
this.elements.push(element);
this._createOverlay(element);
},
clear: function () {
this._divs.forEach(function (div) {
if (div.parentNode) {
div.parentNode.removeChild(div);
}
});
this._divs = [];
if (this.trigger) {
this.elements.forEach(function (el) {
_trigger(el, "highlight", 0);
});
}
this.elements = [];
},
redraw: function () {
const elements = this.selector
? Array.from(window.document.querySelectorAll(this.selector))
: this.elements.slice();
this.clear();
elements.forEach(function (el) { this.add(el); }, this);
},
_createOverlay: function (element) {
const bounds = element.getBoundingClientRect();
if (bounds.width === 0 && bounds.height === 0) { return; }
const cs = window.getComputedStyle(element);
// Parse box model values (getComputedStyle always resolves to px)
const bt = parseFloat(cs.borderTopWidth) || 0,
br = parseFloat(cs.borderRightWidth) || 0,
bb = parseFloat(cs.borderBottomWidth) || 0,
bl = parseFloat(cs.borderLeftWidth) || 0;
const pt = parseFloat(cs.paddingTop) || 0,
pr = parseFloat(cs.paddingRight) || 0,
pb = parseFloat(cs.paddingBottom) || 0,
pl = parseFloat(cs.paddingLeft) || 0;
const mt = parseFloat(cs.marginTop) || 0,
mr = parseFloat(cs.marginRight) || 0,
mb = parseFloat(cs.marginBottom) || 0,
ml = parseFloat(cs.marginLeft) || 0;
// Compute the 4 absolute boxes exactly like dev tools:
// getBoundingClientRect() always returns the border box regardless of box-sizing.
const scroll = LivePreviewView.screenOffset(element);
const borderBox = {
left: scroll.left,
top: scroll.top,
width: bounds.width,
height: bounds.height
};
const paddingBox = {
left: borderBox.left + bl,
top: borderBox.top + bt,
width: borderBox.width - bl - br,
height: borderBox.height - bt - bb
};
const contentBox = {
left: paddingBox.left + pl,
top: paddingBox.top + pt,
width: paddingBox.width - pl - pr,
height: paddingBox.height - pt - pb
};
const marginBox = {
left: borderBox.left - ml,
top: borderBox.top - mt,
width: borderBox.width + ml + mr,
height: borderBox.height + mt + mb
};
// Container div — sized to the margin box so all rects fit inside it
const div = window.document.createElement("div");
div.className = GLOBALS.HIGHLIGHT_CLASSNAME;
div.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
div.trackingElement = element;
const divStyle = div.style;
divStyle.position = "absolute";
divStyle.left = marginBox.left + "px";
divStyle.top = marginBox.top + "px";
divStyle.width = marginBox.width + "px";
divStyle.height = marginBox.height + "px";
divStyle.zIndex = 2147483645;
divStyle.margin = "0";
divStyle.padding = "0";
divStyle.border = "none";
divStyle.pointerEvents = "none";
divStyle.boxSizing = "border-box";
// Helper to create a colored rect at absolute page coordinates, offset by the container origin
function makeRect(left, top, width, height, color) {
if (width <= 0 || height <= 0) { return; }
const r = window.document.createElement("div");
r.style.position = "absolute";
r.style.left = (left - marginBox.left) + "px";
r.style.top = (top - marginBox.top) + "px";
r.style.width = width + "px";
r.style.height = height + "px";
r.style.backgroundColor = color;
div.appendChild(r);
}
// Padding region: 4 rects filling paddingBox minus contentBox
const padColor = COLORS.highlightPadding;
// top padding
makeRect(paddingBox.left, paddingBox.top,
paddingBox.width, pt, padColor);
// bottom padding
makeRect(paddingBox.left, contentBox.top + contentBox.height,
paddingBox.width, pb, padColor);
// left padding
makeRect(paddingBox.left, contentBox.top,
pl, contentBox.height, padColor);
// right padding
makeRect(contentBox.left + contentBox.width, contentBox.top,
pr, contentBox.height, padColor);
// Margin region: 4 rects filling marginBox minus borderBox
const margColor = COLORS.highlightMargin;
// top margin
makeRect(marginBox.left, marginBox.top,
marginBox.width, mt, margColor);
// bottom margin
makeRect(marginBox.left, borderBox.top + borderBox.height,
marginBox.width, mb, margColor);
// left margin
makeRect(marginBox.left, borderBox.top,
ml, borderBox.height, margColor);
// right margin
makeRect(borderBox.left + borderBox.width, borderBox.top,
mr, borderBox.height, margColor);
// Selection outline: 1px border at the border-box edge (drawn inside the border area)
const isEditable = element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
const outlineColor = isEditable ? COLORS.outlineEditable : COLORS.outlineNonEditable;
const outlineDiv = window.document.createElement("div");
outlineDiv.style.position = "absolute";
outlineDiv.style.left = (borderBox.left - marginBox.left) + "px";
outlineDiv.style.top = (borderBox.top - marginBox.top) + "px";
outlineDiv.style.width = borderBox.width + "px";
outlineDiv.style.height = borderBox.height + "px";
outlineDiv.style.border = `1px solid ${outlineColor}`;
outlineDiv.style.boxSizing = "border-box";
outlineDiv.style.pointerEvents = "none";
div.appendChild(outlineDiv);
window.document.body.appendChild(div);
this._divs.push(div);
}
};
// helper function to get the current elements highlight mode
// this is as per user settings (either click or hover)
function getHighlightMode() {
return config.elemHighlights ? config.elemHighlights.toLowerCase() : "hover";
}
// helper function to check if highlights should show on hover
function shouldShowHighlightOnHover() {
return getHighlightMode() !== "click";
}
function onElementHover(event) {
// don't want highlighting and stuff when auto scrolling or when dragging (svgs)
// for dragging normal html elements its already taken care of...so we just add svg drag checking
if (SHARED_STATE.isAutoScrolling || SHARED_STATE._isDraggingSVG) {
return;
}
const element = event.target;
if(!LivePreviewView.isElementInspectable(element) || element.nodeType !== Node.ELEMENT_NODE) {
return false;
}
if(element && (element.closest('.phcode-no-lp-edit') || element.classList.contains('phcode-no-lp-edit-this'))) {
return false;
}
// if _hoverHighlight is uninitialized, initialize it
if (!_hoverHighlight && shouldShowHighlightOnHover()) {
_hoverHighlight = new Highlight(true);
}
// this is to check the user's settings, if they want to show the elements highlights on hover or click
if (_hoverHighlight && shouldShowHighlightOnHover()) {
_hoverHighlight.clear();
// Skip hover overlay for the currently click-selected element.
// It already has its own overlay from the click/selection flow,
// and adding hover state on top would stack duplicate overlays.
if (element !== previouslySelectedElement) {
_hoverHighlight.add(element);
}
// Show minimal hover tooltip (tag + dimensions)
const hoverBoxHandler = LivePreviewView.getToolHandler("HoverBox");
if (hoverBoxHandler) {
hoverBoxHandler.dismiss();
if (element !== previouslySelectedElement) {
hoverBoxHandler.createHoverBox(element);
}
}
}
}
function onElementHoverOut(event) {
// don't want highlighting and stuff when auto scrolling
if (SHARED_STATE.isAutoScrolling) { return; }
const element = event.target;
// Use isElementInspectable (not isElementEditable) so that JS-rendered
// elements also get their hover highlight and hover box properly dismissed.
if(LivePreviewView.isElementInspectable(element) && element.nodeType === Node.ELEMENT_NODE) {
// this is to check the user's settings, if they want to show the elements highlights on hover or click
if (_hoverHighlight && shouldShowHighlightOnHover()) {
_hoverHighlight.clear();
// dismiss the hover box
const hoverBoxHandler = LivePreviewView.getToolHandler("HoverBox");
if (hoverBoxHandler) {
hoverBoxHandler.dismiss();
}
}
}
}
function scrollElementToViewPort(element) {
if (!element) {
return;
}
// Check if element is in viewport, if not scroll to it
if (!isInViewport(element)) {
let top = getDocumentOffsetTop(element);
if (top) {
top -= (window.innerHeight / 2);
window.scrollTo(0, top);
}
}
}
/**
* this function is responsible to select an element in the live preview
* @param {Element} element - The DOM element to select
* @param {boolean} [fromEditor] - If true, this is an editor-cursor-driven selection;
* only lightweight highlights (outline, margin/padding overlay) are shown, not interactive
* UI like control box, spacing handles, or measurements.
*/
function selectElement(element, fromEditor) {
dismissUIAndCleanupState();
// this should also be there when users are in highlight mode
scrollElementToViewPort(element);
if(!LivePreviewView.isElementInspectable(element, true)) {
return false;
}
// Only invoke tool handlers for user-initiated clicks in the live preview,
// not for editor cursor movements which should only show lightweight highlights
if (!fromEditor) {
// when user clicks on a non-editable element
if (!element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR)) {
getAllToolHandlers().forEach(handler => {
if (handler.onNonEditableElementClick) {
handler.onNonEditableElementClick(element);
}
});
}
// make sure that the element is actually visible to the user
if (isElementVisible(element)) {
// Notify handlers about element selection
getAllToolHandlers().forEach(handler => {
if (handler.onElementSelected) {
handler.onElementSelected(element);
}
});
}
}
if (!_clickHighlight) {
_clickHighlight = new Highlight();
}
_clickHighlight.clear();
_clickHighlight.add(element);
previouslySelectedElement = element;
window.__current_ph_lp_selected = element;
}
function disableHoverListeners() {
window.document.removeEventListener("mouseover", onElementHover);
window.document.removeEventListener("mouseout", onElementHoverOut);
}
function enableHoverListeners() {
// don't enable hover listeners if user is currently editing an element
// this was added to fix a specific bug:
// lets say user double clicked an element: so as soon as the first click is made,
// 'breiflyDisableHoverListeners' is called which has a timer to re-enable hover listeners,
// because of which even during editing the hover listeners were working
if (SHARED_STATE._currentlyEditingElement) {
return;
}
if (config.mode === 'edit' && shouldShowHighlightOnHover()) {
disableHoverListeners();
window.document.addEventListener("mouseover", onElementHover);
window.document.addEventListener("mouseout", onElementHoverOut);
}
}
/**
* this function disables hover listeners for 800ms to prevent ui conclicts
* Used when user performs click actions to avoid UI box conflicts
*/
function brieflyDisableHoverListeners() {
if (_hoverLockTimer) {
clearTimeout(_hoverLockTimer);
}
disableHoverListeners();
_hoverLockTimer = setTimeout(() => {
enableHoverListeners();
_hoverLockTimer = null;
}, 800);
}
/**
* this function is called when user clicks on an element in the LP when in edit mode
*
* @param {HTMLElement} element - The clicked element
* @param {Event} event - The click event
*/
function handleElementClick(element, event) {
// Check for dismiss action first - dismiss LP editing when clicked (takes precedence over no-edit)
if(element && (
element.closest('.phcode-dismiss-lp-edit') || element.classList.contains('phcode-dismiss-lp-edit-this'))) {
dismissUIAndCleanupState();
event.preventDefault();
event.stopPropagation();
return;
}
if(element && (element.closest('.phcode-no-lp-edit') || element.classList.contains('phcode-no-lp-edit-this'))) {
return;
}
if (!LivePreviewView.isElementInspectable(element)) {
dismissUIAndCleanupState();
return;
}
// if anything is currently selected, we need to clear that
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
selection.removeAllRanges();
}
// send cursor movement message to editor so cursor jumps to clicked element
if (element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR) &&
config.syncSourceAndPreview !== false) {
MessageBroker.send({
"tagId": element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR),
"nodeID": element.id,
"nodeClassList": element.classList,
"nodeName": element.nodeName,
"allSelectors": window.getAllInheritedSelectorsInOrder(element),
"contentEditable": element.contentEditable === "true",
"clicked": true
});
}
brieflyDisableHoverListeners();
selectElement(element);
}
// clear CSS selector highlights
function clearCssSelectorHighlight() {
if (_cssSelectorHighlightTimer) {
clearTimeout(_cssSelectorHighlightTimer);
_cssSelectorHighlightTimer = null;
}
if (_cssSelectorHighlight) {
_cssSelectorHighlight.clear();
_cssSelectorHighlight = null;
}
}
// create CSS selector highlights for edit mode
function createCssSelectorHighlight(nodes, rule) {
// Clear any existing highlights
clearCssSelectorHighlight();
// Highlight all matching elements except the selected one
// (it already has a click highlight)
_cssSelectorHighlight = new Highlight();
for (let i = 0; i < nodes.length; i++) {
if (nodes[i] !== previouslySelectedElement &&
LivePreviewView.isElementInspectable(nodes[i], true) &&
nodes[i].nodeType === Node.ELEMENT_NODE) {
_cssSelectorHighlight.add(nodes[i]);
}
}
_cssSelectorHighlight.selector = rule;
}
// remove active highlights
function hideHighlight() {
if (_clickHighlight) {
_clickHighlight.clear();
_clickHighlight = null;
}
if (_hoverHighlight) {
_hoverHighlight.clear();
_hoverHighlight = null;
}
clearCssSelectorHighlight();
}
// highlight an element
function highlight(element, clear) {
if (!_clickHighlight) {
_clickHighlight = new Highlight();
}
if (clear) {
_clickHighlight.clear();
}
if (LivePreviewView.isElementInspectable(element, true) && element.nodeType === Node.ELEMENT_NODE) {
_clickHighlight.add(element);
}
}
/**
* Find the best element to select from a list of matched nodes
* Prefers: previously selected element > parent of selected > first valid element
* @param {NodeList} nodes - The nodes matching the CSS rule
* @param {string} rule - The CSS rule used to match nodes
* @returns {{element: Element|null, skipSelection: boolean}} - The element to select and whether to skip selection
*/
function findBestElementToSelect(nodes, rule) {
let firstValidElement = null;
let elementToSelect = null;
for (let i = 0; i < nodes.length; i++) {
if(!LivePreviewView.isElementInspectable(nodes[i], true) || nodes[i].tagName === "BR") {
continue;
}
// Store the first valid element as a fallback
if (!firstValidElement) {
firstValidElement = nodes[i];
}
// if hover lock timer is active, skip selection as it's already handled by handleElementClick
if (_hoverLockTimer && nodes[i] === previouslySelectedElement) {
return { element: null, skipSelection: true };
}
// Check if the currently selected element or any of its parents have a highlight
if (previouslySelectedElement) {
if (nodes[i] === previouslySelectedElement) {
// Exact match - prefer this
elementToSelect = previouslySelectedElement;
break;
} else if (!elementToSelect &&
previouslySelectedElement.closest && nodes[i] === previouslySelectedElement.closest(rule)) {
// The node is a parent of the currently selected element. we stop at the first parent, after that
// we only scan for exact match
elementToSelect = nodes[i];
}
}
}
return {
element: elementToSelect || firstValidElement,
skipSelection: false
};
}
/**
* Highlight all elements matching a CSS rule and select the best one
* @param {string} rule - The CSS rule to highlight
*/
function highlightRule(rule) {
hideHighlight();
// Filter out the universal selector (*) from the rule - highlighting everything
// is not useful, similar to how we skip html/body in isElementInspectable.
// The rule can be a comma-separated list of selectors (from multi-cursor),
// so we filter out any standalone * segments and keep valid ones.
rule = rule.split(",").map(s => s.trim()).filter(s => s !== "*").join(",");
if (!rule) {
dismissUIAndCleanupState();
return;
}
const nodes = window.document.querySelectorAll(rule);
// Highlight all matching nodes
for (let i = 0; i < nodes.length; i++) {
highlight(nodes[i]);
}
if (_clickHighlight) {
_clickHighlight.selector = rule;
}
// In edit mode, select the best element and create temporary highlights for the rest.
// In highlight mode, skip selection so all matching elements stay highlighted equally.
if (config.mode === 'edit') {
const { element, skipSelection } = findBestElementToSelect(nodes, rule);
if (!skipSelection) {
if (element) {
selectElement(element, true);
} else {
// No valid element found, dismiss UI
dismissUIAndCleanupState();
}
}
createCssSelectorHighlight(nodes, rule);
}
}
// recreate UI boxes so that they are placed properly
function redrawUIBoxes() {
// commented out for unified box redesign
// if (SHARED_STATE._toolBox) {
// const element = SHARED_STATE._toolBox.element;
// const toolBoxHandler = LivePreviewView.getToolHandler("ToolBox");
// if (toolBoxHandler) {
// toolBoxHandler.dismiss();
// toolBoxHandler.createToolBox(element);
// }
// }
// if (SHARED_STATE._infoBox) {
// const element = SHARED_STATE._infoBox.element;
// const infoBoxHandler = LivePreviewView.getToolHandler("InfoBox");
// if (infoBoxHandler) {
// infoBoxHandler.dismiss();
// infoBoxHandler.createInfoBox(element);
// }
// }
}
// redraw active highlights
function redrawHighlights() {
if (_clickHighlight) {
_clickHighlight.redraw();
}
if (_hoverHighlight) {
_hoverHighlight.redraw();
}
}
// just a wrapper function when we need to redraw highlights as well as UI boxes
function redrawEverything() {
redrawHighlights();
redrawUIBoxes();
// Call redraw on all registered handlers
getAllToolHandlers().forEach(handler => {
if (handler.redraw) {
handler.redraw();
}
});
}
window.addEventListener("resize", redrawEverything);
/**
* Constructor
* @param {Document} htmlDocument
*/
function DOMEditHandler(htmlDocument) {
this.htmlDocument = htmlDocument;
this.rememberedNodes = null;
this.entityParseParent = htmlDocument.createElement("div");
}
/**
* @private
* Find the first matching element with the specified data-brackets-id
* @param {string} id
* @return {Element}
*/
DOMEditHandler.prototype._queryBracketsID = function (id) {
if (!id) {
return null;
}
if (this.rememberedNodes && this.rememberedNodes[id]) {
return this.rememberedNodes[id];
}
var results = this.htmlDocument.querySelectorAll(`[${GLOBALS.DATA_BRACKETS_ID_ATTR}='${id}']`);
return results && results[0];
};
/**
* @private
* Insert a new child element
* @param {Element} targetElement Parent element already in the document
* @param {Element} childElement New child element
* @param {Object} edit
*/
DOMEditHandler.prototype._insertChildNode = function (targetElement, childElement, edit) {
var before = this._queryBracketsID(edit.beforeID),
after = this._queryBracketsID(edit.afterID);
if (edit.firstChild) {
before = targetElement.firstChild;
} else if (edit.lastChild) {
after = targetElement.lastChild;
}
if (before) {
targetElement.insertBefore(childElement, before);
} else if (after && (after !== targetElement.lastChild)) {
targetElement.insertBefore(childElement, after.nextSibling);
} else {
targetElement.appendChild(childElement);
}
};
/**
* @private
* Given a string containing encoded entity references, returns the string with the entities decoded.
* @param {string} text The text to parse.
* @return {string} The decoded text.
*/
DOMEditHandler.prototype._parseEntities = function (text) {
// Kind of a hack: just set the innerHTML of a div to the text, which will parse the entities, then
// read the content out.
var result;
this.entityParseParent.innerHTML = text;
result = this.entityParseParent.textContent;
this.entityParseParent.textContent = "";
return result;
};
/**
* @private
* @param {Node} node
* @return {boolean} true if node expects its content to be
* raw text (not parsed for entities) according to the HTML5 spec.
*/
function _isRawTextNode(node) {
return (
node.nodeType === Node.ELEMENT_NODE &&
/script|style|noscript|noframes|noembed|iframe|xmp/i.test(node.tagName)
);
}
/**
* @private
* Replace a range of text and comment nodes with an optional new text node
* @param {Element} targetElement
* @param {Object} edit
*/
DOMEditHandler.prototype._textReplace = function (targetElement, edit) {
function prevIgnoringHighlights(node) {
do {
node = node.previousSibling;
} while (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME);
return node;
}
function nextIgnoringHighlights(node) {
do {
node = node.nextSibling;
} while (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME);
return node;
}
function lastChildIgnoringHighlights(node) {
node = (node.childNodes.length ? node.childNodes.item(node.childNodes.length - 1) : null);
if (node && node.className === GLOBALS.HIGHLIGHT_CLASSNAME) {
node = prevIgnoringHighlights(node);
}
return node;
}
var start = (edit.afterID) ? this._queryBracketsID(edit.afterID) : null,
startMissing = edit.afterID && !start,
end = (edit.beforeID) ? this._queryBracketsID(edit.beforeID) : null,
endMissing = edit.beforeID && !end,
moveNext = start && nextIgnoringHighlights(start),
current = moveNext ||
(end && prevIgnoringHighlights(end)) || lastChildIgnoringHighlights(targetElement),
next,
textNode = (edit.content !== undefined) ?
this.htmlDocument.createTextNode(
_isRawTextNode(targetElement) ? edit.content : this._parseEntities(edit.content)
) : null,
lastRemovedWasText,
isText;
// remove all nodes inside the range
while (current && (current !== end)) {
isText = current.nodeType === Node.TEXT_NODE;
// if start is defined, delete following text nodes
// if start is not defined, delete preceding text nodes
next = (moveNext) ? nextIgnoringHighlights(current) : prevIgnoringHighlights(current);
// only delete up to the nearest element.
// if the start/end tag was deleted in a prior edit, stop removing
// nodes when we hit adjacent text nodes
if ((current.nodeType === Node.ELEMENT_NODE) ||
((startMissing || endMissing) && (isText && lastRemovedWasText))) {
break;
} else {
lastRemovedWasText = isText;
if (current.remove) {