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