@cropper/element-shade
Version:
A custom shade element for the Cropper.
482 lines (476 loc) • 17.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_SELECTION = `${NAMESPACE}-selection`;
const CROPPER_SHADE = `${NAMESPACE}-shade`;
// Actions
const ACTION_SELECT = 'select';
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 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 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;
}
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;height:0;left:0;outline:var(--theme-color) solid 1px;position:relative;top:0;width:0}:host([transparent]){outline-color:transparent}`;
const canvasCache = new WeakMap();
class CropperShade extends CropperElement {
constructor() {
super(...arguments);
this.$onCanvasChange = null;
this.$onCanvasActionEnd = null;
this.$onCanvasActionStart = null;
this.$style = style;
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
this.slottable = false;
this.themeColor = 'rgba(0, 0, 0, 0.65)';
}
set $canvas(element) {
canvasCache.set(this, element);
}
get $canvas() {
return canvasCache.get(this);
}
static get observedAttributes() {
return super.observedAttributes.concat([
'height',
'width',
'x',
'y',
]);
}
connectedCallback() {
super.connectedCallback();
const $canvas = this.closest(this.$getTagNameOf(CROPPER_CANVAS));
if ($canvas) {
this.$canvas = $canvas;
this.style.position = 'absolute';
const $selection = $canvas.querySelector(this.$getTagNameOf(CROPPER_SELECTION));
if ($selection) {
this.$onCanvasActionStart = (event) => {
if ($selection.hidden && event.detail.action === ACTION_SELECT) {
this.hidden = false;
}
};
this.$onCanvasActionEnd = (event) => {
if ($selection.hidden && event.detail.action === ACTION_SELECT) {
this.hidden = true;
}
};
this.$onCanvasChange = (event) => {
const { x, y, width, height, } = event.detail;
this.$change(x, y, width, height);
if ($selection.hidden || (x === 0 && y === 0 && width === 0 && height === 0)) {
this.hidden = true;
}
};
on($canvas, EVENT_ACTION_START, this.$onCanvasActionStart);
on($canvas, EVENT_ACTION_END, this.$onCanvasActionEnd);
on($canvas, EVENT_CHANGE, this.$onCanvasChange);
}
}
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.$onCanvasChange) {
off($canvas, EVENT_CHANGE, this.$onCanvasChange);
this.$onCanvasChange = null;
}
}
super.disconnectedCallback();
}
/**
* Changes the position and/or size of the shade.
* @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.
* @returns {CropperShade} Returns `this` for chaining.
*/
$change(x, y, width = this.width, height = this.height) {
if (!isNumber(x)
|| !isNumber(y)
|| !isNumber(width)
|| !isNumber(height)
|| (x === this.x && y === this.y && width === this.width && height === this.height)) {
return this;
}
if (this.hidden) {
this.hidden = false;
}
this.x = x;
this.y = y;
this.width = width;
this.height = height;
return this.$render();
}
/**
* Resets the shade to its initial position and size.
* @returns {CropperShade} Returns `this` for chaining.
*/
$reset() {
return this.$change(0, 0, 0, 0);
}
/**
* Refreshes the position or size of the shade.
* @returns {CropperShade} Returns `this` for chaining.
*/
$render() {
return this.$setStyles({
transform: `translate(${this.x}px, ${this.y}px)`,
width: this.width,
height: this.height,
outlineWidth: WINDOW.innerWidth,
});
}
}
CropperShade.$name = CROPPER_SHADE;
CropperShade.$version = '2.0.0';
export { CropperShade as default };