blob: 8667db2a4b94394225558c8d0853f63ef5d45eda [file] [log] [blame]
/*
* Copyright (C) 2017 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
{
constructor(delegate, property)
{
super();
console.assert(property instanceof WI.CSSProperty);
this._delegate = delegate || null;
this._property = property;
this._element = document.createElement("div");
this._contentElement = null;
this._nameElement = null;
this._valueElement = null;
this._nameTextField = null;
this._valueTextField = null;
this._property.__propertyView = this;
this._hasInvalidVariableValue = false;
this._update();
property.addEventListener(WI.CSSProperty.Event.OverriddenStatusChanged, this.updateStatus, this);
property.addEventListener(WI.CSSProperty.Event.Changed, this.updateStatus, this);
}
// Public
get element() { return this._element; }
get nameTextField() { return this._nameTextField; }
get valueTextField() { return this._valueTextField; }
get enabled() { return this._property.enabled; }
set index(index)
{
this._element.dataset.propertyIndex = index;
}
detached()
{
this._property.__propertyView = null;
if (this._nameTextField)
this._nameTextField.detached();
if (this._valueTextField)
this._valueTextField.detached();
}
hidden()
{
if (this._nameTextField && this._nameTextField.editing)
this._nameTextField.element.blur();
else if (this._valueTextField && this._valueTextField.editing)
this._valueTextField.element.blur();
}
highlight()
{
this._element.classList.add("highlighted");
}
updateStatus()
{
let duplicatePropertyExistsBelow = (cssProperty) => {
let propertyFound = false;
for (let property of this._property.ownerStyle.properties) {
if (property === cssProperty)
propertyFound = true;
else if (property.name === cssProperty.name && propertyFound)
return true;
}
return false;
};
let classNames = [WI.SpreadsheetStyleProperty.StyleClassName];
let elementTitle = "";
if (this._property.overridden) {
classNames.push("overridden");
if (duplicatePropertyExistsBelow(this._property)) {
classNames.push("has-warning");
elementTitle = WI.UIString("Duplicate property");
}
}
if (this._property.implicit)
classNames.push("implicit");
if (this._property.ownerStyle.inherited && !this._property.inherited)
classNames.push("not-inherited");
if (!this._property.valid && this._property.hasOtherVendorNameOrKeyword())
classNames.push("other-vendor");
else if (this._hasInvalidVariableValue || (!this._property.valid && this._property.value !== "")) {
let propertyNameIsValid = false;
if (WI.CSSCompletions.cssNameCompletions)
propertyNameIsValid = WI.CSSCompletions.cssNameCompletions.isValidPropertyName(this._property.name);
classNames.push("has-warning");
if (!propertyNameIsValid) {
classNames.push("invalid-name");
elementTitle = WI.UIString("Unsupported property name");
} else {
classNames.push("invalid-value");
elementTitle = WI.UIString("Unsupported property value");
}
}
if (!this._property.enabled)
classNames.push("disabled");
this._element.className = classNames.join(" ");
this._element.title = elementTitle;
}
applyFilter(filterText)
{
let matchesName = this._nameElement.textContent.includes(filterText);
let matchesValue = this._valueElement.textContent.includes(filterText);
let matches = matchesName || matchesValue;
this._contentElement.classList.toggle(WI.GeneralStyleDetailsSidebarPanel.FilterMatchSectionClassName, matches);
this._contentElement.classList.toggle(WI.GeneralStyleDetailsSidebarPanel.NoFilterMatchInPropertyClassName, !matches);
return matches;
}
// Private
_remove(replacement = "")
{
this.element.remove();
if (replacement)
this._property.replaceWithText(replacement);
else
this._property.remove();
this.detached();
if (this._delegate && typeof this._delegate.spreadsheetStylePropertyRemoved === "function")
this._delegate.spreadsheetStylePropertyRemoved(this);
}
_update()
{
this.element.removeChildren();
if (this._property.editable) {
this._checkboxElement = this.element.appendChild(document.createElement("input"));
this._checkboxElement.classList.add("property-toggle");
this._checkboxElement.type = "checkbox";
this._checkboxElement.checked = this._property.enabled;
this._checkboxElement.tabIndex = -1;
this._checkboxElement.addEventListener("click", (event) => {
event.stopPropagation();
let disabled = !this._checkboxElement.checked;
this._property.commentOut(disabled);
this._update();
});
}
this._contentElement = this.element.appendChild(document.createElement("span"));
this._contentElement.className = "content";
if (!this._property.enabled)
this._contentElement.append("/* ");
this._nameElement = this._contentElement.appendChild(document.createElement("span"));
this._nameElement.classList.add("name");
this._nameElement.textContent = this._property.name;
let colonElement = this._contentElement.appendChild(document.createElement("span"));
colonElement.textContent = ": ";
this._valueElement = this._contentElement.appendChild(document.createElement("span"));
this._valueElement.classList.add("value");
this._renderValue(this._property.rawValue);
if (this._property.editable && this._property.enabled) {
this._nameElement.tabIndex = 0;
this._nameElement.addEventListener("beforeinput", this._handleNameBeforeInput.bind(this));
this._nameElement.addEventListener("paste", this._handleNamePaste.bind(this));
this._nameTextField = new WI.SpreadsheetTextField(this, this._nameElement, this._nameCompletionDataProvider.bind(this));
this._valueElement.tabIndex = 0;
this._valueElement.addEventListener("beforeinput", this._handleValueBeforeInput.bind(this));
this._valueTextField = new WI.SpreadsheetTextField(this, this._valueElement, this._valueCompletionDataProvider.bind(this));
}
if (this._property.editable) {
this._setupJumpToSymbol(this._nameElement);
this._setupJumpToSymbol(this._valueElement);
}
let semicolonElement = this._contentElement.appendChild(document.createElement("span"));
semicolonElement.textContent = ";";
if (this._property.enabled) {
this._warningElement = this.element.appendChild(document.createElement("span"));
this._warningElement.className = "warning";
} else
this._contentElement.append(" */");
this.updateStatus();
}
// SpreadsheetTextField delegate
spreadsheetTextFieldWillStartEditing(textField)
{
let isEditingName = textField === this._nameTextField;
textField.value = isEditingName ? this._property.name : this._property.rawValue;
}
spreadsheetTextFieldDidChange(textField)
{
if (textField === this._valueTextField)
this._handleValueChange();
else if (textField === this._nameTextField)
this._handleNameChange();
}
spreadsheetTextFieldDidCommit(textField, {direction})
{
let propertyName = this._nameTextField.value.trim();
let propertyValue = this._valueTextField.value.trim();
let willRemoveProperty = false;
let isEditingName = textField === this._nameTextField;
if (!propertyName || (!propertyValue && !isEditingName && direction === "forward"))
willRemoveProperty = true;
if (!isEditingName && !willRemoveProperty)
this._renderValue(propertyValue);
if (direction === "forward") {
if (isEditingName && !willRemoveProperty) {
// Move focus from the name to the value.
this._valueTextField.startEditing();
return;
}
} else {
if (!isEditingName) {
// Move focus from the value to the name.
this._nameTextField.startEditing();
return;
}
}
if (typeof this._delegate.spreadsheetStylePropertyFocusMoved === "function") {
// Move focus away from the current property, to the next or previous one, if exists, or to the next or previous rule, if exists.
this._delegate.spreadsheetStylePropertyFocusMoved(this, {direction, willRemoveProperty});
}
if (willRemoveProperty)
this._remove();
}
spreadsheetTextFieldDidBlur(textField, event)
{
let focusedOutsideThisProperty = event.relatedTarget !== this._nameElement && event.relatedTarget !== this._valueElement;
if (focusedOutsideThisProperty && (!this._nameTextField.value.trim() || !this._valueTextField.value.trim())) {
this._remove();
return;
}
if (textField === this._valueTextField)
this._renderValue(this._valueElement.textContent);
}
spreadsheetTextFieldDidBackspace(textField)
{
if (textField === this._nameTextField)
this.spreadsheetTextFieldDidCommit(textField, {direction: "backward"});
else if (textField === this._valueTextField)
this._nameTextField.startEditing();
}
// Private
_renderValue(value)
{
this._hasInvalidVariableValue = false;
const maxValueLength = 150;
let tokens = WI.tokenizeCSSValue(value);
if (this._property.enabled) {
// FIXME: <https://webkit.org/b/178636> Web Inspector: Styles: Make inline widgets work with CSS functions (var(), calc(), etc.)
tokens = this._addGradientTokens(tokens);
tokens = this._addColorTokens(tokens);
tokens = this._addTimingFunctionTokens(tokens, "cubic-bezier");
tokens = this._addTimingFunctionTokens(tokens, "spring");
tokens = this._addVariableTokens(tokens);
}
tokens = tokens.map((token) => {
if (token instanceof Element)
return token;
let className = "";
if (token.type) {
if (token.type.includes("string"))
className = "token-string";
else if (token.type.includes("link"))
className = "token-link";
else if (token.type.includes("comment"))
className = "token-comment";
}
if (className) {
let span = document.createElement("span");
span.classList.add(className);
span.textContent = token.value.truncateMiddle(maxValueLength);
return span;
}
return token.value;
});
this._valueElement.removeChildren();
this._valueElement.append(...tokens);
}
_createInlineSwatch(type, text, valueObject)
{
let tokenElement = document.createElement("span");
let innerElement = document.createElement("span");
innerElement.textContent = text;
let readOnly = !this._property.editable;
let swatch = new WI.InlineSwatch(type, valueObject, readOnly);
swatch.addEventListener(WI.InlineSwatch.Event.ValueChanged, (event) => {
let value = event.data.value && event.data.value.toString();
if (!value)
return;
innerElement.textContent = value;
this._handleValueChange();
}, this);
if (typeof this._delegate.stylePropertyInlineSwatchActivated === "function") {
swatch.addEventListener(WI.InlineSwatch.Event.Activated, () => {
this._delegate.stylePropertyInlineSwatchActivated();
});
}
if (typeof this._delegate.stylePropertyInlineSwatchDeactivated === "function") {
swatch.addEventListener(WI.InlineSwatch.Event.Deactivated, () => {
this._delegate.stylePropertyInlineSwatchDeactivated();
});
}
tokenElement.append(swatch.element, innerElement);
// Prevent the value from editing when clicking on the swatch.
swatch.element.addEventListener("mousedown", (event) => { event.stop(); });
return tokenElement;
}
_addGradientTokens(tokens)
{
let gradientRegex = /^(repeating-)?(linear|radial)-gradient$/i;
let newTokens = [];
let gradientStartIndex = NaN;
let openParenthesis = 0;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token.type && token.type.includes("atom") && gradientRegex.test(token.value)) {
gradientStartIndex = i;
openParenthesis = 0;
} else if (token.value === "(" && !isNaN(gradientStartIndex))
openParenthesis++;
else if (token.value === ")" && !isNaN(gradientStartIndex)) {
openParenthesis--;
if (openParenthesis > 0) {
// Matched a CSS function inside of the gradient.
continue;
}
let rawTokens = tokens.slice(gradientStartIndex, i + 1);
let text = rawTokens.map((token) => token.value).join("");
let gradient = WI.Gradient.fromString(text);
if (gradient)
newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Gradient, text, gradient));
else
newTokens.push(...rawTokens);
gradientStartIndex = NaN;
} else if (isNaN(gradientStartIndex))
newTokens.push(token);
}
return newTokens;
}
_addColorTokens(tokens)
{
let newTokens = [];
let pushPossibleColorToken = (text, ...rawTokens) => {
let color = WI.Color.fromString(text);
if (color)
newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Color, text, color));
else
newTokens.push(...rawTokens);
};
let colorFunctionStartIndex = NaN;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token.type && token.type.includes("hex-color")) {
// Hex
pushPossibleColorToken(token.value, token);
} else if (WI.Color.FunctionNames.has(token.value) && token.type && (token.type.includes("atom") || token.type.includes("keyword"))) {
// Color Function start
colorFunctionStartIndex = i;
} else if (isNaN(colorFunctionStartIndex) && token.type && token.type.includes("keyword")) {
// Color keyword
pushPossibleColorToken(token.value, token);
} else if (!isNaN(colorFunctionStartIndex)) {
// Color Function end
if (token.value !== ")")
continue;
let rawTokens = tokens.slice(colorFunctionStartIndex, i + 1);
let text = rawTokens.map((token) => token.value).join("");
pushPossibleColorToken(text, ...rawTokens);
colorFunctionStartIndex = NaN;
} else
newTokens.push(token);
}
return newTokens;
}
_addTimingFunctionTokens(tokens, tokenType)
{
let newTokens = [];
let startIndex = NaN;
let openParenthesis = 0;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token.value === tokenType && token.type && token.type.includes("atom")) {
startIndex = i;
openParenthesis = 0;
} else if (token.value === "(" && !isNaN(startIndex))
openParenthesis++;
else if (token.value === ")" && !isNaN(startIndex)) {
openParenthesis--;
if (openParenthesis > 0)
continue;
let rawTokens = tokens.slice(startIndex, i + 1);
let text = rawTokens.map((token) => token.value).join("");
let valueObject;
let inlineSwatchType;
if (tokenType === "cubic-bezier") {
valueObject = WI.CubicBezier.fromString(text);
inlineSwatchType = WI.InlineSwatch.Type.Bezier;
} else if (tokenType === "spring") {
valueObject = WI.Spring.fromString(text);
inlineSwatchType = WI.InlineSwatch.Type.Spring;
}
if (valueObject)
newTokens.push(this._createInlineSwatch(inlineSwatchType, text, valueObject));
else
newTokens.push(...rawTokens);
startIndex = NaN;
} else if (isNaN(startIndex))
newTokens.push(token);
}
return newTokens;
}
_addVariableTokens(tokens)
{
let newTokens = [];
let startIndex = NaN;
let openParenthesis = 0;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token.value === "var" && token.type && token.type.includes("atom")) {
startIndex = i;
openParenthesis = 0;
} else if (token.value === "(" && !isNaN(startIndex))
++openParenthesis;
else if (token.value === ")" && !isNaN(startIndex)) {
--openParenthesis;
if (openParenthesis > 0)
continue;
let rawTokens = tokens.slice(startIndex, i + 1);
let tokenValues = rawTokens.map((token) => token.value);
let variableName = tokenValues.find((value, i) => value.startsWith("--") && /\bvariable-2\b/.test(rawTokens[i].type));
const dontCreateIfMissing = true;
let variableProperty = this._property.ownerStyle.nodeStyles.computedStyle.propertyForName(variableName, dontCreateIfMissing);
if (variableProperty) {
let valueObject = variableProperty.value.trim();
newTokens.push(this._createInlineSwatch(WI.InlineSwatch.Type.Variable, tokenValues.join(""), valueObject));
} else {
this._hasInvalidVariableValue = true;
newTokens.push(...rawTokens);
}
startIndex = NaN;
} else if (isNaN(startIndex))
newTokens.push(token);
}
return newTokens;
}
_handleNameChange()
{
this._property.name = this._nameElement.textContent.trim();
}
_handleValueChange()
{
this._property.rawValue = this._valueElement.textContent.trim();
}
_handleNameBeforeInput(event)
{
if (event.data !== ":" || event.inputType !== "insertText")
return;
event.preventDefault();
this._nameTextField.discardCompletion();
this._valueTextField.startEditing();
}
_handleNamePaste(event)
{
let text = event.clipboardData.getData("text/plain");
if (!text || !text.includes(":"))
return;
event.preventDefault();
this._remove(text);
if (this._delegate.spreadsheetStylePropertyAddBlankPropertySoon) {
this._delegate.spreadsheetStylePropertyAddBlankPropertySoon(this, {
index: parseInt(this._element.dataset.propertyIndex) + 1,
});
}
}
_nameCompletionDataProvider(prefix)
{
return WI.CSSCompletions.cssNameCompletions.startsWith(prefix);
}
_handleValueBeforeInput(event)
{
if (event.data !== ";" || event.inputType !== "insertText")
return;
let text = this._valueTextField.valueWithoutSuggestion();
let selection = window.getSelection();
if (!selection.rangeCount || selection.getRangeAt(0).endOffset !== text.length)
return;
// Find the first and last index (if any) of a quote character to ensure that the string
// doesn't contain unbalanced quotes. If so, then there's no way that the semicolon could be
// part of a string within the value, so we can assume that it's the property "terminator".
const quoteRegex = /["']/g;
let start = -1;
let end = text.length;
let match = null;
while (match = quoteRegex.exec(text)) {
if (start < 0)
start = match.index;
end = match.index + 1;
}
if (start !== -1 && !text.substring(start, end).hasMatchingEscapedQuotes())
return;
event.preventDefault();
this._valueTextField.stopEditing();
this.spreadsheetTextFieldDidCommit(this._valueTextField, {direction: "forward"});
}
_valueCompletionDataProvider(prefix)
{
let propertyName = this._nameElement.textContent.trim();
return WI.CSSKeywordCompletions.forProperty(propertyName).startsWith(prefix);
}
_setupJumpToSymbol(element)
{
element.addEventListener("mousedown", (event) => {
if (event.button !== 0)
return;
if (!WI.modifierKeys.metaKey)
return;
if (element.isContentEditable)
return;
let sourceCodeLocation = null;
if (this._property.ownerStyle.ownerRule)
sourceCodeLocation = this._property.ownerStyle.ownerRule.sourceCodeLocation;
if (!sourceCodeLocation)
return;
let range = this._property.styleSheetTextRange;
const options = {
ignoreNetworkTab: true,
ignoreSearchTab: true,
};
let sourceCode = sourceCodeLocation.sourceCode;
WI.showSourceCodeLocation(sourceCode.createSourceCodeLocation(range.startLine, range.startColumn), options);
});
}
};
WI.SpreadsheetStyleProperty.StyleClassName = "property";