170 lines
4.9 KiB
TypeScript
170 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) {
|
||
|
const mediaItem = mediaFragment.mediaItem;
|
||
|
const onload = function () {
|
||
|
applyFragment(mediaFragment);
|
||
|
// Removes the load listener from the image, so that it doesn't fire
|
||
|
// again when we set the image's @src to a transparent 1x1 GIF, but only
|
||
|
// once when the initial image has fully loaded
|
||
|
mediaItem.removeEventListener("load", onload);
|
||
|
// Base64-encoded transparent 1x1 pixel GIF
|
||
|
mediaItem.src =
|
||
|
"data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
|
||
|
};
|
||
|
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;";
|
||
|
}
|
||
|
}
|