Files
react-native-devtools-frontend/front_end/inline_editor/CSSAngle.ts
T
Changhao Han 4684176107 Fix angle popover early hiding and shift-click behavior
This CL fixes two issues.
1. The angle popover would hide when:
(1). a new CSS property line is created
by either pressing Tab in the Styles section or clicking at an empty
space;
(2). angle popover is opened when angle swatch is clicked;
(3). click anywhere on the popover would hide the popover, which is
not expected because clicking on the popover should only update the
angle but not closing it.
Upon investigation, the culprit is when clicking on the angle swatch,
the mousedown event is consumed with `preventDefault`, which also
delays the blur event of the newly created CSS property line. There is
an internal `_editingStyle` property that determines if the angle
component will be re-created and thus lose previous popover state. This
property is set to false, which recreates <angle>, by the blur listener,
and then set to true, which prevents the re-creation (thus preserving
the popover). Since the blur event is delayed, the sequence of events
is off and causes this early dismissal of angle popover.
The fix is to not `preventDefault()` in angle swatch's mousedown
event, but this creates another problem where holding shift will
select text around <angle>. This is then fixed with setting
`user-select: none` on it.

2. This CL also fixes a previous TODO: when angle popover is open,
shift-clicking it should close the popover and not toggle the unit,
so that the behavior is consistent with color swatch for now.

Fixed: chromium:1142523
Change-Id: I1e1f4d8500d5dd9308bf5d225be3aff958fea971
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2611131
Commit-Queue: Changhao Han <changhaohan@chromium.org>
Commit-Queue: Mathias Bynens <mathias@chromium.org>
Auto-Submit: Changhao Han <changhaohan@chromium.org>
Reviewed-by: Mathias Bynens <mathias@chromium.org>
2021-01-07 09:24:36 +00:00

285 lines
8.2 KiB
TypeScript

