UNPKG

lazy-widgets

Version:

Typescript retained mode GUI for the HTML canvas API

282 lines 11.8 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { damageField, layoutField, damageLayoutArrayField } from '../decorators/FlagFields.js'; import { Widget } from './Widget.js'; import { DynMsg } from '../core/Strings.js'; import { urlToBackingMediaSource } from '../helpers/BackingMediaSource.js'; import { BackingMediaWrapper } from '../helpers/BackingMediaWrapper.js'; import { BackingMediaEventType } from '../helpers/BackingMediaEventType.js'; // TODO rename this to MediaFit /** * The image fitting mode for {@link Icon} widgets; describes how an image is * transformed if its dimensions don't match the output dimensions. * * @category Widget */ export var IconFit; (function (IconFit) { /** * The image will be scaled down or up such that at least an axis of the * image has the same dimensions as the widget, and the entire image is * visible, preserving the aspect ratio of the image. The default image * fitting mode. */ IconFit[IconFit["Contain"] = 0] = "Contain"; /** * Similar to {@link IconFit.Contain}, except parts of the image can be cut * off so that all parts of the widget are covered by the image. */ IconFit[IconFit["Cover"] = 1] = "Cover"; /** * The image will be forced to have the same size as the widget by * stretching or squishing it. */ IconFit[IconFit["Fill"] = 2] = "Fill"; })(IconFit || (IconFit = {})); // TODO rename this to Media, and all related properties /** * A widget which displays a given image. * * @category Widget */ export class Icon extends Widget { constructor(image, properties) { var _a, _b, _c, _d, _e, _f; super(properties); /** Has the user already been warned about the broken media? */ this.warnedBroken = false; /** The current media rotation in radians. */ this.rotation = 0; /** * The wanted width. If null, the media's width will be used, taking * {@link Icon#viewBox} into account. */ this.imageWidth = null; /** * The wanted height. If null, the media's height will be used, taking * {@link Icon#viewBox} into account. */ this.imageHeight = null; /** Horizontal offset. */ this.offsetX = 0; /** Vertical offset. */ this.offsetY = 0; /** Actual image width. */ this.actualWidth = 0; /** Actual image height. */ this.actualHeight = 0; this.onMediaEvent = (ev) => { switch (ev) { case BackingMediaEventType.Dirty: this.markWholeAsDirty(); break; case BackingMediaEventType.Resized: this._layoutDirty = true; break; } }; if (typeof image === 'string') { image = urlToBackingMediaSource(image)[0]; } this._media = image === null ? null : new BackingMediaWrapper(image); this.rotation = (_a = properties === null || properties === void 0 ? void 0 : properties.rotation) !== null && _a !== void 0 ? _a : 0; this.viewBox = (_b = properties === null || properties === void 0 ? void 0 : properties.viewBox) !== null && _b !== void 0 ? _b : null; this.imageWidth = (_c = properties === null || properties === void 0 ? void 0 : properties.width) !== null && _c !== void 0 ? _c : null; this.imageHeight = (_d = properties === null || properties === void 0 ? void 0 : properties.height) !== null && _d !== void 0 ? _d : null; this.fit = (_e = properties === null || properties === void 0 ? void 0 : properties.fit) !== null && _e !== void 0 ? _e : IconFit.Contain; this.mediaPadding = (_f = properties === null || properties === void 0 ? void 0 : properties.mediaPadding) !== null && _f !== void 0 ? _f : { left: 0, right: 0, top: 0, bottom: 0 }; } handleAttachment() { if (this._media) { this._media.addEventListener(this.onMediaEvent); } } handleDetachment() { if (this._media) { this._media.removeEventListener(this.onMediaEvent); } } /** * The image or video used by this Icon. Sets {@link Icon#_media} if * changed. * * If getting, returns {@link Icon#_media}. */ set image(image) { if (image !== this._media) { if (this._media && this.attached) { this._media.removeEventListener(this.onMediaEvent); } this._media = image === null ? null : new BackingMediaWrapper(image); if (this._media && this.attached) { this._media.addEventListener(this.onMediaEvent); } } } get image() { return this._media === null ? null : this._media.source; } handleResolveDimensions(minWidth, maxWidth, minHeight, maxHeight) { const pad = this.mediaPadding; const hPad = pad.left + pad.right; const vPad = pad.top + pad.bottom; // Find dimensions let wantedWidth = this.imageWidth; if (wantedWidth === null) { if (this._media === null) { wantedWidth = 0; } else if (this.viewBox === null) { wantedWidth = this._media.width; } else { wantedWidth = this.viewBox[2]; } } this.idealWidth = Math.max(Math.min(wantedWidth + hPad, maxWidth), minWidth); let idealWidthNoPad = Math.max(this.idealWidth - hPad, 0); let wantedHeight = this.imageHeight; if (wantedHeight === null) { if (this._media === null) { wantedHeight = 0; } else if (this.viewBox === null) { wantedHeight = this._media.height; } else { wantedHeight = this.viewBox[3]; } } this.idealHeight = Math.max(Math.min(wantedHeight + vPad, maxHeight), minHeight); let idealHeightNoPad = Math.max(this.idealHeight - vPad, 0); // Find offset and actual image dimensions (preserving aspect ratio) switch (this.fit) { case IconFit.Contain: case IconFit.Cover: { if (this._media === null) { // XXX fallback for no media this.actualWidth = idealWidthNoPad; this.actualHeight = idealHeightNoPad; } else { const widthRatio = wantedWidth === 0 ? 0 : idealWidthNoPad / wantedWidth; const heightRatio = wantedHeight === 0 ? 0 : idealHeightNoPad / wantedHeight; let scale; if (this.fit === IconFit.Contain) { scale = Math.min(widthRatio, heightRatio); } else { scale = Math.max(widthRatio, heightRatio); } this.actualWidth = wantedWidth * scale; this.actualHeight = wantedHeight * scale; } if (this.fit === IconFit.Contain) { this.idealWidth = Math.max(Math.min(this.actualWidth + hPad, maxWidth), minWidth); this.idealHeight = Math.max(Math.min(this.actualHeight + vPad, maxHeight), minHeight); idealWidthNoPad = Math.max(this.idealWidth - hPad, 0); idealHeightNoPad = Math.max(this.idealHeight - vPad, 0); } this.offsetX = Math.round((idealWidthNoPad - this.actualWidth) / 2 + pad.left); this.offsetY = Math.round((idealHeightNoPad - this.actualHeight) / 2 + pad.top); break; } case IconFit.Fill: this.actualWidth = idealWidthNoPad; this.actualHeight = idealHeightNoPad; this.offsetX = pad.left; this.offsetY = pad.top; break; default: throw new Error(DynMsg.INVALID_ENUM(this.fit, 'IconFit', 'fit')); } } handlePainting(_dirtyRects) { let actualImage = this._media ? this._media.canvasImageSource : null; // Translate, rotate and clip if rotation is not 0 let tdx = this.x + this.offsetX, tdy = this.y + this.offsetY; const rotated = this.rotation !== 0; const needsClip = rotated || this.fit === IconFit.Cover; const ctx = this.viewport.context; if (needsClip) { ctx.save(); ctx.beginPath(); ctx.rect(this.x, this.y, this.width, this.height); ctx.clip(); if (rotated) { ctx.translate(this.x + this.offsetX + this.actualWidth / 2, this.y + this.offsetY + this.actualHeight / 2); tdx = -this.actualWidth / 2; tdy = -this.actualHeight / 2; ctx.rotate(this.rotation); } } // Draw image, with viewBox if it is not null if (actualImage) { try { if (this.viewBox === null) { ctx.drawImage(actualImage, tdx, tdy, this.actualWidth, this.actualHeight); } else { ctx.drawImage(actualImage, ...this.viewBox, tdx, tdy, this.actualWidth, this.actualHeight); } } catch (err) { // HACK even though complete is true, the image might be in a broken // state, which is not easy to detect. to prevent a crash, catch the // exception and log as a warning if (!this.warnedBroken) { this.warnedBroken = true; console.error(err); console.warn("Failed to paint image to Icon widget. Are you using an invalid URL? This warning won't be shown again"); } actualImage = null; } } // Draw fallback colour if (!actualImage) { const fill = this.mediaFallbackFill; if (fill !== 'transparent') { ctx.fillStyle = fill; ctx.fillRect(tdx, tdy, this.actualWidth, this.actualHeight); } } // Revert transformation if (needsClip) { ctx.restore(); } } } Icon.autoXML = { name: 'icon', inputConfig: [ { mode: 'value', name: 'image', validator: 'nullable:image-source' } ] }; __decorate([ damageField ], Icon.prototype, "rotation", void 0); __decorate([ damageLayoutArrayField(true) ], Icon.prototype, "viewBox", void 0); __decorate([ layoutField ], Icon.prototype, "imageWidth", void 0); __decorate([ layoutField ], Icon.prototype, "imageHeight", void 0); __decorate([ damageField ], Icon.prototype, "fit", void 0); __decorate([ layoutField ], Icon.prototype, "mediaPadding", void 0); //# sourceMappingURL=Icon.js.map