Web Inspector: Styles: completion popover doesn't hide when switching panels
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / SpreadsheetStyleProperty.js
1 /*
2  * Copyright (C) 2017 Apple Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
27 {
28     constructor(delegate, property)
29     {
30         super();
31
32         console.assert(property instanceof WI.CSSProperty);
33
34         this._delegate = delegate || null;
35         this._property = property;
36         this._element = document.createElement("div");
37
38         this._contentElement = null;
39         this._nameElement = null;
40         this._valueElement = null;
41
42         this._nameTextField = null;
43         this._valueTextField = null;
44
45         this._property.__propertyView = this;
46
47         this._hasInvalidVariableValue = false;
48
49         this._update();
50         property.addEventListener(WI.CSSProperty.Event.OverriddenStatusChanged, this.updateStatus, this);
51         property.addEventListener(WI.CSSProperty.Event.Changed, this.updateStatus, this);
52     }
53
54     // Public
55
56     get element() { return this._element; }
57     get nameTextField() { return this._nameTextField; }
58     get valueTextField() { return this._valueTextField; }
59     get enabled() { return this._property.enabled; }
60
61     set index(index)
62     {
63         this._element.dataset.propertyIndex = index;
64     }
65
66     detached()
67     {
68         this._property.__propertyView = null;
69
70         if (this._nameTextField)
71             this._nameTextField.detached();
72
73         if (this._valueTextField)
74             this._valueTextField.detached();
75     }
76
77     hidden()
78     {
79         if (this._nameTextField && this._nameTextField.editing)
80             this._nameTextField.element.blur();
81         else if (this._valueTextField && this._valueTextField.editing)
82             this._valueTextField.element.blur();
83     }
84
85     highlight()
86     {
87         this._element.classList.add("highlighted");
88     }
89
90     updateStatus()
91     {
92         let duplicatePropertyExistsBelow = (cssProperty) => {
93             let propertyFound = false;
94
95             for (let property of this._property.ownerStyle.properties) {
96                 if (property === cssProperty)
97                     propertyFound = true;
98                 else if (property.name === cssProperty.name && propertyFound)
99                     return true;
100             }
101
102             return false;
103         };
104
105         let classNames = [WI.SpreadsheetStyleProperty.StyleClassName];
106         let elementTitle = "";
107
108         if (this._property.overridden) {
109             classNames.push("overridden");
110             if (duplicatePropertyExistsBelow(this._property)) {
111                 classNames.push("has-warning");
112                 elementTitle = WI.UIString("Duplicate property");
113             }
114         }
115
116         if (this._property.implicit)
117             classNames.push("implicit");
118
119         if (this._property.ownerStyle.inherited && !this._property.inherited)
120             classNames.push("not-inherited");
121
122         if (!this._property.valid && this._property.hasOtherVendorNameOrKeyword())
123             classNames.push("other-vendor");
124         else if (this._hasInvalidVariableValue || (!this._property.valid && this._property.value !== "")) {
125             let propertyNameIsValid = false;
126             if (WI.CSSCompletions.cssNameCompletions)
127                 propertyNameIsValid = WI.CSSCompletions.cssNameCompletions.isValidPropertyName(this._property.name);
128
129             classNames.push("has-warning");
130
131             if (!propertyNameIsValid) {
132                 classNames.push("invalid-name");
133                 elementTitle = WI.UIString("Unsupported property name");
134             } else {
135                 classNames.push("invalid-value");
136                 elementTitle = WI.UIString("Unsupported property value");
137             }
138         }
139
140         if (!this._property.enabled)
141             classNames.push("disabled");
142
143         this._element.className = classNames.join(" ");
144         this._element.title = elementTitle;
145     }
146
147     applyFilter(filterText)
148     {
149         let matchesName = this._nameElement.textContent.includes(filterText);
150         let matchesValue = this._valueElement.textContent.includes(filterText);
151         let matches = matchesName || matchesValue;
152         this._contentElement.classList.toggle(WI.GeneralStyleDetailsSidebarPanel.FilterMatchSectionClassName, matches);
153         this._contentElement.classList.toggle(WI.GeneralStyleDetailsSidebarPanel.NoFilterMatchInPropertyClassName, !matches);
154         return matches;
155     }
156
157     // Private
158
159     _remove(replacement = "")
160     {
161         this.element.remove();
162
163         if (replacement)
164             this._property.replaceWithText(replacement);
165         else
166             this._property.remove();
167
168         this.detached();
169
170         if (this._delegate && typeof this._delegate.spreadsheetStylePropertyRemoved === "function")
171             this._delegate.spreadsheetStylePropertyRemoved(this);
172     }
173
174     _update()
175     {
176         this.element.removeChildren();
177
178         if (this._property.editable) {
179             this._checkboxElement = this.element.appendChild(document.createElement("input"));
180             this._checkboxElement.classList.add("property-toggle");
181             this._checkboxElement.type = "checkbox";
182             this._checkboxElement.checked = this._property.enabled;
183             this._checkboxElement.tabIndex = -1;
184             this._checkboxElement.addEventListener("click", (event) => {
185                 event.stopPropagation();
186                 let disabled = !this._checkboxElement.checked;
187                 this._property.commentOut(disabled);
188                 this._update();
189             });
190         }
191
192         this._contentElement = this.element.appendChild(document.createElement("span"));
193         this._contentElement.className = "content";
194
195         if (!this._property.enabled)
196             this._contentElement.append("/* ");
197
198         this._nameElement = this._contentElement.appendChild(document.createElement("span"));
199         this._nameElement.classList.add("name");
200         this._nameElement.textContent = this._property.name;
201
202         let colonElement = this._contentElement.appendChild(document.createElement("span"));
203         colonElement.textContent = ": ";
204
205         this._valueElement = this._contentElement.appendChild(document.createElement("span"));
206         this._valueElement.classList.add("value");
207         this._renderValue(this._property.rawValue);
208
209         if (this._property.editable && this._property.enabled) {
210             this._nameElement.tabIndex = 0;
211             this._nameElement.addEventListener("beforeinput", this._handleNameBeforeInput.bind(this));
212             this._nameElement.addEventListener("paste", this._handleNamePaste.bind(this));
213
214             this._nameTextField = new WI.SpreadsheetTextField(this, this._nameElement, this._nameCompletionDataProvider.bind(this));
215
216             this._valueElement.tabIndex = 0;
217             this._valueElement.addEventListener("beforeinput", this._handleValueBeforeInput.bind(this));
218
219             this._valueTextField = new WI.SpreadsheetTextField(this, this._valueElement, this._valueCompletionDataProvider.bind(this));
220         }
221
222         if (this._property.editable) {
223             this._setupJumpToSymbol(this._nameElement);
224             this._setupJumpToSymbol(this._valueElement);
225         }
226
227         let semicolonElement = this._contentElement.appendChild(document.createElement("span"));
228         semicolonElement.textContent = ";";
229
230         if (this._property.enabled) {
231             this._warningElement = this.element.appendChild(document.createElement("span"));
232             this._warningElement.className = "warning";
233         } else
234             this._contentElement.append(" */");
235
236         this.updateStatus();
237     }
238
239     // SpreadsheetTextField delegate
240
241     spreadsheetTextFieldWillStartEditing(textField)
242     {
243         let isEditingName = textField === this._nameTextField;
244         textField.value = isEditingName ? this._property.name : this._property.rawValue;
245     }
246
247     spreadsheetTextFieldDidChange(textField)
248     {
249         if (textField === this._valueTextField)
250             this.debounce(WI.SpreadsheetStyleProperty.CommitCoalesceDelay)._handleValueChange();
251         else if (textField === this._nameTextField)
252             this.debounce(WI.SpreadsheetStyleProperty.CommitCoalesceDelay)._handleNameChange();
253     }
254
255     spreadsheetTextFieldDidCommit(textField, {direction})
256     {
257         let propertyName = this._nameTextField.value.trim();
258         let propertyValue = this._valueTextField.value.trim();
259         let willRemoveProperty = false;
260         let isEditingName = textField === this._nameTextField;
261
262         if (!propertyName || (!propertyValue && !isEditingName && direction === "forward"))
263             willRemoveProperty = true;
264
265         if (!isEditingName && !willRemoveProperty)
266             this._renderValue(propertyValue);
267
268         if (direction === "forward") {
269             if (isEditingName && !willRemoveProperty) {
270                 // Move focus from the name to the value.
271                 this._valueTextField.startEditing();
272                 return;
273             }
274         } else {
275             if (!isEditingName) {
276                 // Move focus from the value to the name.
277                 this._nameTextField.startEditing();
278                 return;
279             }
280         }
281
282         if (typeof this._delegate.spreadsheetStylePropertyFocusMoved === "function") {
283             // Move focus away from the current property, to the next or previous one, if exists, or to the next or previous rule, if exists.
284             this._delegate.spreadsheetStylePropertyFocusMoved(this, {direction, willRemoveProperty});
285         }
286
287         if (willRemoveProperty)
288             this._remove();
289     }
290
291     spreadsheetTextFieldDidBlur(textField, event)
292     {
293         let focusedOutsideThisProperty = event.relatedTarget !== this._nameElement && event.relatedTarget !== this._valueElement;
294         if (focusedOutsideThisProperty && (!this._nameTextField.value.trim() || !this._valueTextField.value.trim())) {
295             this._remove();
296             return;
297         }
298
299         if (textField === this._valueTextField)
300             this._renderValue(this._valueElement.textContent);
301     }
302
303     spreadsheetTextFieldDidBackspace(textField)
304     {
305         if (textField === this._nameTextField)
306             this.spreadsheetTextFieldDidCommit(textField, {direction: "backward"});
307         else if (textField === this._valueTextField)
308             this._nameTextField.startEditing();
309     }
310
311     // Private
312
313     _renderValue(value)
314     {
315         this._hasInvalidVariableValue = false;
316
317         const maxValueLength = 150;
318         let tokens = WI.tokenizeCSSValue(value);
319
320         if (this._property.enabled) {
321             // FIXME: <https://webkit.org/b/178636> Web Inspector: Styles: Make inline widgets work with CSS functions (var(), calc(), etc.)
322             tokens = this._addGradientTokens(tokens);
323             tokens = this._addColorTokens(tokens);
324             tokens = this._addTimingFunctionTokens(tokens, "cubic-bezier");
325             tokens = this._addTimingFunctionTokens(tokens, "spring");
326             tokens = this._addVariableTokens(tokens);
327         }
328
329         tokens = tokens.map((token) => {
330             if (token instanceof Element)
331                 return token;
332
333             let className = "";
334
335             if (token.type) {
336                 if (token.type.includes("string"))
337                     className = "token-string";
338                 else if (token.type.includes("link"))
339                     className = "token-link";
340                 else if (token.type.includes("comment"))
341                     className = "token-comment";
342             }
343
344             if (className) {
345                 let span = document.createElement("span");
346                 span.classList.add(className);
347                 span.textContent = token.value.truncateMiddle(maxValueLength);
348                 return span;
349             }
350
351             return token.value;
352         });
353
354         this._valueElement.removeChildren();
355         this._valueElement.append(...tokens);
356     }
357
358     _createInlineSwatch(type, text, valueObject)
359     {
360         let tokenElement = document.createElement("span");
361         let innerElement = document.createElement("span");
362         innerElement.textContent = text;
363
364         let readOnly = !this._property.editable;
365         let swatch = new WI.InlineSwatch(type, valueObject, readOnly);
366
367         swatch.addEventListener(WI.InlineSwatch.Event.ValueChanged, (event) => {
368             let value = event.data.value && event.data.value.toString();
369             if (!value)
370                 return;
371
372             innerElement.textContent = value;
373             this._handleValueChange();
374         }, this);
375
376         if (typeof this._delegate.stylePropertyInlineSwatchActivated === "function") {
377             swatch.addEventListener(WI.InlineSwatch.Event.Activated, () => {
378                 this._delegate.stylePropertyInlineSwatchActivated();
379             });
380         }
381
382         if (typeof this._delegate.stylePropertyInlineSwatchDeactivated === "function") {
383             swatch.addEventListener(WI.InlineSwatch.Event.Deactivated, () => {
384                 this._delegate.stylePropertyInlineSwatchDeactivated();
385             });
386         }
387
388         tokenElement.append(swatch.element, innerElement);
389
390         // Prevent the value from editing when clicking on the swatch.
391         swatch.element.addEventListener("mousedown", (event) => { event.stop(); });
392
393         return tokenElement;
394     }
395
396     _addGradientTokens(tokens)
397     {
398         let gradientRegex = /^(repeating-)?(linear|radial)-gradient$/i;
399         let newTokens = [];
400         let gradientStartIndex = NaN;
401         let openParenthesis = 0;
402
403         for (let i = 0; i < tokens.length; i++) {
404             let token = tokens[i];
405             if (token.type && token.type.includes("atom") && gradientRegex.test(token.value)) {
406                 gradientStartIndex = i;
407                 openParenthesis = 0;
408             } else if (token.value === "(" && !isNaN(gradientStartIndex))
409                 openParenthesis++;
410             else if (token.value === ")" && !isNaN(gradientStartIndex)) {
411                 openParenthesis--;
412                 if (openParenthesis > 0) {
413                     // Matched a CSS function inside of the gradient.
414                     continue;
415                 }
416
417                 let rawTokens = tokens.slice(gradientStartIndex, i + 1);
418                 let text = rawTokens.map((token) => token.value).join("");
419                 let gradient = WI.Gradient.fromString(text);
420                 if (gradient)
421                     newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Gradient, text, gradient));
422                 else
423                     newTokens.push(...rawTokens);
424
425                 gradientStartIndex = NaN;
426             } else if (isNaN(gradientStartIndex))
427                 newTokens.push(token);
428         }
429
430         return newTokens;
431     }
432
433     _addColorTokens(tokens)
434     {
435         let newTokens = [];
436
437         let pushPossibleColorToken = (text, ...rawTokens) => {
438             let color = WI.Color.fromString(text);
439             if (color)
440                 newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Color, text, color));
441             else
442                 newTokens.push(...rawTokens);
443         };
444
445         let colorFunctionStartIndex = NaN;
446
447         for (let i = 0; i < tokens.length; i++) {
448             let token = tokens[i];
449             if (token.type && token.type.includes("hex-color")) {
450                 // Hex
451                 pushPossibleColorToken(token.value, token);
452             } else if (WI.Color.FunctionNames.has(token.value) && token.type && (token.type.includes("atom") || token.type.includes("keyword"))) {
453                 // Color Function start
454                 colorFunctionStartIndex = i;
455             } else if (isNaN(colorFunctionStartIndex) && token.type && token.type.includes("keyword")) {
456                 // Color keyword
457                 pushPossibleColorToken(token.value, token);
458             } else if (!isNaN(colorFunctionStartIndex)) {
459                 // Color Function end
460                 if (token.value !== ")")
461                     continue;
462
463                 let rawTokens = tokens.slice(colorFunctionStartIndex, i + 1);
464                 let text = rawTokens.map((token) => token.value).join("");
465                 pushPossibleColorToken(text, ...rawTokens);
466                 colorFunctionStartIndex = NaN;
467             } else
468                 newTokens.push(token);
469         }
470
471         return newTokens;
472     }
473
474     _addTimingFunctionTokens(tokens, tokenType)
475     {
476         let newTokens = [];
477         let startIndex = NaN;
478         let openParenthesis = 0;
479
480         for (let i = 0; i < tokens.length; i++) {
481             let token = tokens[i];
482             if (token.value === tokenType && token.type && token.type.includes("atom")) {
483                 startIndex = i;
484                 openParenthesis = 0;
485             } else if (token.value === "(" && !isNaN(startIndex))
486                 openParenthesis++;
487             else if (token.value === ")" && !isNaN(startIndex)) {
488
489                 openParenthesis--;
490                 if (openParenthesis > 0)
491                     continue;
492
493                 let rawTokens = tokens.slice(startIndex, i + 1);
494                 let text = rawTokens.map((token) => token.value).join("");
495
496                 let valueObject;
497                 let inlineSwatchType;
498                 if (tokenType === "cubic-bezier") {
499                     valueObject = WI.CubicBezier.fromString(text);
500                     inlineSwatchType = WI.InlineSwatch.Type.Bezier;
501                 } else if (tokenType === "spring") {
502                     valueObject = WI.Spring.fromString(text);
503                     inlineSwatchType = WI.InlineSwatch.Type.Spring;
504                 }
505
506                 if (valueObject)
507                     newTokens.push(this._createInlineSwatch(inlineSwatchType, text, valueObject));
508                 else
509                     newTokens.push(...rawTokens);
510
511                 startIndex = NaN;
512             } else if (isNaN(startIndex))
513                 newTokens.push(token);
514         }
515
516         return newTokens;
517     }
518
519     _addVariableTokens(tokens)
520     {
521         let newTokens = [];
522         let startIndex = NaN;
523         let openParenthesis = 0;
524
525         for (let i = 0; i < tokens.length; i++) {
526             let token = tokens[i];
527             if (token.value === "var" && token.type && token.type.includes("atom")) {
528                 startIndex = i;
529                 openParenthesis = 0;
530             } else if (token.value === "(" && !isNaN(startIndex))
531                 ++openParenthesis;
532             else if (token.value === ")" && !isNaN(startIndex)) {
533                 --openParenthesis;
534                 if (openParenthesis > 0)
535                     continue;
536
537                 let rawTokens = tokens.slice(startIndex, i + 1);
538                 let tokenValues = rawTokens.map((token) => token.value);
539                 let variableName = tokenValues.find((value, i) => value.startsWith("--") && /\bvariable-2\b/.test(rawTokens[i].type));
540
541                 const dontCreateIfMissing = true;
542                 let variableProperty = this._property.ownerStyle.nodeStyles.computedStyle.propertyForName(variableName, dontCreateIfMissing);
543                 if (variableProperty) {
544                     let valueObject = variableProperty.value.trim();
545                     newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Variable, tokenValues.join(""), valueObject));
546                 } else {
547                     this._hasInvalidVariableValue = true;
548                     newTokens.push(...rawTokens);
549                 }
550
551                 startIndex = NaN;
552             } else if (isNaN(startIndex))
553                 newTokens.push(token);
554         }
555
556         return newTokens;
557     }
558
559     _handleNameChange()
560     {
561         this._property.name = this._nameElement.textContent.trim();
562     }
563
564     _handleValueChange()
565     {
566         this._property.rawValue = this._valueElement.textContent.trim();
567     }
568
569     _handleNameBeforeInput(event)
570     {
571         if (event.data !== ":" || event.inputType !== "insertText")
572             return;
573
574         event.preventDefault();
575         this._nameTextField.discardCompletion();
576         this._valueTextField.startEditing();
577     }
578
579     _handleNamePaste(event)
580     {
581         let text = event.clipboardData.getData("text/plain");
582         if (!text || !text.includes(":"))
583             return;
584
585         event.preventDefault();
586
587         this._remove(text);
588
589         if (this._delegate.spreadsheetStylePropertyAddBlankPropertySoon) {
590             this._delegate.spreadsheetStylePropertyAddBlankPropertySoon(this, {
591                 index: parseInt(this._element.dataset.propertyIndex) + 1,
592             });
593         }
594     }
595
596     _nameCompletionDataProvider(prefix)
597     {
598         return WI.CSSCompletions.cssNameCompletions.startsWith(prefix);
599     }
600
601     _handleValueBeforeInput(event)
602     {
603         if (event.data !== ";" || event.inputType !== "insertText")
604             return;
605
606         let text = this._valueTextField.valueWithoutSuggestion();
607         let selection = window.getSelection();
608         if (!selection.rangeCount || selection.getRangeAt(0).endOffset !== text.length)
609             return;
610
611         // Find the first and last index (if any) of a quote character to ensure that the string
612         // doesn't contain unbalanced quotes. If so, then there's no way that the semicolon could be
613         // part of a string within the value, so we can assume that it's the property "terminator".
614         const quoteRegex = /["']/g;
615         let start = -1;
616         let end = text.length;
617         let match = null;
618         while (match = quoteRegex.exec(text)) {
619             if (start < 0)
620                 start = match.index;
621             end = match.index + 1;
622         }
623
624         if (start !== -1 && !text.substring(start, end).hasMatchingEscapedQuotes())
625             return;
626
627         event.preventDefault();
628         this._valueTextField.stopEditing();
629         this.spreadsheetTextFieldDidCommit(this._valueTextField, {direction: "forward"});
630     }
631
632     _valueCompletionDataProvider(prefix)
633     {
634         let propertyName = this._nameElement.textContent.trim();
635         return WI.CSSKeywordCompletions.forProperty(propertyName).startsWith(prefix);
636     }
637
638     _setupJumpToSymbol(element)
639     {
640         element.addEventListener("mousedown", (event) => {
641             if (event.button !== 0)
642                 return;
643
644             if (!WI.modifierKeys.metaKey)
645                 return;
646
647             if (element.isContentEditable)
648                 return;
649
650             let sourceCodeLocation = null;
651             if (this._property.ownerStyle.ownerRule)
652                 sourceCodeLocation = this._property.ownerStyle.ownerRule.sourceCodeLocation;
653
654             if (!sourceCodeLocation)
655                 return;
656
657             let range = this._property.styleSheetTextRange;
658             const options = {
659                 ignoreNetworkTab: true,
660                 ignoreSearchTab: true,
661             };
662             let sourceCode = sourceCodeLocation.sourceCode;
663             WI.showSourceCodeLocation(sourceCode.createSourceCodeLocation(range.startLine, range.startColumn), options);
664         });
665     }
666 };
667
668 WI.SpreadsheetStyleProperty.StyleClassName = "property";
669
670 WI.SpreadsheetStyleProperty.CommitCoalesceDelay = 250;