UNPKG

@cropper/element-selection

Version:
846 lines (839 loc) 34.3 kB
(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; }));