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