@cropper/element-selection
Version:
A custom selection element for the Cropper.
1,280 lines (1,274 loc) • 47.3 kB
JavaScript
const IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined';
const WINDOW = IS_BROWSER ? window : {};
IS_BROWSER ? 'ontouchstart' in WINDOW.document.documentElement : false;
const NAMESPACE = 'cropper';
const CROPPER_CANVAS = `${NAMESPACE}-canvas`;
const CROPPER_IMAGE = `${NAMESPACE}-image`;
const CROPPER_SELECTION = `${NAMESPACE}-selection`;
// Actions
const ACTION_SELECT = 'select';
const ACTION_MOVE = 'move';
const ACTION_SCALE = 'scale';
const ACTION_RESIZE_NORTH = 'n-resize';
const ACTION_RESIZE_EAST = 'e-resize';
const ACTION_RESIZE_SOUTH = 's-resize';
const ACTION_RESIZE_WEST = 'w-resize';
const ACTION_RESIZE_NORTHEAST = 'ne-resize';
const ACTION_RESIZE_NORTHWEST = 'nw-resize';
const ACTION_RESIZE_SOUTHEAST = 'se-resize';
const ACTION_RESIZE_SOUTHWEST = 'sw-resize';
const EVENT_KEYDOWN = 'keydown';
// Custom events
const EVENT_ACTION = 'action';
const EVENT_ACTION_END = 'actionend';
const EVENT_ACTION_START = 'actionstart';
const EVENT_CHANGE = 'change';
/**
* Check if the given value is not a number.
*/
const isNaN = Number.isNaN || WINDOW.isNaN;
/**
* Check if the given value is a number.
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if the given value is a number, else `false`.
*/
function isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Check if the given value is a positive number.
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if the given value is a positive number, else `false`.
*/
function isPositiveNumber(value) {
return isNumber(value) && value > 0 && value < Infinity;
}
/**
* Check if the given value is undefined.
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if the given value is undefined, else `false`.
*/
function isUndefined(value) {
return typeof value === 'undefined';
}
/**
* Check if the given value is an object.
* @param {*} value - The value to check.
* @returns {boolean} Returns `true` if the given value is an object, else `false`.
*/
function isObject(value) {
return typeof value === 'object' && value !== null;
}
const { hasOwnProperty } = Object.prototype;
/**
* Check if the given value is a plain object.
* @param {*} value - The value to check.
* @returns {boolean} Returns `true` if the given value is a plain object, else `false`.
*/
function isPlainObject(value) {
if (!isObject(value)) {
return false;
}
try {
const { constructor } = value;
const { prototype } = constructor;
return constructor && prototype && hasOwnProperty.call(prototype, 'isPrototypeOf');
}
catch (error) {
return false;
}
}
/**
* Check if the given value is a function.
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if the given value is a function, else `false`.
*/
function isFunction(value) {
return typeof value === 'function';
}
const REGEXP_CAMEL_CASE = /([a-z\d])([A-Z])/g;
/**
* Transform the given string from camelCase to kebab-case.
* @param {string} value The value to transform.
* @returns {string} Returns the transformed value.
*/
function toKebabCase(value) {
return String(value).replace(REGEXP_CAMEL_CASE, '$1-$2').toLowerCase();
}
const REGEXP_KEBAB_CASE = /-[A-z\d]/g;
/**
* Transform the given string from kebab-case to camelCase.
* @param {string} value The value to transform.
* @returns {string} Returns the transformed value.
*/
function toCamelCase(value) {
return value.replace(REGEXP_KEBAB_CASE, (substring) => substring.slice(1).toUpperCase());
}
const REGEXP_SPACES = /\s\s*/;
/**
* Remove event listener from the event target.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener}
* @param {EventTarget} target The target of the event.
* @param {string} types The types of the event.
* @param {EventListenerOrEventListenerObject} listener The listener of the event.
* @param {EventListenerOptions} [options] The options specify characteristics about the event listener.
*/
function off(target, types, listener, options) {
types.trim().split(REGEXP_SPACES).forEach((type) => {
target.removeEventListener(type, listener, options);
});
}
/**
* Add event listener to the event target.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener}
* @param {EventTarget} target The target of the event.
* @param {string} types The types of the event.
* @param {EventListenerOrEventListenerObject} listener The listener of the event.
* @param {AddEventListenerOptions} [options] The options specify characteristics about the event listener.
*/
function on(target, types, listener, options) {
types.trim().split(REGEXP_SPACES).forEach((type) => {
target.addEventListener(type, listener, options);
});
}
const defaultEventOptions = {
bubbles: true,
cancelable: true,
composed: true,
};
/**
* Dispatch event on the event target.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent}
* @param {EventTarget} target The target of the event.
* @param {string} type The name of the event.
* @param {*} [detail] The data passed when initializing the event.
* @param {CustomEventInit} [options] The other event options.
* @returns {boolean} Returns the result value.
*/
function emit(target, type, detail, options) {
return target.dispatchEvent(new CustomEvent(type, Object.assign(Object.assign(Object.assign({}, defaultEventOptions), { detail }), options)));
}
const resolvedPromise = Promise.resolve();
/**
* Defers the callback to be executed after the next DOM update cycle.
* @param {*} [context] The `this` context.
* @param {Function} [callback] The callback to execute after the next DOM update cycle.
* @returns {Promise} A promise that resolves to nothing.
*/
function nextTick(context, callback) {
return callback
? resolvedPromise.then(context ? callback.bind(context) : callback)
: resolvedPromise;
}
/**
* Get the offset base on the document.
* @param {Element} element The target element.
* @returns {object} The offset data.
*/
function getOffset(element) {
const { documentElement } = element.ownerDocument;
const box = element.getBoundingClientRect();
return {
left: box.left + (WINDOW.pageXOffset - documentElement.clientLeft),
top: box.top + (WINDOW.pageYOffset - documentElement.clientTop),
};
}
const SIZE_ADJUSTMENT_TYPE_CONTAIN = 'contain';
const SIZE_ADJUSTMENT_TYPE_COVER = 'cover';
/**
* Get the max sizes in a rectangle under the given aspect ratio.
* @param {object} data The original sizes.
* @param {string} [type] The adjust type.
* @returns {object} Returns the result sizes.
*/
function getAdjustedSizes(data, type = SIZE_ADJUSTMENT_TYPE_CONTAIN) {
const { aspectRatio } = data;
let { width, height } = data;
const isValidWidth = isPositiveNumber(width);
const isValidHeight = isPositiveNumber(height);
if (isValidWidth && isValidHeight) {
const adjustedWidth = height * aspectRatio;
if ((type === SIZE_ADJUSTMENT_TYPE_CONTAIN && adjustedWidth > width)
|| (type === SIZE_ADJUSTMENT_TYPE_COVER && adjustedWidth < width)) {
height = width / aspectRatio;
}
else {
width = height * aspectRatio;
}
}
else if (isValidWidth) {
height = width / aspectRatio;
}
else if (isValidHeight) {
width = height * aspectRatio;
}
return {
width,
height,
};
}
var style$1 = `:host([hidden]){display:none!important}`;
const REGEXP_SUFFIX = /left|top|width|height/i;
const DEFAULT_SHADOW_ROOT_MODE = 'open';
const shadowRoots = new WeakMap();
const styleSheets = new WeakMap();
const tagNames = new Map();
const supportsAdoptedStyleSheets = WINDOW.document && Array.isArray(WINDOW.document.adoptedStyleSheets) && 'replaceSync' in WINDOW.CSSStyleSheet.prototype;
class CropperElement extends HTMLElement {
get $sharedStyle() {
return `${this.themeColor ? `:host{--theme-color: ${this.themeColor};}` : ''}${style$1}`;
}
constructor() {
var _a, _b;
super();
this.shadowRootMode = DEFAULT_SHADOW_ROOT_MODE;
this.slottable = true;
const name = (_b = (_a = Object.getPrototypeOf(this)) === null || _a === void 0 ? void 0 : _a.constructor) === null || _b === void 0 ? void 0 : _b.$name;
if (name) {
tagNames.set(name, this.tagName.toLowerCase());
}
}
static get observedAttributes() {
return [
'shadow-root-mode',
'slottable',
'theme-color',
];
}
// Convert attribute to property
attributeChangedCallback(name, oldValue, newValue) {
if (Object.is(newValue, oldValue)) {
return;
}
const propertyName = toCamelCase(name);
const oldPropertyValue = this[propertyName];
let newPropertyValue = newValue;
switch (typeof oldPropertyValue) {
case 'boolean':
newPropertyValue = newValue !== null && newValue !== 'false';
break;
case 'number':
newPropertyValue = Number(newValue);
break;
}
this[propertyName] = newPropertyValue;
switch (name) {
case 'theme-color': {
const styleSheet = styleSheets.get(this);
const styles = this.$sharedStyle;
if (styleSheet && styles) {
if (supportsAdoptedStyleSheets) {
styleSheet.replaceSync(styles);
}
else {
styleSheet.textContent = styles;
}
}
break;
}
}
}
// Convert property to attribute
$propertyChangedCallback(name, oldValue, newValue) {
if (Object.is(newValue, oldValue)) {
return;
}
name = toKebabCase(name);
switch (typeof newValue) {
case 'boolean':
if (newValue === true) {
if (!this.hasAttribute(name)) {
this.setAttribute(name, '');
}
}
else {
this.removeAttribute(name);
}
break;
case 'number':
if (isNaN(newValue)) {
newValue = '';
}
else {
newValue = String(newValue);
}
// Fall through
// case 'string':
// eslint-disable-next-line no-fallthrough
default:
if (newValue) {
if (this.getAttribute(name) !== newValue) {
this.setAttribute(name, newValue);
}
}
else {
this.removeAttribute(name);
}
}
}
connectedCallback() {
// Observe properties after observed attributes
Object.getPrototypeOf(this).constructor.observedAttributes.forEach((attribute) => {
const property = toCamelCase(attribute);
let value = this[property];
if (!isUndefined(value)) {
this.$propertyChangedCallback(property, undefined, value);
}
Object.defineProperty(this, property, {
enumerable: true,
configurable: true,
get() {
return value;
},
set(newValue) {
const oldValue = value;
value = newValue;
this.$propertyChangedCallback(property, oldValue, newValue);
},
});
});
const shadow = this.attachShadow({
mode: this.shadowRootMode || DEFAULT_SHADOW_ROOT_MODE,
});
if (!this.shadowRoot) {
shadowRoots.set(this, shadow);
}
styleSheets.set(this, this.$addStyles(this.$sharedStyle));
if (this.$style) {
this.$addStyles(this.$style);
}
if (this.$template) {
const template = document.createElement('template');
template.innerHTML = this.$template;
shadow.appendChild(template.content);
}
if (this.slottable) {
const slot = document.createElement('slot');
shadow.appendChild(slot);
}
}
disconnectedCallback() {
if (styleSheets.has(this)) {
styleSheets.delete(this);
}
if (shadowRoots.has(this)) {
shadowRoots.delete(this);
}
}
// eslint-disable-next-line class-methods-use-this
$getTagNameOf(name) {
var _a;
return (_a = tagNames.get(name)) !== null && _a !== void 0 ? _a : name;
}
$setStyles(properties) {
Object.keys(properties).forEach((property) => {
let value = properties[property];
if (isNumber(value)) {
if (value !== 0 && REGEXP_SUFFIX.test(property)) {
value = `${value}px`;
}
else {
value = String(value);
}
}
this.style[property] = value;
});
return this;
}
/**
* Outputs the shadow root of the element.
* @returns {ShadowRoot} Returns the shadow root.
*/
$getShadowRoot() {
return this.shadowRoot || shadowRoots.get(this);
}
/**
* Adds styles to the shadow root.
* @param {string} styles The styles to add.
* @returns {CSSStyleSheet|HTMLStyleElement} Returns the generated style sheet.
*/
$addStyles(styles) {
let styleSheet;
const shadow = this.$getShadowRoot();
if (supportsAdoptedStyleSheets) {
styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(styles);
shadow.adoptedStyleSheets = shadow.adoptedStyleSheets.concat(styleSheet);
}
else {
styleSheet = document.createElement('style');
styleSheet.textContent = styles;
shadow.appendChild(styleSheet);
}
return styleSheet;
}
/**
* Dispatches an event at the element.
* @param {string} type The name of the event.
* @param {*} [detail] The data passed when initializing the event.
* @param {CustomEventInit} [options] The other event options.
* @returns {boolean} Returns the result value.
*/
$emit(type, detail, options) {
return emit(this, type, detail, options);
}
/**
* Defers the callback to be executed after the next DOM update cycle.
* @param {Function} [callback] The callback to execute after the next DOM update cycle.
* @returns {Promise} A promise that resolves to nothing.
*/
$nextTick(callback) {
return nextTick(this, callback);
}
/**
* Defines the constructor as a new custom element.
* {@link https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define}
* @param {string|object} [name] The element name.
* @param {object} [options] The element definition options.
*/
static $define(name, options) {
if (isObject(name)) {
options = name;
name = '';
}
if (!name) {
name = this.$name || this.name;
}
name = toKebabCase(name);
if (IS_BROWSER && WINDOW.customElements && !WINDOW.customElements.get(name)) {
customElements.define(name, this, options);
}
}
}
CropperElement.$version = '2.0.0';
var style = `:host{display:block;left:0;position:relative;right:0}:host([outlined]){outline:1px solid var(--theme-color)}:host([multiple]){outline:1px dashed hsla(0,0%,100%,.5)}:host([multiple]):after{bottom:0;content:"";cursor:pointer;display:block;left:0;position:absolute;right:0;top:0}:host([multiple][active]){outline-color:var(--theme-color);z-index:1}:host([multiple])>*{visibility:hidden}:host([multiple][active])>*{visibility:visible}:host([multiple][active]):after{display:none}`;
const canvasCache = new WeakMap();
class CropperSelection extends CropperElement {
constructor() {
super(...arguments);
this.$onCanvasAction = null;
this.$onCanvasActionStart = null;
this.$onCanvasActionEnd = null;
this.$onDocumentKeyDown = null;
this.$action = '';
this.$actionStartTarget = null;
this.$changing = false;
this.$style = style;
this.$initialSelection = {
x: 0,
y: 0,
width: 0,
height: 0,
};
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
this.aspectRatio = NaN;
this.initialAspectRatio = NaN;
this.initialCoverage = NaN;
this.active = false;
// Deprecated as of v2.0.0-rc.0, use `dynamic` instead.
this.linked = false;
this.dynamic = false;
this.movable = false;
this.resizable = false;
this.zoomable = false;
this.multiple = false;
this.keyboard = false;
this.outlined = false;
this.precise = false;
}
set $canvas(element) {
canvasCache.set(this, element);
}
get $canvas() {
return canvasCache.get(this);
}
static get observedAttributes() {
return super.observedAttributes.concat([
'active',
'aspect-ratio',
'dynamic',
'height',
'initial-aspect-ratio',
'initial-coverage',
'keyboard',
'linked',
'movable',
'multiple',
'outlined',
'precise',
'resizable',
'width',
'x',
'y',
'zoomable',
]);
}
$propertyChangedCallback(name, oldValue, newValue) {
if (Object.is(newValue, oldValue)) {
return;
}
super.$propertyChangedCallback(name, oldValue, newValue);
switch (name) {
case 'x':
case 'y':
case 'width':
case 'height':
if (!this.$changing) {
this.$nextTick(() => {
this.$change(this.x, this.y, this.width, this.height, this.aspectRatio, true);
});
}
break;
case 'aspectRatio':
case 'initialAspectRatio':
this.$nextTick(() => {
this.$initSelection();
});
break;
case 'initialCoverage':
this.$nextTick(() => {
if (isPositiveNumber(newValue) && newValue <= 1) {
this.$initSelection(true, true);
}
});
break;
case 'keyboard':
this.$nextTick(() => {
if (this.$canvas) {
if (newValue) {
if (!this.$onDocumentKeyDown) {
this.$onDocumentKeyDown = this.$handleKeyDown.bind(this);
on(this.ownerDocument, EVENT_KEYDOWN, this.$onDocumentKeyDown);
}
}
else if (this.$onDocumentKeyDown) {
off(this.ownerDocument, EVENT_KEYDOWN, this.$onDocumentKeyDown);
this.$onDocumentKeyDown = null;
}
}
});
break;
case 'multiple':
this.$nextTick(() => {
if (this.$canvas) {
const selections = this.$getSelections();
if (newValue) {
selections.forEach((selection) => {
selection.active = false;
});
this.active = true;
this.$emit(EVENT_CHANGE, {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
});
}
else {
this.active = false;
selections.slice(1).forEach((selection) => {
this.$removeSelection(selection);
});
}
}
});
break;
case 'precise':
this.$nextTick(() => {
this.$change(this.x, this.y);
});
break;
// Backwards compatible with 2.0.0-rc
case 'linked':
if (newValue) {
this.dynamic = true;
}
break;
}
}
connectedCallback() {
super.connectedCallback();
const $canvas = this.closest(this.$getTagNameOf(CROPPER_CANVAS));
if ($canvas) {
this.$canvas = $canvas;
this.$setStyles({
position: 'absolute',
transform: `translate(${this.x}px, ${this.y}px)`,
});
if (!this.hidden) {
this.$render();
}
this.$initSelection(true);
this.$onCanvasActionStart = this.$handleActionStart.bind(this);
this.$onCanvasActionEnd = this.$handleActionEnd.bind(this);
this.$onCanvasAction = this.$handleAction.bind(this);
on($canvas, EVENT_ACTION_START, this.$onCanvasActionStart);
on($canvas, EVENT_ACTION_END, this.$onCanvasActionEnd);
on($canvas, EVENT_ACTION, this.$onCanvasAction);
}
else {
this.$render();
}
}
disconnectedCallback() {
const { $canvas } = this;
if ($canvas) {
if (this.$onCanvasActionStart) {
off($canvas, EVENT_ACTION_START, this.$onCanvasActionStart);
this.$onCanvasActionStart = null;
}
if (this.$onCanvasActionEnd) {
off($canvas, EVENT_ACTION_END, this.$onCanvasActionEnd);
this.$onCanvasActionEnd = null;
}
if (this.$onCanvasAction) {
off($canvas, EVENT_ACTION, this.$onCanvasAction);
this.$onCanvasAction = null;
}
}
super.disconnectedCallback();
}
$getSelections() {
let selections = [];
if (this.parentElement) {
selections = Array.from(this.parentElement.querySelectorAll(this.$getTagNameOf(CROPPER_SELECTION)));
}
return selections;
}
$initSelection(center = false, resize = false) {
const { initialCoverage, parentElement } = this;
if (isPositiveNumber(initialCoverage) && parentElement) {
const aspectRatio = this.aspectRatio || this.initialAspectRatio;
let width = (resize ? 0 : this.width) || parentElement.offsetWidth * initialCoverage;
let height = (resize ? 0 : this.height) || parentElement.offsetHeight * initialCoverage;
if (isPositiveNumber(aspectRatio)) {
({ width, height } = getAdjustedSizes({ aspectRatio, width, height }));
}
this.$change(this.x, this.y, width, height);
if (center) {
this.$center();
}
// Overrides the initial position and size
this.$initialSelection = {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
};
}
}
$createSelection() {
const newSelection = this.cloneNode(true);
if (this.hasAttribute('id')) {
newSelection.removeAttribute('id');
}
newSelection.initialCoverage = NaN;
this.active = false;
if (this.parentElement) {
this.parentElement.insertBefore(newSelection, this.nextSibling);
}
return newSelection;
}
$removeSelection(selection = this) {
if (this.parentElement) {
const selections = this.$getSelections();
if (selections.length > 1) {
const index = selections.indexOf(selection);
const activeSelection = selections[index + 1] || selections[index - 1];
if (activeSelection) {
selection.active = false;
this.parentElement.removeChild(selection);
activeSelection.active = true;
activeSelection.$emit(EVENT_CHANGE, {
x: activeSelection.x,
y: activeSelection.y,
width: activeSelection.width,
height: activeSelection.height,
});
}
}
else {
this.$clear();
}
}
}
$handleActionStart(event) {
var _a, _b;
const relatedTarget = (_b = (_a = event.detail) === null || _a === void 0 ? void 0 : _a.relatedEvent) === null || _b === void 0 ? void 0 : _b.target;
this.$action = '';
this.$actionStartTarget = relatedTarget;
if (!this.hidden
&& this.multiple
&& !this.active
&& relatedTarget === this
&& this.parentElement) {
this.$getSelections().forEach((selection) => {
selection.active = false;
});
this.active = true;
this.$emit(EVENT_CHANGE, {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
});
}
}
$handleAction(event) {
const { currentTarget, detail } = event;
if (!currentTarget || !detail) {
return;
}
const { relatedEvent } = detail;
let { action } = detail;
// Switching to another selection
if (!action && this.multiple) {
// Get the `action` property from the focusing in selection
action = this.$action || (relatedEvent === null || relatedEvent === void 0 ? void 0 : relatedEvent.target.action);
this.$action = action;
}
if (!action
|| (this.hidden && action !== ACTION_SELECT)
|| (this.multiple && !this.active && action !== ACTION_SCALE)) {
return;
}
const moveX = detail.endX - detail.startX;
const moveY = detail.endY - detail.startY;
const { width, height } = this;
let { aspectRatio } = this;
// Locking aspect ratio by holding shift key
if (!isPositiveNumber(aspectRatio) && relatedEvent.shiftKey) {
aspectRatio = isPositiveNumber(width) && isPositiveNumber(height) ? width / height : 1;
}
switch (action) {
case ACTION_SELECT:
if (moveX !== 0 && moveY !== 0) {
const { $canvas } = this;
const offset = getOffset(currentTarget);
(this.multiple && !this.hidden ? this.$createSelection() : this).$change(detail.startX - offset.left, detail.startY - offset.top, Math.abs(moveX), Math.abs(moveY), aspectRatio);
if (moveX < 0) {
if (moveY < 0) {
// ↖️
action = ACTION_RESIZE_NORTHWEST;
}
else if (moveY > 0) {
// ↙️
action = ACTION_RESIZE_SOUTHWEST;
}
}
else if (moveX > 0) {
if (moveY < 0) {
// ↗️
action = ACTION_RESIZE_NORTHEAST;
}
else if (moveY > 0) {
// ↘️
action = ACTION_RESIZE_SOUTHEAST;
}
}
if ($canvas) {
$canvas.$action = action;
}
}
break;
case ACTION_MOVE:
if (this.movable && (this.dynamic
|| (this.$actionStartTarget && this.contains(this.$actionStartTarget)))) {
this.$move(moveX, moveY);
}
break;
case ACTION_SCALE:
if (relatedEvent && this.zoomable && (this.dynamic
|| this.contains(relatedEvent.target))) {
const offset = getOffset(currentTarget);
this.$zoom(detail.scale, relatedEvent.pageX - offset.left, relatedEvent.pageY - offset.top);
}
break;
default:
this.$resize(action, moveX, moveY, aspectRatio);
}
}
$handleActionEnd() {
this.$action = '';
this.$actionStartTarget = null;
}
$handleKeyDown(event) {
if (this.hidden
|| !this.keyboard
|| (this.multiple && !this.active)
|| event.defaultPrevented) {
return;
}
const { activeElement } = document;
// Disable keyboard control when input something
if (activeElement && (['INPUT', 'TEXTAREA'].includes(activeElement.tagName)
|| ['true', 'plaintext-only'].includes(activeElement.contentEditable))) {
return;
}
switch (event.key) {
case 'Backspace':
if (event.metaKey) {
event.preventDefault();
this.$removeSelection();
}
break;
case 'Delete':
event.preventDefault();
this.$removeSelection();
break;
// Move to the left
case 'ArrowLeft':
event.preventDefault();
this.$move(-1, 0);
break;
// Move to the right
case 'ArrowRight':
event.preventDefault();
this.$move(1, 0);
break;
// Move to the top
case 'ArrowUp':
event.preventDefault();
this.$move(0, -1);
break;
// Move to the bottom
case 'ArrowDown':
event.preventDefault();
this.$move(0, 1);
break;
case '+':
event.preventDefault();
this.$zoom(0.1);
break;
case '-':
event.preventDefault();
this.$zoom(-0.1);
break;
}
}
/**
* Aligns the selection to the center of its parent element.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$center() {
const { parentElement } = this;
if (!parentElement) {
return this;
}
const x = (parentElement.offsetWidth - this.width) / 2;
const y = (parentElement.offsetHeight - this.height) / 2;
return this.$change(x, y);
}
/**
* Moves the selection.
* @param {number} x The moving distance in the horizontal direction.
* @param {number} [y] The moving distance in the vertical direction.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$move(x, y = x) {
return this.$moveTo(this.x + x, this.y + y);
}
/**
* Moves the selection to a specific position.
* @param {number} x The new position in the horizontal direction.
* @param {number} [y] The new position in the vertical direction.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$moveTo(x, y = x) {
if (!this.movable) {
return this;
}
return this.$change(x, y);
}
/**
* Adjusts the size the selection on a specific side or corner.
* @param {string} action Indicates the side or corner to resize.
* @param {number} [offsetX] The horizontal offset of the specific side or corner.
* @param {number} [offsetY] The vertical offset of the specific side or corner.
* @param {number} [aspectRatio] The aspect ratio for computing the new size if it is necessary.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$resize(action, offsetX = 0, offsetY = 0, aspectRatio = this.aspectRatio) {
if (!this.resizable) {
return this;
}
const hasValidAspectRatio = isPositiveNumber(aspectRatio);
const { $canvas } = this;
let { x, y, width, height, } = this;
switch (action) {
case ACTION_RESIZE_NORTH:
y += offsetY;
height -= offsetY;
if (height < 0) {
action = ACTION_RESIZE_SOUTH;
height = -height;
y -= height;
}
if (hasValidAspectRatio) {
offsetX = offsetY * aspectRatio;
x += offsetX / 2;
width -= offsetX;
if (width < 0) {
width = -width;
x -= width;
}
}
break;
case ACTION_RESIZE_EAST:
width += offsetX;
if (width < 0) {
action = ACTION_RESIZE_WEST;
width = -width;
x -= width;
}
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
y -= offsetY / 2;
height += offsetY;
if (height < 0) {
height = -height;
y -= height;
}
}
break;
case ACTION_RESIZE_SOUTH:
height += offsetY;
if (height < 0) {
action = ACTION_RESIZE_NORTH;
height = -height;
y -= height;
}
if (hasValidAspectRatio) {
offsetX = offsetY * aspectRatio;
x -= offsetX / 2;
width += offsetX;
if (width < 0) {
width = -width;
x -= width;
}
}
break;
case ACTION_RESIZE_WEST:
x += offsetX;
width -= offsetX;
if (width < 0) {
action = ACTION_RESIZE_EAST;
width = -width;
x -= width;
}
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
y += offsetY / 2;
height -= offsetY;
if (height < 0) {
height = -height;
y -= height;
}
}
break;
case ACTION_RESIZE_NORTHEAST:
if (hasValidAspectRatio) {
offsetY = -offsetX / aspectRatio;
}
y += offsetY;
height -= offsetY;
width += offsetX;
if (width < 0 && height < 0) {
action = ACTION_RESIZE_SOUTHWEST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = ACTION_RESIZE_NORTHWEST;
width = -width;
x -= width;
}
else if (height < 0) {
action = ACTION_RESIZE_SOUTHEAST;
height = -height;
y -= height;
}
break;
case ACTION_RESIZE_NORTHWEST:
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
}
x += offsetX;
y += offsetY;
width -= offsetX;
height -= offsetY;
if (width < 0 && height < 0) {
action = ACTION_RESIZE_SOUTHEAST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = ACTION_RESIZE_NORTHEAST;
width = -width;
x -= width;
}
else if (height < 0) {
action = ACTION_RESIZE_SOUTHWEST;
height = -height;
y -= height;
}
break;
case ACTION_RESIZE_SOUTHEAST:
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
}
width += offsetX;
height += offsetY;
if (width < 0 && height < 0) {
action = ACTION_RESIZE_NORTHWEST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = ACTION_RESIZE_SOUTHWEST;
width = -width;
x -= width;
}
else if (height < 0) {
action = ACTION_RESIZE_NORTHEAST;
height = -height;
y -= height;
}
break;
case ACTION_RESIZE_SOUTHWEST:
if (hasValidAspectRatio) {
offsetY = -offsetX / aspectRatio;
}
x += offsetX;
width -= offsetX;
height += offsetY;
if (width < 0 && height < 0) {
action = ACTION_RESIZE_NORTHEAST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = ACTION_RESIZE_SOUTHEAST;
width = -width;
x -= width;
}
else if (height < 0) {
action = ACTION_RESIZE_NORTHWEST;
height = -height;
y -= height;
}
break;
}
if ($canvas) {
$canvas.$setAction(action);
}
return this.$change(x, y, width, height);
}
/**
* Zooms the selection.
* @param {number} scale The zoom factor. Positive numbers for zooming in, and negative numbers for zooming out.
* @param {number} [x] The zoom origin in the horizontal, defaults to the center of the selection.
* @param {number} [y] The zoom origin in the vertical, defaults to the center of the selection.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$zoom(scale, x, y) {
if (!this.zoomable || scale === 0) {
return this;
}
if (scale < 0) {
scale = 1 / (1 - scale);
}
else {
scale += 1;
}
const { width, height } = this;
const newWidth = width * scale;
const newHeight = height * scale;
let newX = this.x;
let newY = this.y;
if (isNumber(x) && isNumber(y)) {
newX -= (newWidth - width) * ((x - this.x) / width);
newY -= (newHeight - height) * ((y - this.y) / height);
}
else {
// Zoom from the center of the selection
newX -= (newWidth - width) / 2;
newY -= (newHeight - height) / 2;
}
return this.$change(newX, newY, newWidth, newHeight);
}
/**
* Changes the position and/or size of the selection.
* @param {number} x The new position in the horizontal direction.
* @param {number} y The new position in the vertical direction.
* @param {number} [width] The new width.
* @param {number} [height] The new height.
* @param {number} [aspectRatio] The new aspect ratio for this change only.
* @param {number} [_force] Force change.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$change(x, y, width = this.width, height = this.height, aspectRatio = this.aspectRatio, _force = false) {
if (this.$changing
|| !isNumber(x)
|| !isNumber(y)
|| !isNumber(width)
|| !isNumber(height)
|| width < 0
|| height < 0) {
return this;
}
if (isPositiveNumber(aspectRatio)) {
({ width, height } = getAdjustedSizes({ aspectRatio, width, height }, 'cover'));
}
if (!this.precise) {
x = Math.round(x);
y = Math.round(y);
width = Math.round(width);
height = Math.round(height);
}
if (x === this.x
&& y === this.y
&& width === this.width
&& height === this.height
&& Object.is(aspectRatio, this.aspectRatio)
&& !_force) {
return this;
}
if (this.hidden) {
this.hidden = false;
}
if (this.$emit(EVENT_CHANGE, {
x,
y,
width,
height,
}) === false) {
return this;
}
this.$changing = true;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.$changing = false;
return this.$render();
}
/**
* Resets the selection to its initial position and size.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$reset() {
const { x, y, width, height, } = this.$initialSelection;
return this.$change(x, y, width, height);
}
/**
* Clears the selection.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$clear() {
this.$change(0, 0, 0, 0, NaN, true);
this.hidden = true;
return this;
}
/**
* Refreshes the position or size of the selection.
* @returns {CropperSelection} Returns `this` for chaining.
*/
$render() {
return this.$setStyles({
transform: `translate(${this.x}px, ${this.y}px)`,
width: this.width,
height: this.height,
});
}
/**
* Generates a real canvas element, with the image (selected area only) draw into if there is one.
* @param {object} [options] The available options.
* @param {number} [options.width] The width of the canvas.
* @param {number} [options.height] The height of the canvas.
* @param {Function} [options.beforeDraw] The function called before drawing the image onto the canvas.
* @returns {Promise} Returns a promise that resolves to the generated canvas element.
*/
$toCanvas(options) {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
reject(new Error('The current element is not connected to the DOM.'));
return;
}
const canvas = document.createElement('canvas');
let { width, height } = this;
let scale = 1;
if (isPlainObject(options)
&& (isPositiveNumber(options.width) || isPositiveNumber(options.height))) {
({ width, height } = getAdjustedSizes({
aspectRatio: width / height,
width: options.width,
height: options.height,
}));
scale = width / this.width;
}
canvas.width = width;
canvas.height = height;
if (!this.$canvas) {
resolve(canvas);
return;
}
const cropperImage = this.$canvas.querySelector(this.$getTagNameOf(CROPPER_IMAGE));
if (!cropperImage) {
resolve(canvas);
return;
}
cropperImage.$ready().then((image) => {
const context = canvas.getContext('2d');
if (context) {
const [a, b, c, d, e, f] = cropperImage.$getTransform();
const offsetX = -this.x;
const offsetY = -this.y;
const translateX = ((offsetX * d) - (c * offsetY)) / ((a * d) - (c * b));
const translateY = ((offsetY * a) - (b * offsetX)) / ((a * d) - (c * b));
let newE = a * translateX + c * translateY + e;
let newF = b * translateX + d * translateY + f;
let destWidth = image.naturalWidth;
let destHeight = image.naturalHeight;
if (scale !== 1) {
newE *= scale;
newF *= scale;
destWidth *= scale;
destHeight *= scale;
}
const centerX = destWidth / 2;
const centerY = destHeight / 2;
context.fillStyle = 'transparent';
context.fillRect(0, 0, width, height);
if (isPlainObject(options) && isFunction(options.beforeDraw)) {
options.beforeDraw.call(this, context, canvas);
}
context.save();
// Move the transform origin to the center of the image.
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-origin
context.translate(centerX, centerY);
context.transform(a, b, c, d, newE, newF);
// Move the transform origin to the top-left of the image.
context.translate(-centerX, -centerY);
context.drawImage(image, 0, 0, destWidth, destHeight);
context.restore();
}
resolve(canvas);
}).catch(reject);
});
}
}
CropperSelection.$name = CROPPER_SELECTION;
CropperSelection.$version = '2.0.0';
export { CropperSelection as default };