| // Copyright 2014 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| import '../../ui/legacy/legacy.js'; |
| |
| 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 UI from '../../ui/legacy/legacy.js'; |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Tooltip text that appears when hovering over largeicon pan button in Transform Controller of the Layers panel |
| */ |
| panModeX: 'Pan mode (X)', |
| /** |
| * @description Tooltip text that appears when hovering over largeicon rotate button in Transform Controller of the Layers panel |
| */ |
| rotateModeV: 'Rotate mode (V)', |
| /** |
| * @description Tooltip text that appears when hovering over the largeicon center button in the Transform Controller of the Layers panel |
| */ |
| resetTransform: 'Reset transform (0)', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/layer_viewer/TransformController.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| export class TransformController extends Common.ObjectWrapper.ObjectWrapper<EventTypes> { |
| private mode!: Modes; |
| #scale: number; |
| #offsetX: number; |
| #offsetY: number; |
| #rotateX: number; |
| #rotateY: number; |
| private oldRotateX: number; |
| private oldRotateY: number; |
| private originX: number; |
| private originY: number; |
| element: HTMLElement; |
| private minScale: number; |
| private maxScale: number; |
| private readonly controlPanelToolbar: UI.Toolbar.Toolbar; |
| private readonly modeButtons: Record<string, UI.Toolbar.ToolbarToggle>; |
| /** |
| * @param element The HTML element to apply transformations to. |
| * @param disableRotate Optional. If true, pan and rotate will be disabled. Defaults to false. |
| * @param preventDefaultOnMousedown Optional. If true, mousedown events will be prevented from their default behavior (including focus). Defaults to true. |
| */ |
| constructor(element: HTMLElement, disableRotate?: boolean, preventDefaultOnMouseDown = true) { |
| super(); |
| this.#scale = 1; |
| this.#offsetX = 0; |
| this.#offsetY = 0; |
| this.#rotateX = 0; |
| this.#rotateY = 0; |
| this.oldRotateX = 0; |
| this.oldRotateY = 0; |
| this.originX = 0; |
| this.originY = 0; |
| this.element = element; |
| this.registerShortcuts(); |
| UI.UIUtils.installDragHandle( |
| element, this.onDragStart.bind(this), this.onDrag.bind(this), this.onDragEnd.bind(this), 'move', null, 0, |
| preventDefaultOnMouseDown); |
| element.addEventListener('wheel', this.onMouseWheel.bind(this), false); |
| this.minScale = 0; |
| this.maxScale = Infinity; |
| |
| this.controlPanelToolbar = document.createElement('devtools-toolbar'); |
| this.controlPanelToolbar.classList.add('transform-control-panel'); |
| this.controlPanelToolbar.setAttribute('jslog', `${VisualLogging.toolbar()}`); |
| |
| this.modeButtons = {}; |
| if (!disableRotate) { |
| const panModeButton = new UI.Toolbar.ToolbarToggle( |
| i18nString(UIStrings.panModeX), '3d-pan', undefined, 'layers.3d-pan', /* toggleOnClick */ false); |
| panModeButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.setMode.bind(this, Modes.PAN)); |
| this.modeButtons[Modes.PAN] = panModeButton; |
| this.controlPanelToolbar.appendToolbarItem(panModeButton); |
| const rotateModeButton = new UI.Toolbar.ToolbarToggle( |
| i18nString(UIStrings.rotateModeV), '3d-rotate', undefined, 'layers.3d-rotate', /* toggleOnClick */ false); |
| rotateModeButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.setMode.bind(this, Modes.ROTATE)); |
| this.modeButtons[Modes.ROTATE] = rotateModeButton; |
| this.controlPanelToolbar.appendToolbarItem(rotateModeButton); |
| } |
| this.setMode(Modes.PAN); |
| |
| const resetButton = |
| new UI.Toolbar.ToolbarButton(i18nString(UIStrings.resetTransform), '3d-center', undefined, 'layers.3d-center'); |
| resetButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.resetAndNotify.bind(this, undefined)); |
| this.controlPanelToolbar.appendToolbarItem(resetButton); |
| |
| this.reset(); |
| } |
| |
| toolbar(): UI.Toolbar.Toolbar { |
| return this.controlPanelToolbar; |
| } |
| |
| private registerShortcuts(): void { |
| const zoomFactor = 1.1; |
| UI.ShortcutRegistry.ShortcutRegistry.instance().addShortcutListener(this.element, { |
| 'layers.reset-view': async () => { |
| this.resetAndNotify(); |
| return true; |
| }, |
| 'layers.pan-mode': async () => { |
| this.setMode(Modes.PAN); |
| return true; |
| }, |
| 'layers.rotate-mode': async () => { |
| this.setMode(Modes.ROTATE); |
| return true; |
| }, |
| 'layers.zoom-in': this.onKeyboardZoom.bind(this, zoomFactor), |
| 'layers.zoom-out': this.onKeyboardZoom.bind(this, 1 / zoomFactor), |
| 'layers.up': this.onKeyboardPanOrRotate.bind(this, 0, -1), |
| 'layers.down': this.onKeyboardPanOrRotate.bind(this, 0, 1), |
| 'layers.left': this.onKeyboardPanOrRotate.bind(this, -1, 0), |
| 'layers.right': this.onKeyboardPanOrRotate.bind(this, 1, 0), |
| }); |
| } |
| |
| private postChangeEvent(): void { |
| this.dispatchEventToListeners(Events.TRANSFORM_CHANGED); |
| } |
| |
| private reset(): void { |
| this.#scale = 1; |
| this.#offsetX = 0; |
| this.#offsetY = 0; |
| this.#rotateX = 0; |
| this.#rotateY = 0; |
| } |
| |
| private setMode(mode: Modes): void { |
| if (this.mode === mode) { |
| return; |
| } |
| this.mode = mode; |
| this.updateModeButtons(); |
| } |
| |
| private updateModeButtons(): void { |
| for (const mode in this.modeButtons) { |
| this.modeButtons[mode].setToggled(mode === this.mode); |
| } |
| } |
| |
| resetAndNotify(event?: Event): void { |
| this.reset(); |
| this.postChangeEvent(); |
| if (event) { |
| event.preventDefault(); |
| } |
| this.element.focus(); |
| } |
| |
| setScaleConstraints(minScale: number, maxScale: number): void { |
| this.minScale = minScale; |
| this.maxScale = maxScale; |
| this.#scale = Platform.NumberUtilities.clamp(this.#scale, minScale, maxScale); |
| } |
| |
| clampOffsets(minX: number, maxX: number, minY: number, maxY: number): void { |
| this.#offsetX = Platform.NumberUtilities.clamp(this.#offsetX, minX, maxX); |
| this.#offsetY = Platform.NumberUtilities.clamp(this.#offsetY, minY, maxY); |
| } |
| |
| scale(): number { |
| return this.#scale; |
| } |
| |
| offsetX(): number { |
| return this.#offsetX; |
| } |
| |
| offsetY(): number { |
| return this.#offsetY; |
| } |
| |
| rotateX(): number { |
| return this.#rotateX; |
| } |
| |
| rotateY(): number { |
| return this.#rotateY; |
| } |
| |
| private onScale(scaleFactor: number, x: number, y: number): void { |
| scaleFactor = Platform.NumberUtilities.clamp(this.#scale * scaleFactor, this.minScale, this.maxScale) / this.#scale; |
| this.#scale *= scaleFactor; |
| this.#offsetX -= (x - this.#offsetX) * (scaleFactor - 1); |
| this.#offsetY -= (y - this.#offsetY) * (scaleFactor - 1); |
| this.postChangeEvent(); |
| } |
| |
| private onPan(offsetX: number, offsetY: number): void { |
| this.#offsetX += offsetX; |
| this.#offsetY += offsetY; |
| this.postChangeEvent(); |
| } |
| |
| private onRotate(rotateX: number, rotateY: number): void { |
| this.#rotateX = rotateX; |
| this.#rotateY = rotateY; |
| this.postChangeEvent(); |
| } |
| |
| private async onKeyboardZoom(zoomFactor: number): Promise<boolean> { |
| this.onScale(zoomFactor, this.element.clientWidth / 2, this.element.clientHeight / 2); |
| return true; |
| } |
| |
| private async onKeyboardPanOrRotate(xMultiplier: number, yMultiplier: number): Promise<boolean> { |
| const panStepInPixels = 6; |
| const rotateStepInDegrees = 5; |
| |
| if (this.mode === Modes.ROTATE) { |
| // Sic! onRotate treats X and Y as "rotate around X" and "rotate around Y", so swap X/Y multipliers. |
| this.onRotate( |
| this.#rotateX + yMultiplier * rotateStepInDegrees, this.#rotateY + xMultiplier * rotateStepInDegrees); |
| } else { |
| this.onPan(xMultiplier * panStepInPixels, yMultiplier * panStepInPixels); |
| } |
| return true; |
| } |
| |
| private onMouseWheel(event: Event): void { |
| /** @constant */ |
| const zoomFactor = 1.1; |
| /** @constant */ |
| const wheelZoomSpeed = 1 / 53; |
| const mouseEvent = event as WheelEvent; |
| const scaleFactor = Math.pow(zoomFactor, -mouseEvent.deltaY * wheelZoomSpeed); |
| this.onScale( |
| scaleFactor, mouseEvent.clientX - this.element.getBoundingClientRect().left, |
| mouseEvent.clientY - this.element.getBoundingClientRect().top); |
| } |
| |
| private onDrag(event: Event): void { |
| const {clientX, clientY} = event as MouseEvent; |
| if (this.mode === Modes.ROTATE) { |
| this.onRotate( |
| this.oldRotateX + (this.originY - clientY) / this.element.clientHeight * 180, |
| this.oldRotateY - (this.originX - clientX) / this.element.clientWidth * 180); |
| } else { |
| this.onPan(clientX - this.originX, clientY - this.originY); |
| this.originX = clientX; |
| this.originY = clientY; |
| } |
| } |
| |
| private onDragStart(event: MouseEvent): boolean { |
| this.element.focus(); |
| this.originX = event.clientX; |
| this.originY = event.clientY; |
| this.oldRotateX = this.#rotateX; |
| this.oldRotateY = this.#rotateY; |
| return true; |
| } |
| |
| private onDragEnd(): void { |
| this.originX = 0; |
| this.originY = 0; |
| this.oldRotateX = 0; |
| this.oldRotateY = 0; |
| } |
| } |
| |
| export const enum Events { |
| TRANSFORM_CHANGED = 'TransformChanged', |
| } |
| |
| export interface EventTypes { |
| [Events.TRANSFORM_CHANGED]: void; |
| } |
| |
| export const enum Modes { |
| PAN = 'Pan', |
| ROTATE = 'Rotate', |
| } |