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