UNPKG

lazy-widgets

Version:

Typescript retained mode GUI for the HTML canvas API

439 lines 19.4 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, Msg } from '../core/Strings.js'; import { getBackingMediaSourceType, urlToBackingMediaSource } from '../helpers/BackingMediaSource.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; // Icons need a clear background, have no children and don't propagate // events super(properties); /** * The last source that the current image was using. Used for tracking if * the image source changed and if the image is fully loaded. Only used if * the media is an HTMLImageElement. */ this.lastSrc = null; /** The last presentation hash if using an AsyncImageBitmap. */ this.lastPHash = -1; /** 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; /** * Listener for video loadedmetadata and canplay events. Saved so it can be * removed when needed. */ this.loadedmetadataListener = null; /** * Listener for video canplay event. Saved so it can be removed when needed. */ this.canplayListener = null; /** * Used for requestVideoFrameCallback. If null, then callback is being done * by marking the whole widget as dirty, which may be wasteful. */ this.frameCallback = null; if (typeof image === 'string') { [image, this._mediaType] = urlToBackingMediaSource(image); } else { this._mediaType = getBackingMediaSourceType(image); } this._media = 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 }; this.setupVideoEvents(); } /** * Setup event listeners for video. Has no effect if {@link Icon#image} is * not a video */ setupVideoEvents() { if (this._mediaType === 1 /* BackingMediaSourceType.HTMLVideoElement */) { const video = this._media; // Add event listeners // loadedmetadata is so that we resize the widget when we know the // video dimensions this.loadedmetadataListener = _event => this._layoutDirty = true; video.addEventListener('loadedmetadata', this.loadedmetadataListener); // canplay is so that the first video frame is always displayed this.canplayListener = _event => this.markWholeAsDirty(); video.addEventListener('canplay', this.canplayListener); if ('requestVideoFrameCallback' in video) { console.warn(Msg.VIDEO_API_AVAILABLE); const originalVideo = video; this.frameCallback = (_now, _metadata) => { // Mark widget as dirty when there is a new frame so that it // is painted if (this._media === originalVideo && this.frameCallback !== null) { this.markWholeAsDirty(); video.requestVideoFrameCallback(this.frameCallback); } }; video.requestVideoFrameCallback(this.frameCallback); } } } /** * The image or video used by this Icon. * * Sets {@link Icon#_media} if changed and sets {@link Icon#lastSrc} to null * to mark the image as loading so that flickers are minimised. * * If getting, returns {@link Icon#_media}. */ set image(image) { if (image !== this._media) { if (this._media instanceof HTMLVideoElement) { // Remove old event listeners in video. null checks aren't // needed, but adding them anyways so that typescript doesn't // complain if (this.loadedmetadataListener !== null) { this._media.removeEventListener('loadedmetadata', this.loadedmetadataListener); } if (this.canplayListener !== null) { this._media.removeEventListener('canplay', this.canplayListener); } } this._media = image; this._mediaType = getBackingMediaSourceType(image); this.lastPHash = -1; this.lastSrc = null; this.loadedmetadataListener = null; this.canplayListener = null; this.frameCallback = null; this.setupVideoEvents(); } } get image() { return this._media; } handlePreLayoutUpdate() { // Icons only needs to be re-drawn if image changed, which is tracked by // the image setter, or if the source changed, but not if the icon isn't // loaded yet. If this is a playing video, icon only needs to be // re-drawn if video is playing if (this._mediaType === 1 /* BackingMediaSourceType.HTMLVideoElement */) { if (!this._media.paused && this.frameCallback === null) { this.markWholeAsDirty(); } } else if (this._mediaType === 0 /* BackingMediaSourceType.HTMLImageElement */) { const img = this._media; const curSrc = img.src; if (curSrc !== this.lastSrc && img.complete) { this._layoutDirty = true; this.lastSrc = curSrc; this.markWholeAsDirty(); } } else if (this._mediaType === 4 /* BackingMediaSourceType.AsyncImageBitmap */) { const aib = this._media; if (aib.presentationHash !== this.lastPHash && aib.bitmap) { this._layoutDirty = true; this.markWholeAsDirty(); } } } 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) { switch (this._mediaType) { case 0 /* BackingMediaSourceType.HTMLImageElement */: { const media = this._media; wantedWidth = media.naturalWidth; // HACK firefox has a naturalWidth of 0 for some SVGs. note // that images will likely have a bad aspect ratio if (wantedWidth === 0 && media.complete) { wantedWidth = 150; } } break; case 1 /* BackingMediaSourceType.HTMLVideoElement */: wantedWidth = this._media.videoWidth; break; case 2 /* BackingMediaSourceType.SVGImageElement */: { const baseVal = this._media.width.baseVal; if (baseVal.unitType === SVGLength.SVG_LENGTHTYPE_PX) { wantedWidth = baseVal.value; } else { baseVal.convertToSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PX); wantedWidth = baseVal.valueInSpecifiedUnits; } } break; case 3 /* BackingMediaSourceType.VideoFrame */: wantedWidth = this._media.codedWidth; break; default: 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) { switch (this._mediaType) { case 0 /* BackingMediaSourceType.HTMLImageElement */: { const media = this._media; wantedHeight = media.naturalHeight; // HACK firefox has a naturalHeight of 0 for some SVGs. note // that images will likely have a bad aspect ratio if (wantedHeight === 0 && media.complete) { wantedHeight = 150; } } break; case 1 /* BackingMediaSourceType.HTMLVideoElement */: wantedHeight = this._media.videoHeight; break; case 2 /* BackingMediaSourceType.SVGImageElement */: { const baseVal = this._media.height.baseVal; if (baseVal.unitType === SVGLength.SVG_LENGTHTYPE_PX) { wantedHeight = baseVal.value; } else { baseVal.convertToSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PX); wantedHeight = baseVal.valueInSpecifiedUnits; } } break; case 3 /* BackingMediaSourceType.VideoFrame */: wantedHeight = this._media.codedHeight; break; default: 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 || (this._mediaType === 0 /* BackingMediaSourceType.HTMLImageElement */ && !this._media.complete)) { // XXX fallback for no media or not-yet-loaded images 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 = (idealWidthNoPad - this.actualWidth) / 2 + pad.left; this.offsetY = (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; if (this._mediaType === 4 /* BackingMediaSourceType.AsyncImageBitmap */) { const aib = this._media; const bitmap = aib.bitmap; if (!bitmap) { return; } actualImage = bitmap; this.lastPHash = aib.presentationHash; } else if (this._mediaType === 0 /* BackingMediaSourceType.HTMLImageElement */ && !this._media.complete) { this.lastSrc = null; actualImage = null; } else { actualImage = this._media; } // 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: '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