chrome-devtools-frontend
Version:
Chrome DevTools UI
499 lines (453 loc) • 19.4 kB
text/typescript
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as Common from '../../../../core/common/common.js';
import * as i18n from '../../../../core/i18n/i18n.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';
import cssShadowEditorStyles from './cssShadowEditor.css.js';
const UIStrings = {
/**
*@description Text that refers to some types
*/
type: 'Type',
/**
*@description Text in CSSShadow Editor of the inline editor in the Styles tab
*/
xOffset: 'X offset',
/**
*@description Text in CSSShadow Editor of the inline editor in the Styles tab
*/
yOffset: 'Y offset',
/**
* @description Text in CSSShadow Editor of the inline editor in the Styles tab. Noun which is a
* label for an input that allows the user to specify how blurred the box-shadow should be.
*/
blur: 'Blur',
/**
*@description Text in CSSShadow Editor of the inline editor in the Styles tab
*/
spread: 'Spread',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/inline_editor/CSSShadowEditor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const maxRange = 20;
const defaultUnit = 'px';
const sliderThumbRadius = 6;
const canvasSize = 88;
export interface CSSShadowModel {
setInset(inset: boolean): void;
setOffsetX(offsetX: CSSLength): void;
setOffsetY(offsetY: CSSLength): void;
setBlurRadius(blurRadius: CSSLength): void;
setSpreadRadius(spreadRadius: CSSLength): void;
isBoxShadow(): boolean;
inset(): boolean;
offsetX(): CSSLength;
offsetY(): CSSLength;
blurRadius(): CSSLength;
spreadRadius(): CSSLength;
}
const CSS_LENGTH_REGEX = (function(): string {
const number = '([+-]?(?:[0-9]*[.])?[0-9]+(?:[eE][+-]?[0-9]+)?)';
const unit = '(ch|cm|em|ex|in|mm|pc|pt|px|rem|vh|vmax|vmin|vw)';
const zero = '[+-]?(?:0*[.])?0+(?:[eE][+-]?[0-9]+)?';
return new RegExp(number + unit + '|' + zero, 'gi').source;
})();
export class CSSLength {
amount: number;
unit: string;
constructor(amount: number, unit: string) {
this.amount = amount;
this.unit = unit;
}
static parse(text: string): CSSLength|null {
const lengthRegex = new RegExp('^(?:' + CSS_LENGTH_REGEX + ')$', 'i');
const match = text.match(lengthRegex);
if (!match) {
return null;
}
if (match.length > 2 && match[2]) {
return new CSSLength(parseFloat(match[1]), match[2]);
}
return CSSLength.zero();
}
static zero(): CSSLength {
return new CSSLength(0, '');
}
asCSSText(): string {
return this.amount + this.unit;
}
}
export class CSSShadowEditor extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(
UI.Widget.VBox) {
private readonly typeField: HTMLElement;
private readonly outsetButton: HTMLElement;
private readonly insetButton: HTMLElement;
private xInput: HTMLInputElement;
private yInput: HTMLInputElement;
private xySlider: HTMLCanvasElement;
private halfCanvasSize: number;
private readonly innerCanvasSize: number;
private blurInput: HTMLInputElement;
private blurSlider: HTMLInputElement;
private readonly spreadField: HTMLElement;
private spreadInput: HTMLInputElement;
private spreadSlider: HTMLInputElement;
private model!: CSSShadowModel;
private canvasOrigin!: UI.Geometry.Point;
private changedElement?: HTMLInputElement|null;
constructor() {
super(true);
this.registerRequiredCSS(cssShadowEditorStyles);
this.contentElement.tabIndex = 0;
this.contentElement.setAttribute(
'jslog', `${VisualLogging.dialog('cssShadowEditor').parent('mapped').track({keydown: 'Enter|Escape'})}`);
this.setDefaultFocusedElement(this.contentElement);
this.typeField = this.contentElement.createChild('div', 'shadow-editor-field shadow-editor-flex-field');
this.typeField.createChild('label', 'shadow-editor-label').textContent = i18nString(UIStrings.type);
this.outsetButton = this.typeField.createChild('button', 'shadow-editor-button-left');
this.outsetButton.textContent = i18n.i18n.lockedString('Outset');
this.outsetButton.addEventListener('click', this.onButtonClick.bind(this), false);
this.insetButton = this.typeField.createChild('button', 'shadow-editor-button-right');
this.insetButton.textContent = i18n.i18n.lockedString('Inset');
this.insetButton.addEventListener('click', this.onButtonClick.bind(this), false);
const xField = this.contentElement.createChild('div', 'shadow-editor-field');
this.xInput = this.createTextInput(xField, i18nString(UIStrings.xOffset), 'x-offset');
const yField = this.contentElement.createChild('div', 'shadow-editor-field');
this.yInput = this.createTextInput(yField, i18nString(UIStrings.yOffset), 'y-offset');
this.xySlider = xField.createChild('canvas', 'shadow-editor-2D-slider');
this.xySlider.setAttribute('jslog', `${VisualLogging.slider('xy').track({
click: true,
drag: true,
keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight',
})}`);
this.xySlider.width = canvasSize;
this.xySlider.height = canvasSize;
this.xySlider.tabIndex = -1;
this.halfCanvasSize = canvasSize / 2;
this.innerCanvasSize = this.halfCanvasSize - sliderThumbRadius;
UI.UIUtils.installDragHandle(this.xySlider, this.dragStart.bind(this), this.dragMove.bind(this), null, 'default');
this.xySlider.addEventListener('keydown', this.onCanvasArrowKey.bind(this), false);
this.xySlider.addEventListener('blur', this.onCanvasBlur.bind(this), false);
const blurField =
this.contentElement.createChild('div', 'shadow-editor-field shadow-editor-flex-field shadow-editor-blur-field');
this.blurInput = this.createTextInput(blurField, i18nString(UIStrings.blur), 'blur');
this.blurSlider = this.createSlider(blurField, 'blur');
this.spreadField = this.contentElement.createChild('div', 'shadow-editor-field shadow-editor-flex-field');
this.spreadInput = this.createTextInput(this.spreadField, i18nString(UIStrings.spread), 'spread');
this.spreadSlider = this.createSlider(this.spreadField, 'spread');
}
private createTextInput(field: Element, propertyName: string, jslogContext: string): HTMLInputElement {
const label = field.createChild('label', 'shadow-editor-label');
label.textContent = propertyName;
label.setAttribute('for', propertyName);
const textInput = UI.UIUtils.createInput('shadow-editor-text-input', 'text');
field.appendChild(textInput);
textInput.id = propertyName;
textInput.addEventListener('keydown', this.handleValueModification.bind(this), false);
textInput.addEventListener('wheel', this.handleValueModification.bind(this), false);
textInput.addEventListener('input', this.onTextInput.bind(this), false);
textInput.addEventListener('blur', this.onTextBlur.bind(this), false);
textInput.setAttribute(
'jslog', `${VisualLogging.value().track({change: true, keydown: 'ArrowUp|ArrowDown'}).context(jslogContext)}`);
return textInput;
}
private createSlider(field: Element, jslogContext: string): HTMLInputElement {
const slider = UI.UIUtils.createSlider(0, maxRange, -1);
slider.addEventListener('input', this.onSliderInput.bind(this), false);
slider.setAttribute('jslog', `${VisualLogging.slider().track({click: true, drag: true}).context(jslogContext)}`);
field.appendChild(slider);
return slider;
}
override wasShown(): void {
super.wasShown();
this.updateUI();
}
setModel(model: CSSShadowModel): void {
this.model = model;
this.typeField.classList.toggle('hidden', !model.isBoxShadow());
this.spreadField.classList.toggle('hidden', !model.isBoxShadow());
this.updateUI();
}
private updateUI(): void {
this.updateButtons();
this.xInput.value = this.model.offsetX().asCSSText();
this.yInput.value = this.model.offsetY().asCSSText();
this.blurInput.value = this.model.blurRadius().asCSSText();
this.spreadInput.value = this.model.spreadRadius().asCSSText();
this.blurSlider.value = this.model.blurRadius().amount.toString();
this.spreadSlider.value = this.model.spreadRadius().amount.toString();
this.updateCanvas(false);
}
private updateButtons(): void {
this.insetButton.classList.toggle('enabled', this.model.inset());
this.outsetButton.classList.toggle('enabled', !this.model.inset());
}
private updateCanvas(drawFocus: boolean): void {
const context = this.xySlider.getContext('2d');
if (!context) {
throw new Error('Unable to obtain canvas context');
}
context.clearRect(0, 0, this.xySlider.width, this.xySlider.height);
// Draw dashed axes.
context.save();
context.setLineDash([1, 1]);
context.strokeStyle = 'rgba(210, 210, 210, 0.8)';
context.beginPath();
context.moveTo(this.halfCanvasSize, 0);
context.lineTo(this.halfCanvasSize, canvasSize);
context.moveTo(0, this.halfCanvasSize);
context.lineTo(canvasSize, this.halfCanvasSize);
context.stroke();
context.restore();
const thumbPoint = this.sliderThumbPosition();
// Draw 2D slider line.
context.save();
context.translate(this.halfCanvasSize, this.halfCanvasSize);
context.lineWidth = 2;
context.strokeStyle = 'rgba(130, 130, 130, 0.75)';
context.beginPath();
context.moveTo(0, 0);
context.lineTo(thumbPoint.x, thumbPoint.y);
context.stroke();
// Draw 2D slider thumb.
if (drawFocus) {
context.beginPath();
context.fillStyle = 'rgba(66, 133, 244, 0.4)';
context.arc(thumbPoint.x, thumbPoint.y, sliderThumbRadius + 2, 0, 2 * Math.PI);
context.fill();
}
context.beginPath();
context.fillStyle = '#4285F4';
context.arc(thumbPoint.x, thumbPoint.y, sliderThumbRadius, 0, 2 * Math.PI);
context.fill();
context.restore();
}
private onButtonClick(event: Event): void {
const insetClicked = (event.currentTarget === this.insetButton);
if (insetClicked && this.model.inset() || !insetClicked && !this.model.inset()) {
return;
}
this.model.setInset(insetClicked);
this.updateButtons();
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private handleValueModification(event: Event): void {
const target = (event.currentTarget as HTMLInputElement);
const modifiedValue = UI.UIUtils.createReplacementString(target.value, event, customNumberHandler);
if (!modifiedValue) {
return;
}
const length = CSSLength.parse(modifiedValue);
if (!length) {
return;
}
if (event.currentTarget === this.blurInput && length.amount < 0) {
length.amount = 0;
}
target.value = length.asCSSText();
target.selectionStart = 0;
target.selectionEnd = target.value.length;
this.onTextInput(event);
event.consume(true);
function customNumberHandler(prefix: string, number: number, suffix: string): string {
if (!suffix.length) {
suffix = defaultUnit;
}
return prefix + number + suffix;
}
}
private onTextInput(event: Event): void {
const currentTarget = (event.currentTarget as HTMLInputElement);
this.changedElement = currentTarget;
this.changedElement.classList.remove('invalid');
const length = CSSLength.parse(currentTarget.value);
if (!length || currentTarget === this.blurInput && length.amount < 0) {
return;
}
if (currentTarget === this.xInput) {
this.model.setOffsetX(length);
this.updateCanvas(false);
} else if (currentTarget === this.yInput) {
this.model.setOffsetY(length);
this.updateCanvas(false);
} else if (currentTarget === this.blurInput) {
this.model.setBlurRadius(length);
this.blurSlider.value = length.amount.toString();
} else if (currentTarget === this.spreadInput) {
this.model.setSpreadRadius(length);
this.spreadSlider.value = length.amount.toString();
}
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private onTextBlur(): void {
if (!this.changedElement) {
return;
}
let length: (CSSLength|null)|CSSLength =
!this.changedElement.value.trim() ? CSSLength.zero() : CSSLength.parse(this.changedElement.value);
if (!length) {
length = CSSLength.parse(this.changedElement.value + defaultUnit);
}
if (!length) {
this.changedElement.classList.add('invalid');
this.changedElement = null;
return;
}
if (this.changedElement === this.xInput) {
this.model.setOffsetX(length);
this.xInput.value = length.asCSSText();
this.updateCanvas(false);
} else if (this.changedElement === this.yInput) {
this.model.setOffsetY(length);
this.yInput.value = length.asCSSText();
this.updateCanvas(false);
} else if (this.changedElement === this.blurInput) {
if (length.amount < 0) {
length = CSSLength.zero();
}
this.model.setBlurRadius(length);
this.blurInput.value = length.asCSSText();
this.blurSlider.value = length.amount.toString();
} else if (this.changedElement === this.spreadInput) {
this.model.setSpreadRadius(length);
this.spreadInput.value = length.asCSSText();
this.spreadSlider.value = length.amount.toString();
}
this.changedElement = null;
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private onSliderInput(event: Event): void {
if (event.currentTarget === this.blurSlider) {
this.model.setBlurRadius(
new CSSLength(Number(this.blurSlider.value), this.model.blurRadius().unit || defaultUnit));
this.blurInput.value = this.model.blurRadius().asCSSText();
this.blurInput.classList.remove('invalid');
} else if (event.currentTarget === this.spreadSlider) {
this.model.setSpreadRadius(
new CSSLength(Number(this.spreadSlider.value), this.model.spreadRadius().unit || defaultUnit));
this.spreadInput.value = this.model.spreadRadius().asCSSText();
this.spreadInput.classList.remove('invalid');
}
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private dragStart(event: MouseEvent): boolean {
this.xySlider.focus();
this.updateCanvas(true);
this.canvasOrigin = new UI.Geometry.Point(
this.xySlider.getBoundingClientRect().left + this.halfCanvasSize,
this.xySlider.getBoundingClientRect().top + this.halfCanvasSize);
const clickedPoint = new UI.Geometry.Point(event.x - this.canvasOrigin.x, event.y - this.canvasOrigin.y);
const thumbPoint = this.sliderThumbPosition();
if (clickedPoint.distanceTo(thumbPoint) >= sliderThumbRadius) {
this.dragMove(event);
}
return true;
}
private dragMove(event: MouseEvent): void {
let point: UI.Geometry.Point = new UI.Geometry.Point(event.x - this.canvasOrigin.x, event.y - this.canvasOrigin.y);
if (event.shiftKey) {
point = this.snapToClosestDirection(point);
}
const constrainedPoint = this.constrainPoint(point, this.innerCanvasSize);
const newX = Math.round((constrainedPoint.x / this.innerCanvasSize) * maxRange);
const newY = Math.round((constrainedPoint.y / this.innerCanvasSize) * maxRange);
if (event.shiftKey) {
this.model.setOffsetX(new CSSLength(newX, this.model.offsetX().unit || defaultUnit));
this.model.setOffsetY(new CSSLength(newY, this.model.offsetY().unit || defaultUnit));
} else {
if (!event.altKey) {
this.model.setOffsetX(new CSSLength(newX, this.model.offsetX().unit || defaultUnit));
}
if (!UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
this.model.setOffsetY(new CSSLength(newY, this.model.offsetY().unit || defaultUnit));
}
}
this.xInput.value = this.model.offsetX().asCSSText();
this.yInput.value = this.model.offsetY().asCSSText();
this.xInput.classList.remove('invalid');
this.yInput.classList.remove('invalid');
this.updateCanvas(true);
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private onCanvasBlur(): void {
this.updateCanvas(false);
}
private onCanvasArrowKey(event: Event): void {
const keyboardEvent = (event as KeyboardEvent);
let shiftX = 0;
let shiftY = 0;
if (keyboardEvent.key === 'ArrowRight') {
shiftX = 1;
} else if (keyboardEvent.key === 'ArrowLeft') {
shiftX = -1;
} else if (keyboardEvent.key === 'ArrowUp') {
shiftY = -1;
} else if (keyboardEvent.key === 'ArrowDown') {
shiftY = 1;
}
if (!shiftX && !shiftY) {
return;
}
event.consume(true);
if (shiftX) {
const offsetX = this.model.offsetX();
const newAmount = Platform.NumberUtilities.clamp(offsetX.amount + shiftX, -maxRange, maxRange);
if (newAmount === offsetX.amount) {
return;
}
this.model.setOffsetX(new CSSLength(newAmount, offsetX.unit || defaultUnit));
this.xInput.value = this.model.offsetX().asCSSText();
this.xInput.classList.remove('invalid');
}
if (shiftY) {
const offsetY = this.model.offsetY();
const newAmount = Platform.NumberUtilities.clamp(offsetY.amount + shiftY, -maxRange, maxRange);
if (newAmount === offsetY.amount) {
return;
}
this.model.setOffsetY(new CSSLength(newAmount, offsetY.unit || defaultUnit));
this.yInput.value = this.model.offsetY().asCSSText();
this.yInput.classList.remove('invalid');
}
this.updateCanvas(true);
this.dispatchEventToListeners(Events.SHADOW_CHANGED, this.model);
}
private constrainPoint(point: UI.Geometry.Point, max: number): UI.Geometry.Point {
if (Math.abs(point.x) <= max && Math.abs(point.y) <= max) {
return new UI.Geometry.Point(point.x, point.y);
}
return point.scale(max / Math.max(Math.abs(point.x), Math.abs(point.y)));
}
private snapToClosestDirection(point: UI.Geometry.Point): UI.Geometry.Point {
let minDistance: number = Number.MAX_VALUE;
let closestPoint: UI.Geometry.Point = point;
const directions = [
new UI.Geometry.Point(0, -1), new UI.Geometry.Point(1, -1), new UI.Geometry.Point(1, 0),
new UI.Geometry.Point(1, 1), // Southeast
];
for (const direction of directions) {
const projection = point.projectOn(direction);
const distance = point.distanceTo(projection);
if (distance < minDistance) {
minDistance = distance;
closestPoint = projection;
}
}
return closestPoint;
}
private sliderThumbPosition(): UI.Geometry.Point {
const x = (this.model.offsetX().amount / maxRange) * this.innerCanvasSize;
const y = (this.model.offsetY().amount / maxRange) * this.innerCanvasSize;
return this.constrainPoint(new UI.Geometry.Point(x, y), this.innerCanvasSize);
}
}
export const enum Events {
SHADOW_CHANGED = 'ShadowChanged',
}
export interface EventTypes {
[Events.SHADOW_CHANGED]: CSSShadowModel;
}