// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './margin_control.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {Coordinate2d} from '../data/coordinate2d.js';
import type {Margins, MarginsSetting} from '../data/margins.js';
import {CustomMarginsOrientation, MarginsType} from '../data/margins.js';
import type {MeasurementSystem} from '../data/measurement_system.js';
import type {Size} from '../data/size.js';
import {State} from '../data/state.js';
import type {PrintPreviewMarginControlElement} from './margin_control.js';
import {getTemplate} from './margin_control_container.html.js';
import {SettingsMixin} from './settings_mixin.js';
export const MARGIN_KEY_MAP:
Map<CustomMarginsOrientation, keyof MarginsSetting> = new Map([
[CustomMarginsOrientation.TOP, 'marginTop'],
[CustomMarginsOrientation.RIGHT, 'marginRight'],
[CustomMarginsOrientation.BOTTOM, 'marginBottom'],
[CustomMarginsOrientation.LEFT, 'marginLeft'],
]);
const MINIMUM_DISTANCE: number = 72; // 1 inch
const PrintPreviewMarginControlContainerElementBase =
SettingsMixin(PolymerElement);
export class PrintPreviewMarginControlContainerElement extends
PrintPreviewMarginControlContainerElementBase {
static get is() {
return 'print-preview-margin-control-container';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
pageSize: {
type: Object,
notify: true,
},
documentMargins: {
type: Object,
notify: true,
},
previewLoaded: Boolean,
measurementSystem: Object,
state: {
type: Number,
observer: 'onStateChanged_',
},
scaleTransform_: {
type: Number,
notify: true,
value: 0,
},
translateTransform_: {
type: Object,
notify: true,
value: new Coordinate2d(0, 0),
},
clipSize_: {
type: Object,
notify: true,
value: null,
},
available_: {
type: Boolean,
notify: true,
computed: 'computeAvailable_(previewLoaded, settings.margins.value)',
observer: 'onAvailableChange_',
},
invisible_: {
type: Boolean,
reflectToAttribute: true,
value: true,
},
marginSides_: {
type: Array,
notify: true,
value: [
CustomMarginsOrientation.TOP,
CustomMarginsOrientation.RIGHT,
CustomMarginsOrientation.BOTTOM,
CustomMarginsOrientation.LEFT,
],
},
/**
* String attribute used to set cursor appearance. Possible values:
* empty (''): No margin control is currently being dragged.
* 'dragging-horizontal': The left or right control is being dragged.
* 'dragging-vertical': The top or bottom control is being dragged.
*/
dragging_: {
type: String,
reflectToAttribute: true,
value: '',
},
};
}
static get observers() {
return [
'onMarginSettingsChange_(settings.customMargins.value)',
'onMediaSizeOrLayoutChange_(' +
'settings.mediaSize.value, settings.layout.value)',
];
}
pageSize: Size;
documentMargins: Margins;
previewLoaded: boolean;
measurementSystem: MeasurementSystem|null;
state: State;
private available_: boolean;
private invisible_: boolean;
private clipSize_: Size;
private scaleTransform_: number;
private translateTransform_: Coordinate2d;
private dragging_: string;
private marginSides_: CustomMarginsOrientation[];
private pointerStartPositionInPixels_: Coordinate2d = new Coordinate2d(0, 0);
private marginStartPositionInPixels_: Coordinate2d|null = null;
private resetMargins_: boolean|null = null;
private eventTracker_: EventTracker = new EventTracker();
private textboxFocused_: boolean = false;
private computeAvailable_(): boolean {
return this.previewLoaded && !!this.clipSize_ &&
((this.getSettingValue('margins') as MarginsType) ===
MarginsType.CUSTOM) &&
!!this.pageSize;
}
private onAvailableChange_() {
if (this.available_ && this.resetMargins_) {
// Set the custom margins values to the current document margins if the
// custom margins were reset.
const newMargins: Partial<MarginsSetting> = {};
for (const side of Object.values(CustomMarginsOrientation)) {
const key = MARGIN_KEY_MAP.get(side)!;
newMargins[key] = this.documentMargins.get(side);
}
this.setSetting('customMargins', newMargins);
this.resetMargins_ = false;
}
this.invisible_ = !this.available_;
}
private onMarginSettingsChange_() {
const margins = this.getSettingValue('customMargins') as MarginsSetting;
if (!margins || margins.marginTop === undefined) {
// This may be called when print preview model initially sets the
// settings. It sets custom margins empty by default.
return;
}
this.shadowRoot!.querySelectorAll('print-preview-margin-control')
.forEach(control => {
const key = MARGIN_KEY_MAP.get(control.side)!;
const newValue = margins[key] || 0;
control.setPositionInPts(newValue);
control.setTextboxValue(newValue);
});
}
private onMediaSizeOrLayoutChange_() {
// Reset the custom margins when the paper size changes. Don't do this if
// it is the first preview.
if (this.resetMargins_ === null) {
return;
}
this.resetMargins_ = true;
// Reset custom margins so that the sticky value is not restored for the new
// paper size.
this.setSetting('customMargins', {});
}
private onStateChanged_() {
if (this.state === State.READY && this.resetMargins_ === null) {
// Don't reset margins if there are sticky values. Otherwise, set them
// to the document margins when the user selects custom margins.
const margins = this.getSettingValue('customMargins');
this.resetMargins_ = !margins || margins.marginTop === undefined;
}
}
/**
* @return Whether the controls should be disabled.
*/
private controlsDisabled_(): boolean {
return this.state !== State.READY || this.invisible_;
}
/**
* @param orientation Orientation value to test.
* @return Whether the given orientation is TOP or BOTTOM.
*/
private isTopOrBottom_(orientation: CustomMarginsOrientation): boolean {
return orientation === CustomMarginsOrientation.TOP ||
orientation === CustomMarginsOrientation.BOTTOM;
}
/**
* @param control Control being repositioned.
* @param posInPixels Desired position, in pixels.
* @return The new position for the control, in pts. Returns the
* position for the dimension that the control operates in, i.e.
* x direction for the left/right controls, y direction otherwise.
*/
private posInPixelsToPts_(
control: PrintPreviewMarginControlElement,
posInPixels: Coordinate2d): number {
const side = control.side;
return this.clipAndRoundValue_(
side,
control.convertPixelsToPts(
this.isTopOrBottom_(side) ? posInPixels.y : posInPixels.x));
}
/**
* Moves the position of the given control to the desired position in pts
* within some constraint minimum and maximum.
* @param control Control to move.
* @param posInPts Desired position to move to, in pts. Position is
* 1 dimensional and represents position in the x direction if control
* is for the left or right margin, and the y direction otherwise.
*/
private moveControlWithConstraints_(
control: PrintPreviewMarginControlElement, posInPts: number) {
control.setPositionInPts(posInPts);
control.setTextboxValue(posInPts);
}
/**
* Translates the position of the margin control relative to the pointer
* position in pixels.
* @param pointerPosition New position of the pointer.
* @return New position of the margin control.
*/
translatePointerToPositionInPixels(pointerPosition: Coordinate2d):
Coordinate2d {
return new Coordinate2d(
pointerPosition.x - this.pointerStartPositionInPixels_.x +
this.marginStartPositionInPixels_!.x,
pointerPosition.y - this.pointerStartPositionInPixels_.y +
this.marginStartPositionInPixels_!.y);
}
/**
* Called when the pointer moves in the custom margins component. Moves the
* dragged margin control.
* @param event Contains the position of the pointer.
*/
private onPointerMove_(event: PointerEvent) {
const control = event.target as PrintPreviewMarginControlElement;
const posInPts = this.posInPixelsToPts_(
control,
this.translatePointerToPositionInPixels(
new Coordinate2d(event.x, event.y)));
this.moveControlWithConstraints_(control, posInPts);
}
/**
* Called when the pointer is released in the custom margins component.
* Releases the dragged margin control.
* @param event Contains the position of the pointer.
*/
private onPointerUp_(event: PointerEvent) {
const control = event.target as PrintPreviewMarginControlElement;
this.dragging_ = '';
const posInPixels = this.translatePointerToPositionInPixels(
new Coordinate2d(event.x, event.y));
const posInPts = this.posInPixelsToPts_(control, posInPixels);
this.moveControlWithConstraints_(control, posInPts);
this.setMargin_(control.side, posInPts);
this.updateClippingMask(this.clipSize_);
this.eventTracker_.remove(control, 'pointercancel');
this.eventTracker_.remove(control, 'pointerup');
this.eventTracker_.remove(control, 'pointermove');
this.fireDragChanged_(false);
}
/**
* @param invisible Whether the margin controls should be invisible.
*/
setInvisible(invisible: boolean) {
// Ignore changes if the margin controls are not available.
if (!this.available_) {
return;
}
// Do not set the controls invisible if the user is dragging or focusing
// the textbox for one of them.
if (invisible && (this.dragging_ !== '' || this.textboxFocused_)) {
return;
}
this.invisible_ = invisible;
}
/**
* @param e Contains information about what control fired the event.
*/
private onTextFocus_(e: Event) {
this.textboxFocused_ = true;
const control = e.target as PrintPreviewMarginControlElement;
const x = control.offsetLeft;
const y = control.offsetTop;
const isTopOrBottom = this.isTopOrBottom_(control.side);
const position: {x?: number, y?: number} = {};
// Extra padding, in px, to ensure the full textbox will be visible and
// not just a portion of it. Can't be less than half the width or height
// of the clip area for the computations below to work.
const padding = Math.min(
Math.min(this.clipSize_.width / 2, this.clipSize_.height / 2), 50);
// Note: clipSize_ gives the current visible area of the margin control
// container. The offsets of the controls are relative to the origin of
// this visible area.
if (isTopOrBottom) {
// For top and bottom controls, the horizontal position of the box is
// around halfway across the control's width.
position.x = Math.min(x + control.offsetWidth / 2 - padding, 0);
position.x = Math.max(
x + control.offsetWidth / 2 + padding - this.clipSize_.width,
position.x);
// For top and bottom controls, the vertical position of the box is
// nearly the same as the vertical position of the control.
position.y = Math.min(y - padding, 0);
position.y = Math.max(y - this.clipSize_.height + padding, position.y);
} else {
// For left and right controls, the horizontal position of the box is
// nearly the same as the horizontal position of the control.
position.x = Math.min(x - padding, 0);
position.x = Math.max(x - this.clipSize_.width + padding, position.x);
// For top and bottom controls, the vertical position of the box is
// around halfway up the control's height.
position.y = Math.min(y + control.offsetHeight / 2 - padding, 0);
position.y = Math.max(
y + control.offsetHeight / 2 + padding - this.clipSize_.height,
position.y);
}
this.dispatchEvent(new CustomEvent(
'text-focus-position',
{bubbles: true, composed: true, detail: position}));
}
/**
* @param marginSide The margin side. Must be a CustomMarginsOrientation.
* @param marginValue New value for the margin in points.
*/
private setMargin_(
marginSide: CustomMarginsOrientation, marginValue: number) {
const oldMargins = this.getSettingValue('customMargins') as MarginsSetting;
const key = MARGIN_KEY_MAP.get(marginSide)!;
if (oldMargins[key] === marginValue) {
return;
}
const newMargins = Object.assign({}, oldMargins);
newMargins[key] = marginValue;
this.setSetting('customMargins', newMargins);
}
/**
* @param marginSide The margin side.
* @param value The new margin value in points.
* @return The clipped margin value in points.
*/
private clipAndRoundValue_(
marginSide: CustomMarginsOrientation, value: number): number {
if (value < 0) {
return 0;
}
const Orientation = CustomMarginsOrientation;
let limit = 0;
const margins = this.getSettingValue('customMargins') as MarginsSetting;
if (marginSide === Orientation.TOP) {
limit = this.pageSize.height - margins.marginBottom - MINIMUM_DISTANCE;
} else if (marginSide === Orientation.RIGHT) {
limit = this.pageSize.width - margins.marginLeft - MINIMUM_DISTANCE;
} else if (marginSide === Orientation.BOTTOM) {
limit = this.pageSize.height - margins.marginTop - MINIMUM_DISTANCE;
} else {
assert(marginSide === Orientation.LEFT);
limit = this.pageSize.width - margins.marginRight - MINIMUM_DISTANCE;
}
return Math.round(Math.min(value, limit));
}
/**
* @param e Event containing the new textbox value.
*/
private onTextChange_(e: CustomEvent<number>) {
const control = e.target as PrintPreviewMarginControlElement;
control.invalid = false;
const clippedValue = this.clipAndRoundValue_(control.side, e.detail);
control.setPositionInPts(clippedValue);
this.setMargin_(control.side, clippedValue);
}
/**
* @param e Event fired when a control's text field is blurred. Contains
* information about whether the control is in an invalid state.
*/
private onTextBlur_(e: CustomEvent<boolean>) {
const control = e.target as PrintPreviewMarginControlElement;
control.setTextboxValue(control.getPositionInPts());
if (e.detail /* detail is true if the control is in an invalid state */) {
control.invalid = false;
}
this.textboxFocused_ = false;
}
/**
* @param e Fired when pointerdown occurs on a margin control.
*/
private onPointerDown_(e: PointerEvent) {
const control = e.target as PrintPreviewMarginControlElement;
if (!control.shouldDrag(e)) {
return;
}
this.pointerStartPositionInPixels_ = new Coordinate2d(e.x, e.y);
this.marginStartPositionInPixels_ =
new Coordinate2d(control.offsetLeft, control.offsetTop);
this.dragging_ = this.isTopOrBottom_(control.side) ? 'dragging-vertical' :
'dragging-horizontal';
this.eventTracker_.add(
control, 'pointercancel', (e: PointerEvent) => this.onPointerUp_(e));
this.eventTracker_.add(
control, 'pointerup', (e: PointerEvent) => this.onPointerUp_(e));
this.eventTracker_.add(
control, 'pointermove', (e: PointerEvent) => this.onPointerMove_(e));
control.setPointerCapture(e.pointerId);
this.fireDragChanged_(true);
}
/**
* @param dragChanged
*/
private fireDragChanged_(dragChanged: boolean) {
this.dispatchEvent(new CustomEvent(
'margin-drag-changed',
{bubbles: true, composed: true, detail: dragChanged}));
}
/**
* Set display:none after the opacity transition for the controls is done.
*/
private onTransitionEnd_() {
if (this.invisible_) {
this.style.display = 'none';
}
}
/**
* Updates the translation transformation that translates pixel values in
* the space of the HTML DOM.
* @param translateTransform Updated value of the translation transformation.
*/
updateTranslationTransform(translateTransform: Coordinate2d) {
if (!translateTransform.equals(this.translateTransform_)) {
this.translateTransform_ = translateTransform;
}
}
/**
* Updates the scaling transform that scales pixels values to point values.
* @param scaleTransform Updated value of the scale transform.
*/
updateScaleTransform(scaleTransform: number) {
if (scaleTransform !== this.scaleTransform_) {
this.scaleTransform_ = scaleTransform;
}
}
/**
* Clips margin controls to the given clip size in pixels.
* @param clipSize Size to clip the margin controls to.
*/
updateClippingMask(clipSize: Size) {
if (!clipSize) {
return;
}
this.clipSize_ = clipSize;
this.notifyPath('clipSize_');
}
}
declare global {
interface HTMLElementTagNameMap {
'print-preview-margin-control-container':
PrintPreviewMarginControlContainerElement;
}
}
customElements.define(
PrintPreviewMarginControlContainerElement.is,
PrintPreviewMarginControlContainerElement);