@cropper/element-selection
Version:
A custom selection element for the Cropper.
846 lines (839 loc) • 34.3 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('@cropper/element'), require('@cropper/utils')) :
typeof define === 'function' && define.amd ? define(['@cropper/element', '@cropper/utils'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.CropperSelection = factory(global.CropperElement, global.CropperUtils));
})(this, (function (CropperElement, utils) { 'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var CropperElement__default = /*#__PURE__*/_interopDefaultLegacy(CropperElement);
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__default["default"] {
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 (utils.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);
utils.on(this.ownerDocument, utils.EVENT_KEYDOWN, this.$onDocumentKeyDown);
}
}
else if (this.$onDocumentKeyDown) {
utils.off(this.ownerDocument, utils.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(utils.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(utils.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);
utils.on($canvas, utils.EVENT_ACTION_START, this.$onCanvasActionStart);
utils.on($canvas, utils.EVENT_ACTION_END, this.$onCanvasActionEnd);
utils.on($canvas, utils.EVENT_ACTION, this.$onCanvasAction);
}
else {
this.$render();
}
}
disconnectedCallback() {
const { $canvas } = this;
if ($canvas) {
if (this.$onCanvasActionStart) {
utils.off($canvas, utils.EVENT_ACTION_START, this.$onCanvasActionStart);
this.$onCanvasActionStart = null;
}
if (this.$onCanvasActionEnd) {
utils.off($canvas, utils.EVENT_ACTION_END, this.$onCanvasActionEnd);
this.$onCanvasActionEnd = null;
}
if (this.$onCanvasAction) {
utils.off($canvas, utils.EVENT_ACTION, this.$onCanvasAction);
this.$onCanvasAction = null;
}
}
super.disconnectedCallback();
}
$getSelections() {
let selections = [];
if (this.parentElement) {
selections = Array.from(this.parentElement.querySelectorAll(this.$getTagNameOf(utils.CROPPER_SELECTION)));
}
return selections;
}
$initSelection(center = false, resize = false) {
const { initialCoverage, parentElement } = this;
if (utils.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 (utils.isPositiveNumber(aspectRatio)) {
({ width, height } = utils.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(utils.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(utils.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 !== utils.ACTION_SELECT)
|| (this.multiple && !this.active && action !== utils.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 (!utils.isPositiveNumber(aspectRatio) && relatedEvent.shiftKey) {
aspectRatio = utils.isPositiveNumber(width) && utils.isPositiveNumber(height) ? width / height : 1;
}
switch (action) {
case utils.ACTION_SELECT:
if (moveX !== 0 && moveY !== 0) {
const { $canvas } = this;
const offset = utils.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 = utils.ACTION_RESIZE_NORTHWEST;
}
else if (moveY > 0) {
// ↙️
action = utils.ACTION_RESIZE_SOUTHWEST;
}
}
else if (moveX > 0) {
if (moveY < 0) {
// ↗️
action = utils.ACTION_RESIZE_NORTHEAST;
}
else if (moveY > 0) {
// ↘️
action = utils.ACTION_RESIZE_SOUTHEAST;
}
}
if ($canvas) {
$canvas.$action = action;
}
}
break;
case utils.ACTION_MOVE:
if (this.movable && (this.dynamic
|| (this.$actionStartTarget && this.contains(this.$actionStartTarget)))) {
this.$move(moveX, moveY);
}
break;
case utils.ACTION_SCALE:
if (relatedEvent && this.zoomable && (this.dynamic
|| this.contains(relatedEvent.target))) {
const offset = utils.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 = utils.isPositiveNumber(aspectRatio);
const { $canvas } = this;
let { x, y, width, height, } = this;
switch (action) {
case utils.ACTION_RESIZE_NORTH:
y += offsetY;
height -= offsetY;
if (height < 0) {
action = utils.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 utils.ACTION_RESIZE_EAST:
width += offsetX;
if (width < 0) {
action = utils.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 utils.ACTION_RESIZE_SOUTH:
height += offsetY;
if (height < 0) {
action = utils.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 utils.ACTION_RESIZE_WEST:
x += offsetX;
width -= offsetX;
if (width < 0) {
action = utils.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 utils.ACTION_RESIZE_NORTHEAST:
if (hasValidAspectRatio) {
offsetY = -offsetX / aspectRatio;
}
y += offsetY;
height -= offsetY;
width += offsetX;
if (width < 0 && height < 0) {
action = utils.ACTION_RESIZE_SOUTHWEST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = utils.ACTION_RESIZE_NORTHWEST;
width = -width;
x -= width;
}
else if (height < 0) {
action = utils.ACTION_RESIZE_SOUTHEAST;
height = -height;
y -= height;
}
break;
case utils.ACTION_RESIZE_NORTHWEST:
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
}
x += offsetX;
y += offsetY;
width -= offsetX;
height -= offsetY;
if (width < 0 && height < 0) {
action = utils.ACTION_RESIZE_SOUTHEAST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = utils.ACTION_RESIZE_NORTHEAST;
width = -width;
x -= width;
}
else if (height < 0) {
action = utils.ACTION_RESIZE_SOUTHWEST;
height = -height;
y -= height;
}
break;
case utils.ACTION_RESIZE_SOUTHEAST:
if (hasValidAspectRatio) {
offsetY = offsetX / aspectRatio;
}
width += offsetX;
height += offsetY;
if (width < 0 && height < 0) {
action = utils.ACTION_RESIZE_NORTHWEST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = utils.ACTION_RESIZE_SOUTHWEST;
width = -width;
x -= width;
}
else if (height < 0) {
action = utils.ACTION_RESIZE_NORTHEAST;
height = -height;
y -= height;
}
break;
case utils.ACTION_RESIZE_SOUTHWEST:
if (hasValidAspectRatio) {
offsetY = -offsetX / aspectRatio;
}
x += offsetX;
width -= offsetX;
height += offsetY;
if (width < 0 && height < 0) {
action = utils.ACTION_RESIZE_NORTHEAST;
width = -width;
height = -height;
x -= width;
y -= height;
}
else if (width < 0) {
action = utils.ACTION_RESIZE_SOUTHEAST;
width = -width;
x -= width;
}
else if (height < 0) {
action = utils.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 (utils.isNumber(x) && utils.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
|| !utils.isNumber(x)
|| !utils.isNumber(y)
|| !utils.isNumber(width)
|| !utils.isNumber(height)
|| width < 0
|| height < 0) {
return this;
}
if (utils.isPositiveNumber(aspectRatio)) {
({ width, height } = utils.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(utils.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 (utils.isPlainObject(options)
&& (utils.isPositiveNumber(options.width) || utils.isPositiveNumber(options.height))) {
({ width, height } = utils.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(utils.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 (utils.isPlainObject(options) && utils.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 = utils.CROPPER_SELECTION;
CropperSelection.$version = '2.0.0';
return CropperSelection;
}));