Web Inspector: Styles Redesign: Display warnings
[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, index)
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         this._element.dataset.propertyIndex = index;
38
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._update();
48         property.addEventListener(WI.CSSProperty.Event.OverriddenStatusChanged, this.updateStatus, this);
49         property.addEventListener(WI.CSSProperty.Event.Changed, this.updateStatus, this);
50     }
51
52     // Public
53
54     get element() { return this._element; }
55     get nameTextField() { return this._nameTextField; }
56     get valueTextField() { return this._valueTextField; }
57
58     detached()
59     {
60         this._property.__propertyView = null;
61
62         if (this._nameTextField)
63             this._nameTextField.detached();
64
65         if (this._valueTextField)
66             this._valueTextField.detached();
67     }
68
69     highlight()
70     {
71         this._element.classList.add("highlighted");
72     }
73
74     updateStatus()
75     {
76         let duplicatePropertyExistsBelow = (cssProperty) => {
77             let propertyFound = false;
78
79             for (let property of this._property.ownerStyle.properties) {
80                 if (property === cssProperty)
81                     propertyFound = true;
82                 else if (property.name === cssProperty.name && propertyFound)
83                     return true;
84             }
85
86             return false;
87         };
88
89         let classNames = [WI.SpreadsheetStyleProperty.StyleClassName];
90         let elementTitle = "";
91
92         if (this._property.overridden) {
93             classNames.push("overridden");
94             if (duplicatePropertyExistsBelow(this._property)) {
95                 classNames.push("has-warning");
96                 elementTitle = WI.UIString("Duplicate property");
97             }
98         }
99
100         if (this._property.implicit)
101             classNames.push("implicit");
102
103         if (this._property.ownerStyle.inherited && !this._property.inherited)
104             classNames.push("not-inherited");
105
106         if (!this._property.valid && this._property.hasOtherVendorNameOrKeyword())
107             classNames.push("other-vendor");
108         else if (!this._property.valid && this._property.value !== "") {
109             let propertyNameIsValid = false;
110             if (WI.CSSCompletions.cssNameCompletions)
111                 propertyNameIsValid = WI.CSSCompletions.cssNameCompletions.isValidPropertyName(this._property.name);
112
113             classNames.push("has-warning");
114
115             if (!propertyNameIsValid) {
116                 classNames.push("invalid-name");
117                 elementTitle = WI.UIString("Unsupported property name");
118             } else {
119                 classNames.push("invalid-value");
120                 elementTitle = WI.UIString("Unsupported property value");
121             }
122         }
123
124         if (!this._property.enabled)
125             classNames.push("disabled");
126
127         this._element.className = classNames.join(" ");
128         this._element.title = elementTitle;
129     }
130
131     // Private
132
133     _remove()
134     {
135         this.element.remove();
136         this._property.remove();
137         this.detached();
138
139         if (this._delegate && typeof this._delegate.spreadsheetStylePropertyRemoved === "function")
140             this._delegate.spreadsheetStylePropertyRemoved(this);
141     }
142
143     _update()
144     {
145         this.element.removeChildren();
146
147         if (this._property.editable) {
148             this._checkboxElement = this.element.appendChild(document.createElement("input"));
149             this._checkboxElement.classList.add("property-toggle");
150             this._checkboxElement.type = "checkbox";
151             this._checkboxElement.checked = this._property.enabled;
152             this._checkboxElement.tabIndex = -1;
153             this._checkboxElement.addEventListener("change", () => {
154                 let disabled = !this._checkboxElement.checked;
155                 this._property.commentOut(disabled);
156                 this._update();
157             });
158         }
159
160         if (!this._property.enabled)
161             this.element.append("/* ");
162
163         this._nameElement = this.element.appendChild(document.createElement("span"));
164         this._nameElement.classList.add("name");
165         this._nameElement.textContent = this._property.name;
166
167         this.element.append(": ");
168
169         this._valueElement = this.element.appendChild(document.createElement("span"));
170         this._valueElement.classList.add("value");
171         this._renderValue(this._property.rawValue);
172
173         if (this._property.editable && this._property.enabled) {
174             this._nameElement.tabIndex = 0;
175             this._nameTextField = new WI.SpreadsheetTextField(this, this._nameElement, this._nameCompletionDataProvider.bind(this));
176
177             this._valueElement.tabIndex = 0;
178             this._valueTextField = new WI.SpreadsheetTextField(this, this._valueElement, this._valueCompletionDataProvider.bind(this));
179         }
180
181         if (this._property.editable) {
182             this._setupJumpToSymbol(this._nameElement);
183             this._setupJumpToSymbol(this._valueElement);
184         }
185
186         this.element.append(";");
187
188         if (this._property.enabled) {
189             this._warningElement = this.element.appendChild(document.createElement("span"));
190             this._warningElement.className = "warning";
191         } else
192             this.element.append(" */");
193
194         this.updateStatus();
195     }
196
197     // SpreadsheetTextField delegate
198
199     spreadsheetTextFieldWillStartEditing(textField)
200     {
201         let isEditingName = textField === this._nameTextField;
202         textField.value = isEditingName ? this._property.name : this._property.rawValue;
203     }
204
205     spreadsheetTextFieldDidChange(textField)
206     {
207         if (textField === this._valueTextField)
208             this.debounce(WI.SpreadsheetStyleProperty.CommitCoalesceDelay)._handleValueChange();
209         else if (textField === this._nameTextField)
210             this.debounce(WI.SpreadsheetStyleProperty.CommitCoalesceDelay)._handleNameChange();
211     }
212
213     spreadsheetTextFieldDidCommit(textField, {direction})
214     {
215         let propertyName = this._nameTextField.value.trim();
216         let propertyValue = this._valueTextField.value.trim();
217         let willRemoveProperty = false;
218         let newlyAdded = this._valueTextField.valueBeforeEditing === "";
219
220         // Remove a property with an empty name or value. However, a newly added property
221         // has an empty name and value at first. Don't remove it when moving focus from
222         // the name to the value for the first time.
223         if (!propertyName || (!newlyAdded && !propertyValue))
224             willRemoveProperty = true;
225
226         let isEditingName = textField === this._nameTextField;
227
228         if (!isEditingName && !willRemoveProperty)
229             this._renderValue(propertyValue);
230
231         if (direction === "forward") {
232             if (isEditingName && !willRemoveProperty) {
233                 // Move focus from the name to the value.
234                 this._valueTextField.startEditing();
235                 return;
236             }
237         } else {
238             if (!isEditingName) {
239                 // Move focus from the value to the name.
240                 this._nameTextField.startEditing();
241                 return;
242             }
243         }
244
245         if (typeof this._delegate.spreadsheetCSSStyleDeclarationEditorFocusMoved === "function") {
246             // Move focus away from the current property, to the next or previous one, if exists, or to the next or previous rule, if exists.
247             this._delegate.spreadsheetCSSStyleDeclarationEditorFocusMoved({direction, willRemoveProperty, movedFromProperty: this});
248         }
249
250         if (willRemoveProperty)
251             this._remove();
252     }
253
254     spreadsheetTextFieldDidBlur(textField)
255     {
256         if (textField.value.trim() === "")
257             this._remove();
258         else if (textField === this._valueTextField)
259             this._renderValue(this._valueElement.textContent);
260     }
261
262     // Private
263
264     _renderValue(value)
265     {
266         const maxValueLength = 150;
267         let tokens = WI.tokenizeCSSValue(value);
268
269         if (this._property.enabled) {
270             // FIXME: <https://webkit.org/b/178636> Web Inspector: Styles: Make inline widgets work with CSS functions (var(), calc(), etc.)
271             tokens = this._addGradientTokens(tokens);
272             tokens = this._addColorTokens(tokens);
273             tokens = this._addTimingFunctionTokens(tokens, "cubic-bezier");
274             tokens = this._addTimingFunctionTokens(tokens, "spring");
275         }
276
277         tokens = tokens.map((token) => {
278             if (token instanceof Element)
279                 return token;
280
281             let className = "";
282
283             if (token.type) {
284                 if (token.type.includes("string"))
285                     className = "token-string";
286                 else if (token.type.includes("link"))
287                     className = "token-link";
288                 else if (token.type.includes("comment"))
289                     className = "token-comment";
290             }
291
292             if (className) {
293                 let span = document.createElement("span");
294                 span.classList.add(className);
295                 span.textContent = token.value.trimMiddle(maxValueLength);
296                 return span;
297             }
298
299             return token.value;
300         });
301
302         this._valueElement.removeChildren();
303         this._valueElement.append(...tokens);
304     }
305
306     _createInlineSwatch(type, text, valueObject)
307     {
308         let tokenElement = document.createElement("span");
309         let innerElement = document.createElement("span");
310         innerElement.textContent = text;
311
312         let readOnly = !this._property.editable;
313         let swatch = new WI.InlineSwatch(type, valueObject, readOnly);
314
315         swatch.addEventListener(WI.InlineSwatch.Event.ValueChanged, (event) => {
316             let value = event.data.value && event.data.value.toString();
317             if (!value)
318                 return;
319
320             innerElement.textContent = value;
321             this._handleValueChange();
322         }, this);
323
324         tokenElement.append(swatch.element, innerElement);
325
326         // Prevent the value from editing when clicking on the swatch.
327         swatch.element.addEventListener("mousedown", (event) => { event.stop(); });
328
329         return tokenElement;
330     }
331
332     _addGradientTokens(tokens)
333     {
334         let gradientRegex = /^(repeating-)?(linear|radial)-gradient$/i;
335         let newTokens = [];
336         let gradientStartIndex = NaN;
337         let openParenthesis = 0;
338
339         for (let i = 0; i < tokens.length; i++) {
340             let token = tokens[i];
341             if (token.type && token.type.includes("atom") && gradientRegex.test(token.value)) {
342                 gradientStartIndex = i;
343                 openParenthesis = 0;
344             } else if (token.value === "(" && !isNaN(gradientStartIndex))
345                 openParenthesis++;
346             else if (token.value === ")" && !isNaN(gradientStartIndex)) {
347                 openParenthesis--;
348                 if (openParenthesis > 0) {
349                     // Matched a CSS function inside of the gradient.
350                     continue;
351                 }
352
353                 let rawTokens = tokens.slice(gradientStartIndex, i + 1);
354                 let text = rawTokens.map((token) => token.value).join("");
355                 let gradient = WI.Gradient.fromString(text);
356                 if (gradient)
357                     newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Gradient, text, gradient));
358                 else
359                     newTokens.push(...rawTokens);
360
361                 gradientStartIndex = NaN;
362             } else if (isNaN(gradientStartIndex))
363                 newTokens.push(token);
364         }
365
366         return newTokens;
367     }
368
369     _addColorTokens(tokens)
370     {
371         let newTokens = [];
372
373         let pushPossibleColorToken = (text, ...rawTokens) => {
374             let color = WI.Color.fromString(text);
375             if (color)
376                 newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Color, text, color));
377             else
378                 newTokens.push(...rawTokens);
379         };
380
381         let colorFunctionStartIndex = NaN;
382
383         for (let i = 0; i < tokens.length; i++) {
384             let token = tokens[i];
385             if (token.type && token.type.includes("hex-color")) {
386                 // Hex
387                 pushPossibleColorToken(token.value, token);
388             } else if (WI.Color.FunctionNames.has(token.value) && token.type && (token.type.includes("atom") || token.type.includes("keyword"))) {
389                 // Color Function start
390                 colorFunctionStartIndex = i;
391             } else if (isNaN(colorFunctionStartIndex) && token.type && token.type.includes("keyword")) {
392                 // Color keyword
393                 pushPossibleColorToken(token.value, token);
394             } else if (!isNaN(colorFunctionStartIndex)) {
395                 // Color Function end
396                 if (token.value !== ")")
397                     continue;
398
399                 let rawTokens = tokens.slice(colorFunctionStartIndex, i + 1);
400                 let text = rawTokens.map((token) => token.value).join("");
401                 pushPossibleColorToken(text, ...rawTokens);
402                 colorFunctionStartIndex = NaN;
403             } else
404                 newTokens.push(token);
405         }
406
407         return newTokens;
408     }
409
410     _addTimingFunctionTokens(tokens, tokenType)
411     {
412         let newTokens = [];
413         let startIndex = NaN;
414         let openParenthesis = 0;
415
416         for (let i = 0; i < tokens.length; i++) {
417             let token = tokens[i];
418             if (token.value === tokenType && token.type && token.type.includes("atom")) {
419                 startIndex = i;
420                 openParenthesis = 0;
421             } else if (token.value === "(" && !isNaN(startIndex))
422                 openParenthesis++;
423             else if (token.value === ")" && !isNaN(startIndex)) {
424
425                 openParenthesis--;
426                 if (openParenthesis > 0)
427                     continue;
428
429                 let rawTokens = tokens.slice(startIndex, i + 1);
430                 let text = rawTokens.map((token) => token.value).join("");
431
432                 let valueObject;
433                 let inlineSwatchType;
434                 if (tokenType === "cubic-bezier") {
435                     valueObject = WI.CubicBezier.fromString(text);
436                     inlineSwatchType = WI.InlineSwatch.Type.Bezier;
437                 } else if (tokenType === "spring") {
438                     valueObject = WI.Spring.fromString(text);
439                     inlineSwatchType = WI.InlineSwatch.Type.Spring;
440                 }
441
442                 if (valueObject)
443                     newTokens.push(this._createInlineSwatch(inlineSwatchType, text, valueObject));
444                 else
445                     newTokens.push(...rawTokens);
446
447                 startIndex = NaN;
448             } else if (isNaN(startIndex))
449                 newTokens.push(token);
450         }
451
452         return newTokens;
453     }
454
455     _handleNameChange()
456     {
457         this._property.name = this._nameElement.textContent.trim();
458     }
459
460     _handleValueChange()
461     {
462         this._property.rawValue = this._valueElement.textContent.trim();
463     }
464
465     _nameCompletionDataProvider(prefix)
466     {
467         return WI.CSSCompletions.cssNameCompletions.startsWith(prefix);
468     }
469
470     _valueCompletionDataProvider(prefix)
471     {
472         return WI.CSSKeywordCompletions.forProperty(this._property.name).startsWith(prefix);
473     }
474
475     _setupJumpToSymbol(element)
476     {
477         element.addEventListener("mousedown", (event) => {
478             if (event.button !== 0)
479                 return;
480
481             if (!WI.modifierKeys.metaKey)
482                 return;
483
484             if (element.isContentEditable)
485                 return;
486
487             let sourceCodeLocation = null;
488             if (this._property.ownerStyle.ownerRule)
489                 sourceCodeLocation = this._property.ownerStyle.ownerRule.sourceCodeLocation;
490
491             if (!sourceCodeLocation)
492                 return;
493
494             let range = this._property.styleSheetTextRange;
495             const options = {
496                 ignoreNetworkTab: true,
497                 ignoreSearchTab: true,
498             };
499             let sourceCode = sourceCodeLocation.sourceCode;
500             WI.showSourceCodeLocation(sourceCode.createSourceCodeLocation(range.startLine, range.startColumn), options);
501         });
502     }
503 };
504
505 WI.SpreadsheetStyleProperty.StyleClassName = "property";
506
507 WI.SpreadsheetStyleProperty.CommitCoalesceDelay = 250;