upend/webui/src/util/xywh.ts

173 lines
4.9 KiB
TypeScript

// Code adapted from `xywh.js` by Thomas Steiner
// https://github.com/tomayac/xywh.js
export type MediaFragment = (
| {
mediaItem: HTMLImageElement;
mediaType: "img";
}
| {
mediaItem: HTMLVideoElement;
mediaType: "video";
}
) & {
unit: string;
x: number;
y: number;
w: number;
h: number;
};
export function xywh(mediaItem: HTMLImageElement | HTMLVideoElement) {
const source = mediaItem.src || mediaItem.currentSrc;
// See http://www.w3.org/TR/media-frags/#naming-space
const xywhRegEx =
/[#&?]xywh=(pixel:|percent:)?([\d.]+),([\d.]+),([\d.]+),([\d.]+)/;
const match = xywhRegEx.exec(source);
if (match) {
const mediaFragment = {
mediaItem: mediaItem,
mediaType: mediaItem.nodeName.toLowerCase(),
unit: match[1] ? match[1] : "pixel:",
x: parseFloat(match[2]),
y: parseFloat(match[3]),
w: parseFloat(match[4]),
h: parseFloat(match[5]),
} as MediaFragment;
if (mediaFragment.mediaType === "img") {
addImageLoadListener(mediaFragment);
} else {
addVideoLoadListener(mediaFragment);
}
}
}
/**
* Applies the media fragment when the image has loaded. We need the image's
* original width and height.
*/
function addImageLoadListener(mediaFragment: MediaFragment) {
// Base64-encoded transparent 1x1 pixel GIF
const TRANSPARENT_GIF =
"data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
const mediaItem = mediaFragment.mediaItem;
const onload = function () {
// Prevent onload firing when the 1x1 pixel GIF loads; but still react when `src`
// is changed programatically.
if (mediaItem.src !== TRANSPARENT_GIF) {
// Required on reloads because of size calculations.
mediaItem.style.cssText = "";
applyFragment(mediaFragment);
mediaItem.src = TRANSPARENT_GIF;
}
};
mediaItem.addEventListener("load", onload);
}
/**
* Applies the media fragment when all metadata of the video have loaded. We
* need the video's original width and height.
*/
function addVideoLoadListener(mediaFragment: MediaFragment) {
mediaFragment.mediaItem.addEventListener("loadedmetadata", function () {
applyFragment(mediaFragment);
});
}
/**
* Applies the spatial media fragment:
*
* For images, we replace the @src attribute with a transparent GIF data URL,
* resize the image to match the fragment's dimensions, and set the image's
* CSS background-image according to the fragment's x and y values.
*
* For videos, we wrap the video in a <div> wrapper of the fragment's
* dimensions, set its CSS overflow value to "hidden", and apply a CSS3
* 2D transformation according to the fragment's x and y values.
*/
function applyFragment(fragment: MediaFragment) {
let x: string, y: string, w: string, h: string;
const originalWidth =
fragment.mediaType === "img"
? fragment.mediaItem.width
: fragment.mediaItem.videoWidth;
const originalHeight =
fragment.mediaType === "img"
? fragment.mediaItem.height
: fragment.mediaItem.videoHeight;
// Unit is pixel:
if (fragment.unit === "pixel:") {
const scale =
fragment.mediaType === "img"
? originalWidth / fragment.mediaItem.naturalWidth
: originalWidth / fragment.mediaItem.clientWidth;
w = fragment.w * scale + "px";
h = fragment.h * scale + "px";
x = "-" + fragment.x * scale + "px";
y = "-" + fragment.y * scale + "px";
// Unit is percent:
} else {
w = (originalWidth * fragment.w) / 100 + "px";
h = (originalHeight * fragment.h) / 100 + "px";
x = "-" + (originalWidth * fragment.x) / 100 + "px";
y = "-" + (originalHeight * fragment.y) / 100 + "px";
}
// Media item is a video
if (fragment.mediaType === "video") {
const wrapper = document.createElement("div");
wrapper.style.cssText +=
"overflow:hidden;" +
"width:" +
w +
";" +
"height:" +
h +
";" +
"padding:0;" +
"margin:0;" +
"border-radius:0;" +
"border:none;";
fragment.mediaItem.style.cssText +=
"transform:translate(" +
x +
"," +
y +
");" +
"-webkit-transform:translate(" +
x +
"," +
y +
");";
// Evil DOM operations
fragment.mediaItem.parentNode.insertBefore(wrapper, fragment.mediaItem);
wrapper.appendChild(fragment.mediaItem);
// We need to manually trigger @autoplay, as DOM access seems to kill it
if (fragment.mediaItem.hasAttribute("autoplay")) {
fragment.mediaItem.play();
}
// Media item is an image
} else {
fragment.mediaItem.style.cssText +=
"width:" +
w +
";" +
"height:" +
h +
";" +
"background:url(" +
fragment.mediaItem.src +
") " + // background-image
"no-repeat " + // background-repeat
x +
" " +
y +
"; " + // background-position
"background-size: " +
originalWidth +
"px " +
originalHeight +
"px;";
}
}