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