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