Web Inspector: Styles Redesign: data corruption when updating values quickly
[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         if (typeof this._delegate.stylePropertyInlineSwatchActivated === "function") {
359             swatch.addEventListener(WI.InlineSwatch.Event.Activated, () => {
360                 this._delegate.stylePropertyInlineSwatchActivated();
361             });
362         }
363
364         if (typeof this._delegate.stylePropertyInlineSwatchDeactivated === "function") {
365             swatch.addEventListener(WI.InlineSwatch.Event.Deactivated, () => {
366                 this._delegate.stylePropertyInlineSwatchDeactivated();
367             });
368         }
369
370         tokenElement.append(swatch.element, innerElement);
371
372         // Prevent the value from editing when clicking on the swatch.
373         swatch.element.addEventListener("mousedown", (event) => { event.stop(); });
374
375         return tokenElement;
376     }
377
378     _addGradientTokens(tokens)
379     {
380         let gradientRegex = /^(repeating-)?(linear|radial)-gradient$/i;
381         let newTokens = [];
382         let gradientStartIndex = NaN;
383         let openParenthesis = 0;
384
385         for (let i = 0; i < tokens.length; i++) {
386             let token = tokens[i];
387             if (token.type && token.type.includes("atom") && gradientRegex.test(token.value)) {
388                 gradientStartIndex = i;
389                 openParenthesis = 0;
390             } else if (token.value === "(" && !isNaN(gradientStartIndex))
391                 openParenthesis++;
392             else if (token.value === ")" && !isNaN(gradientStartIndex)) {
393                 openParenthesis--;
394                 if (openParenthesis > 0) {
395                     // Matched a CSS function inside of the gradient.
396                     continue;
397                 }
398
399                 let rawTokens = tokens.slice(gradientStartIndex, i + 1);
400                 let text = rawTokens.map((token) => token.value).join("");
401                 let gradient = WI.Gradient.fromString(text);
402                 if (gradient)
403                     newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Gradient, text, gradient));
404                 else
405                     newTokens.push(...rawTokens);
406
407                 gradientStartIndex = NaN;
408             } else if (isNaN(gradientStartIndex))
409                 newTokens.push(token);
410         }
411
412         return newTokens;
413     }
414
415     _addColorTokens(tokens)
416     {
417         let newTokens = [];
418
419         let pushPossibleColorToken = (text, ...rawTokens) => {
420             let color = WI.Color.fromString(text);
421             if (color)
422                 newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Color, text, color));
423             else
424                 newTokens.push(...rawTokens);
425         };
426
427         let colorFunctionStartIndex = NaN;
428
429         for (let i = 0; i < tokens.length; i++) {
430             let token = tokens[i];
431             if (token.type && token.type.includes("hex-color")) {
432                 // Hex
433                 pushPossibleColorToken(token.value, token);
434             } else if (WI.Color.FunctionNames.has(token.value) && token.type && (token.type.includes("atom") || token.type.includes("keyword"))) {
435                 // Color Function start
436                 colorFunctionStartIndex = i;
437             } else if (isNaN(colorFunctionStartIndex) && token.type && token.type.includes("keyword")) {
438                 // Color keyword
439                 pushPossibleColorToken(token.value, token);
440             } else if (!isNaN(colorFunctionStartIndex)) {
441                 // Color Function end
442                 if (token.value !== ")")
443                     continue;
444
445                 let rawTokens = tokens.slice(colorFunctionStartIndex, i + 1);
446                 let text = rawTokens.map((token) => token.value).join("");
447                 pushPossibleColorToken(text, ...rawTokens);
448                 colorFunctionStartIndex = NaN;
449             } else
450                 newTokens.push(token);
451         }
452
453         return newTokens;
454     }
455
456     _addTimingFunctionTokens(tokens, tokenType)
457     {
458         let newTokens = [];
459         let startIndex = NaN;
460         let openParenthesis = 0;
461
462         for (let i = 0; i < tokens.length; i++) {
463             let token = tokens[i];
464             if (token.value === tokenType && token.type && token.type.includes("atom")) {
465                 startIndex = i;
466                 openParenthesis = 0;
467             } else if (token.value === "(" && !isNaN(startIndex))
468                 openParenthesis++;
469             else if (token.value === ")" && !isNaN(startIndex)) {
470
471                 openParenthesis--;
472                 if (openParenthesis > 0)
473                     continue;
474
475                 let rawTokens = tokens.slice(startIndex, i + 1);
476                 let text = rawTokens.map((token) => token.value).join("");
477
478                 let valueObject;
479                 let inlineSwatchType;
480                 if (tokenType === "cubic-bezier") {
481                     valueObject = WI.CubicBezier.fromString(text);
482                     inlineSwatchType = WI.InlineSwatch.Type.Bezier;
483                 } else if (tokenType === "spring") {
484                     valueObject = WI.Spring.fromString(text);
485                     inlineSwatchType = WI.InlineSwatch.Type.Spring;
486                 }
487
488                 if (valueObject)
489                     newTokens.push(this._createInlineSwatch(inlineSwatchType, text, valueObject));
490                 else
491                     newTokens.push(...rawTokens);
492
493                 startIndex = NaN;
494             } else if (isNaN(startIndex))
495                 newTokens.push(token);
496         }
497
498         return newTokens;
499     }
500
501     _addVariableTokens(tokens)
502     {
503         let newTokens = [];
504         let startIndex = NaN;
505         let openParenthesis = 0;
506
507         for (let i = 0; i < tokens.length; i++) {
508             let token = tokens[i];
509             if (token.value === "var" && token.type && token.type.includes("atom")) {
510                 startIndex = i;
511                 openParenthesis = 0;
512             } else if (token.value === "(" && !isNaN(startIndex))
513                 ++openParenthesis;
514             else if (token.value === ")" && !isNaN(startIndex)) {
515                 --openParenthesis;
516                 if (openParenthesis > 0)
517                     continue;
518
519                 let rawTokens = tokens.slice(startIndex, i + 1);
520                 let tokenValues = rawTokens.map((token) => token.value);
521                 let variableName = tokenValues.find((value, i) => value.startsWith("--") && /\bvariable-2\b/.test(rawTokens[i].type));
522
523                 const dontCreateIfMissing = true;
524                 let variableProperty = this._property.ownerStyle.nodeStyles.computedStyle.propertyForName(variableName, dontCreateIfMissing);
525                 if (variableProperty) {
526                     let valueObject = variableProperty.value.trim();
527                     newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Variable, tokenValues.join(""), valueObject));
528                 } else {
529                     this._hasInvalidVariableValue = true;
530                     newTokens.push(...rawTokens);
531                 }
532
533                 startIndex = NaN;
534             } else if (isNaN(startIndex))
535                 newTokens.push(token);
536         }
537
538         return newTokens;
539     }
540
541     _handleNameChange()
542     {
543         this._property.name = this._nameElement.textContent.trim();
544     }
545
546     _handleValueChange()
547     {
548         this._property.rawValue = this._valueElement.textContent.trim();
549     }
550
551     _handleNameBeforeInput(event)
552     {
553         if (event.data !== ":" || event.inputType !== "insertText")
554             return;
555
556         event.preventDefault();
557         this._nameTextField.discardCompletion();
558         this._valueTextField.startEditing();
559     }
560
561     _nameCompletionDataProvider(prefix)
562     {
563         return WI.CSSCompletions.cssNameCompletions.startsWith(prefix);
564     }
565
566     _handleValueBeforeInput(event)
567     {
568         if (event.data !== ";" || event.inputType !== "insertText")
569             return;
570
571         let text = this._valueTextField.valueWithoutSuggestion();
572         let selection = window.getSelection();
573         if (!selection.rangeCount || selection.getRangeAt(0).endOffset !== text.length)
574             return;
575
576         // Find the first and last index (if any) of a quote character to ensure that the string
577         // doesn't contain unbalanced quotes. If so, then there's no way that the semicolon could be
578         // part of a string within the value, so we can assume that it's the property "terminator".
579         const quoteRegex = /["']/g;
580         let start = -1;
581         let end = text.length;
582         let match = null;
583         while (match = quoteRegex.exec(text)) {
584             if (start < 0)
585                 start = match.index;
586             end = match.index + 1;
587         }
588
589         if (start !== -1 && !text.substring(start, end).hasMatchingEscapedQuotes())
590             return;
591
592         event.preventDefault();
593         this._valueTextField.stopEditing();
594         this.spreadsheetTextFieldDidCommit(this._valueTextField, {direction: "forward"});
595     }
596
597     _valueCompletionDataProvider(prefix)
598     {
599         let propertyName = this._nameElement.textContent.trim();
600         return WI.CSSKeywordCompletions.forProperty(propertyName).startsWith(prefix);
601     }
602
603     _setupJumpToSymbol(element)
604     {
605         element.addEventListener("mousedown", (event) => {
606             if (event.button !== 0)
607                 return;
608
609             if (!WI.modifierKeys.metaKey)
610                 return;
611
612             if (element.isContentEditable)
613                 return;
614
615             let sourceCodeLocation = null;
616             if (this._property.ownerStyle.ownerRule)
617                 sourceCodeLocation = this._property.ownerStyle.ownerRule.sourceCodeLocation;
618
619             if (!sourceCodeLocation)
620                 return;
621
622             let range = this._property.styleSheetTextRange;
623             const options = {
624                 ignoreNetworkTab: true,
625                 ignoreSearchTab: true,
626             };
627             let sourceCode = sourceCodeLocation.sourceCode;
628             WI.showSourceCodeLocation(sourceCode.createSourceCodeLocation(range.startLine, range.startColumn), options);
629         });
630     }
631 };
632
633 WI.SpreadsheetStyleProperty.StyleClassName = "property";
634
635 WI.SpreadsheetStyleProperty.CommitCoalesceDelay = 250;