// Copyright (c) 2020 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.
import './CSSAngleEditor.js';
import './CSSAngleSwatch.js';
import * as LitHtml from '../third_party/lit-html/lit-html.js';
import {Angle, AngleUnit, convertAngleUnit, getNewAngleFromEvent, getNextUnit, parseText, roundAngleByUnit} from './CSSAngleUtils.js';
import type {CSSAngleEditorData} from './CSSAngleEditor.js';
import type {CSSAngleSwatchData} from './CSSAngleSwatch.js';
const {render, html} = LitHtml;
const styleMap = LitHtml.Directives.styleMap;
const ContextAwareProperties = new Set(['color', 'background', 'background-color']);
export class PopoverToggledEvent extends Event {
data: {open: boolean};
constructor(open: boolean) {
super('popover-toggled', {});
this.data = {open};
}
}
export class ValueChangedEvent extends Event {
data: {value: string};
constructor(value: string) {
super('value-changed', {});
this.data = {value};
}
}
export class UnitChangedEvent extends Event {
data: {value: string};
constructor(value: string) {
super('unit-changed', {});
this.data = {value};
}
}
export interface CSSAngleData {
propertyName: string;
propertyValue: string;
angleText: string;
containingPane: HTMLElement;
}
const DefaultAngle = {
value: 0,
unit: AngleUnit.Rad,
};
export class CSSAngle extends HTMLElement {
private readonly shadow = this.attachShadow({mode: 'open'});
private angle: Angle = DefaultAngle;
private displayedAngle: Angle = DefaultAngle;
private propertyName = '';
private propertyValue = '';
private containingPane?: HTMLElement;
private angleElement: HTMLElement|null = null;
private swatchElement: HTMLElement|null = null;
private popoverOpen = false;
private popoverStyleTop = '';
private onMinifyingAction = this.minify.bind(this);
private onAngleUpdate = this.updateAngle.bind(this);
set data(data: CSSAngleData) {
const parsedResult = parseText(data.angleText);
if (!parsedResult) {
return;
}
this.angle = parsedResult;
this.displayedAngle = {...parsedResult};
this.propertyName = data.propertyName;
this.propertyValue = data.propertyValue;
this.containingPane = data.containingPane;
this.render();
}
disconnectedCallback(): void {
this.unbindMinifyingAction();
}
// We bind and unbind mouse event listeners upon popping over and minifying,
// because we anticipate most of the time this widget is minified even when
// it's attached to the DOM tree.
popover(): void {
if (!this.containingPane) {
return;
}
if (!this.angleElement) {
this.angleElement = this.shadow.querySelector<HTMLElement>('.css-angle');
}
if (!this.swatchElement) {
this.swatchElement = this.shadow.querySelector<HTMLElement>('devtools-css-angle-swatch');
}
if (!this.angleElement || !this.swatchElement) {
return;
}
this.dispatchEvent(new PopoverToggledEvent(true));
this.bindMinifyingAction();
const miniIconBottom = this.swatchElement.getBoundingClientRect().bottom;
if (miniIconBottom) {
// We offset mini icon's Y position with the containing styles pane's Y position
// because DevTools' root SplitWidget's insertion-point-sidebar slot,
// where most of the DevTools content lives, has an offset of Y position,
// which makes all of its children's DOMRect Y positions to have this offset.
const topElementOffset = this.containingPane.getBoundingClientRect().top;
this.popoverStyleTop = `${miniIconBottom - topElementOffset}px`;
}
this.popoverOpen = true;
this.render();
this.angleElement.focus();
}
minify(): void {
if (this.popoverOpen === false) {
return;
}
this.popoverOpen = false;
this.dispatchEvent(new PopoverToggledEvent(false));
this.unbindMinifyingAction();
this.render();
}
updateProperty(name: string, value: string): void {
this.propertyName = name;
this.propertyValue = value;
this.render();
}
private updateAngle(angle: Angle): void {
this.displayedAngle = roundAngleByUnit(convertAngleUnit(angle, this.displayedAngle.unit));
this.angle = this.displayedAngle;
this.dispatchEvent(new ValueChangedEvent(`${this.angle.value}${this.angle.unit}`));
}
private displayNextUnit(): void {
const nextUnit = getNextUnit(this.displayedAngle.unit);
this.displayedAngle = roundAngleByUnit(convertAngleUnit(this.angle, nextUnit));
this.dispatchEvent(new UnitChangedEvent(`${this.displayedAngle.value}${this.displayedAngle.unit}`));
}
private bindMinifyingAction(): void {
document.addEventListener('mousedown', this.onMinifyingAction);
if (this.containingPane) {
this.containingPane.addEventListener('scroll', this.onMinifyingAction);
}
}
private unbindMinifyingAction(): void {
document.removeEventListener('mousedown', this.onMinifyingAction);
if (this.containingPane) {
this.containingPane.removeEventListener('scroll', this.onMinifyingAction);
}
}
private onMiniIconClick(event: MouseEvent): void {
event.stopPropagation();
if (event.shiftKey && !this.popoverOpen) {
this.displayNextUnit();
return;
}
this.popoverOpen ? this.minify() : this.popover();
}
// Fix that the previous text will be selected when double-clicking the angle icon
private consume(event: MouseEvent): void {
event.stopPropagation();
}
private onKeydown(event: KeyboardEvent): void {
if (!this.popoverOpen) {
return;
}
switch (event.key) {
case 'Escape':
event.stopPropagation();
this.minify();
this.blur();
break;
case 'ArrowUp':
case 'ArrowDown': {
const newAngle = getNewAngleFromEvent(this.angle, event);
if (newAngle) {
this.updateAngle(newAngle);
}
event.preventDefault();
break;
}
}
}
private render(): void {
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
render(html`
<style>
.css-angle {
display: inline-block;
position: relative;
outline: none;
}
devtools-css-angle-swatch {
display: inline-block;
margin-right: 2px;
user-select: none;
}
devtools-css-angle-editor {
--dial-color: #a3a3a3;
--border-color: var(--toolbar-bg-color);
position: fixed;
z-index: 2;
}
</style>
<div class="css-angle" @keydown=${this.onKeydown} tabindex="-1">
<div class="preview">
<devtools-css-angle-swatch
@click=${this.onMiniIconClick}
@mousedown=${this.consume}
@dblclick=${this.consume}
.data=${{
angle: this.angle,
} as CSSAngleSwatchData}>
</devtools-css-angle-swatch><slot></slot>
</div>
${this.popoverOpen ? this.renderPopover() : null}
</div>
`, this.shadow, {
eventContext: this,
});
// clang-format on
}
private renderPopover(): LitHtml.TemplateResult {
let contextualBackground = '';
// TODO(crbug.com/1143010): for now we ignore values with "url"; when we refactor
// CSS value parsing we should properly apply atomic contextual background.
if (ContextAwareProperties.has(this.propertyName) && !this.propertyValue.match(/url\(.*\)/i)) {
contextualBackground = this.propertyValue;
}
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
return html`
<devtools-css-angle-editor
class="popover popover-css-angle"
style=${styleMap({top: this.popoverStyleTop})}
.data=${{
angle: this.angle,
onAngleUpdate: this.onAngleUpdate,
background: contextualBackground,
} as CSSAngleEditorData}
></devtools-css-angle-editor>
`;
// clang-format on
}
}
if (!customElements.get('devtools-css-angle')) {
customElements.define('devtools-css-angle', CSSAngle);
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLElementTagNameMap {
'devtools-css-angle': CSSAngle;
}
}