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