871 lines
24 KiB
TypeScript
871 lines
24 KiB
TypeScript
import { basename, dirname, extname, join } from "path";
|
|
import { type Position } from "eruda";
|
|
import type HtmlToImage from "html-to-image";
|
|
import { type DragPosition } from "components/system/Files/FileManager/useDraggableEntries";
|
|
import { type Size } from "components/system/Window/RndWindow/useResizable";
|
|
import { type Processes, type RelativePosition } from "contexts/process/types";
|
|
import {
|
|
type IconPosition,
|
|
type IconPositions,
|
|
type SortOrders,
|
|
} from "contexts/session/types";
|
|
import {
|
|
DEFAULT_LOCALE,
|
|
HIGH_PRIORITY_REQUEST,
|
|
ICON_CACHE,
|
|
ICON_PATH,
|
|
ICON_RES_MAP,
|
|
MAX_ICON_SIZE,
|
|
MAX_RES_ICON_OVERRIDE,
|
|
ONE_TIME_PASSIVE_EVENT,
|
|
SMALLEST_JXL_FILE,
|
|
SUPPORTED_ICON_SIZES,
|
|
TASKBAR_HEIGHT,
|
|
TIMESTAMP_DATE_FORMAT,
|
|
USER_ICON_PATH,
|
|
} from "utils/constants";
|
|
import { LOCAL_HOST } from "components/apps/Browser/config";
|
|
|
|
export const GOOGLE_SEARCH_QUERY = "https://www.google.com/search?igu=1&q=";
|
|
|
|
export const bufferToBlob = (buffer: Buffer, type?: string): Blob =>
|
|
new Blob([buffer], type ? { type } : undefined);
|
|
|
|
export const bufferToUrl = (buffer: Buffer, mimeType?: string): string =>
|
|
mimeType
|
|
? `data:${mimeType};base64,${buffer.toString("base64")}`
|
|
: URL.createObjectURL(bufferToBlob(buffer));
|
|
|
|
let dpi: number;
|
|
|
|
export const getDpi = (): number => {
|
|
if (typeof dpi === "number") return dpi;
|
|
|
|
dpi = Math.min(Math.ceil(window.devicePixelRatio), 3);
|
|
|
|
return dpi;
|
|
};
|
|
|
|
export const getExtension = (url: string): string => extname(url).toLowerCase();
|
|
|
|
export const sendMouseClick = (target: HTMLElement, count = 1): void => {
|
|
if (count === 0) return;
|
|
|
|
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
sendMouseClick(target, count - 1);
|
|
};
|
|
|
|
let visibleWindows: string[] = [];
|
|
|
|
export const toggleShowDesktop = (
|
|
processes: Processes,
|
|
stackOrder: string[],
|
|
minimize: (id: string) => void
|
|
): void => {
|
|
const restoreWindows =
|
|
stackOrder.length > 0 &&
|
|
!stackOrder.some((pid) => !processes[pid]?.minimized);
|
|
const allWindows = restoreWindows ? [...stackOrder].reverse() : stackOrder;
|
|
|
|
if (!restoreWindows) visibleWindows = [];
|
|
|
|
allWindows.forEach((pid) => {
|
|
if (restoreWindows) {
|
|
if (visibleWindows.includes(pid)) minimize(pid);
|
|
} else if (!processes[pid]?.minimized) {
|
|
visibleWindows.push(pid);
|
|
minimize(pid);
|
|
}
|
|
});
|
|
|
|
if (restoreWindows) {
|
|
requestAnimationFrame(() =>
|
|
processes[stackOrder[0]]?.componentWindow?.focus()
|
|
);
|
|
}
|
|
};
|
|
|
|
export const imageSrc = (
|
|
imagePath: string,
|
|
size: number,
|
|
ratio: number,
|
|
extension: string
|
|
): string => {
|
|
const imageName = basename(imagePath, ".webp");
|
|
const [expectedSize, maxIconSize] = MAX_RES_ICON_OVERRIDE[imageName] || [];
|
|
const ratioSize = size * ratio;
|
|
const imageSize = Math.min(
|
|
MAX_ICON_SIZE,
|
|
expectedSize === size ? Math.min(maxIconSize, ratioSize) : ratioSize
|
|
);
|
|
|
|
return `${join(
|
|
dirname(imagePath),
|
|
`${ICON_RES_MAP[imageSize] || imageSize}x${
|
|
ICON_RES_MAP[imageSize] || imageSize
|
|
}`,
|
|
`${imageName}${extension}`
|
|
).replace(/\\/g, "/")}${ratio > 1 ? ` ${ratio}x` : ""}`;
|
|
};
|
|
|
|
export const imageSrcs = (
|
|
imagePath: string,
|
|
size: number,
|
|
extension: string,
|
|
failedUrls = [] as string[]
|
|
): string => {
|
|
const srcs = [
|
|
imageSrc(imagePath, size, 1, extension),
|
|
imageSrc(imagePath, size, 2, extension),
|
|
imageSrc(imagePath, size, 3, extension),
|
|
]
|
|
.filter(
|
|
(url) => failedUrls.length === 0 || failedUrls.includes(url.split(" ")[0])
|
|
)
|
|
.join(", ");
|
|
|
|
return failedUrls?.includes(srcs) ? "" : srcs;
|
|
};
|
|
|
|
export const createFallbackSrcSet = (
|
|
src: string,
|
|
failedUrls: string[]
|
|
): string => {
|
|
const failedSizes = new Set(
|
|
new Set(
|
|
failedUrls.map((failedUrl) => {
|
|
const fileName = basename(src, extname(src));
|
|
|
|
return Number(
|
|
failedUrl
|
|
.replace(`${ICON_PATH}/`, "")
|
|
.replace(`${USER_ICON_PATH}/`, "")
|
|
.replace(`/${fileName}.png`, "")
|
|
.replace(`/${fileName}.webp`, "")
|
|
.split("x")[0]
|
|
);
|
|
})
|
|
)
|
|
);
|
|
const possibleSizes = SUPPORTED_ICON_SIZES.filter(
|
|
(size) => !failedSizes.has(size)
|
|
);
|
|
|
|
return possibleSizes
|
|
.map((size) => imageSrc(src, size, 1, extname(src)))
|
|
.reverse()
|
|
.join(", ");
|
|
};
|
|
|
|
export const imageToBufferUrl = (
|
|
path: string,
|
|
buffer: Buffer | string
|
|
): string => {
|
|
const extension = getExtension(path);
|
|
|
|
return extension === ".svg"
|
|
? `data:image/svg+xml;base64,${window.btoa(buffer.toString())}`
|
|
: `data:image/${
|
|
extension === ".ani" || extension === ".gif" ? "gif" : "png"
|
|
};base64,${buffer.toString("base64")}`;
|
|
};
|
|
|
|
export const blobToBase64 = (blob: Blob): Promise<string> =>
|
|
new Promise((resolve) => {
|
|
const fileReader = new FileReader();
|
|
|
|
fileReader.readAsDataURL(blob);
|
|
fileReader.onloadend = () => resolve(fileReader.result as string);
|
|
});
|
|
|
|
export const hasJxlSupport = (): Promise<boolean> =>
|
|
new Promise((resolve) => {
|
|
const JXL = new Image();
|
|
|
|
JXL.src = SMALLEST_JXL_FILE;
|
|
|
|
JXL.addEventListener("load", () => resolve(true));
|
|
JXL.addEventListener("error", () => resolve(false));
|
|
});
|
|
|
|
type JxlDecodeResponse = { data: { imgData: ImageData } };
|
|
|
|
export const decodeJxl = async (image: Buffer): Promise<ImageData> =>
|
|
new Promise((resolve) => {
|
|
const worker = new Worker("System/JXL.js/jxl_dec.js");
|
|
|
|
worker.postMessage({ image, jxlSrc: "image.jxl" });
|
|
worker.addEventListener("message", (message: JxlDecodeResponse) =>
|
|
resolve(message?.data?.imgData)
|
|
);
|
|
});
|
|
|
|
export const imgDataToBuffer = (imageData: ImageData): Buffer => {
|
|
const canvas = document.createElement("canvas");
|
|
|
|
canvas.width = imageData.width;
|
|
canvas.height = imageData.height;
|
|
canvas.getContext("2d")?.putImageData(imageData, 0, 0);
|
|
|
|
return Buffer.from(
|
|
canvas?.toDataURL("image/png").replace("data:image/png;base64,", ""),
|
|
"base64"
|
|
);
|
|
};
|
|
|
|
export const cleanUpBufferUrl = (url: string): void => URL.revokeObjectURL(url);
|
|
|
|
const rowBlank = (imageData: ImageData, width: number, y: number): boolean => {
|
|
for (let x = 0; x < width; ++x) {
|
|
if (imageData.data[y * width * 4 + x * 4 + 3] !== 0) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const columnBlank = (
|
|
imageData: ImageData,
|
|
width: number,
|
|
x: number,
|
|
top: number,
|
|
bottom: number
|
|
): boolean => {
|
|
for (let y = top; y < bottom; ++y) {
|
|
if (imageData.data[y * width * 4 + x * 4 + 3] !== 0) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
export const trimCanvasToTopLeft = (
|
|
canvas: HTMLCanvasElement
|
|
): HTMLCanvasElement => {
|
|
const ctx = canvas.getContext("2d", {
|
|
alpha: true,
|
|
desynchronized: true,
|
|
willReadFrequently: true,
|
|
});
|
|
|
|
if (!ctx) return canvas;
|
|
|
|
const { height, ownerDocument, width } = canvas;
|
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
const { height: bottom, width: right } = imageData;
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
|
|
while (top < bottom && rowBlank(imageData, width, top)) ++top;
|
|
while (left < right && columnBlank(imageData, width, left, top, bottom)) {
|
|
++left;
|
|
}
|
|
|
|
const trimmed = ctx.getImageData(left, top, right - left, bottom - top);
|
|
const copy = ownerDocument.createElement("canvas");
|
|
const copyCtx = copy.getContext("2d");
|
|
|
|
if (!copyCtx) return canvas;
|
|
|
|
copy.width = trimmed.width;
|
|
copy.height = trimmed.height;
|
|
copyCtx.putImageData(trimmed, 0, 0);
|
|
|
|
return copy;
|
|
};
|
|
|
|
const loadScript = (
|
|
src: string,
|
|
defer?: boolean,
|
|
force?: boolean,
|
|
asModule?: boolean
|
|
): Promise<Event> =>
|
|
new Promise((resolve, reject) => {
|
|
const loadedScripts = [...document.scripts];
|
|
const currentScript = loadedScripts.find((loadedScript) =>
|
|
loadedScript.src.endsWith(src)
|
|
);
|
|
|
|
if (currentScript) {
|
|
if (!force) {
|
|
resolve(new Event("Already loaded."));
|
|
return;
|
|
}
|
|
|
|
currentScript.remove();
|
|
}
|
|
|
|
const script = document.createElement(
|
|
"script"
|
|
) as HTMLElementWithPriority<HTMLScriptElement>;
|
|
|
|
script.async = false;
|
|
if (defer) script.defer = true;
|
|
if (asModule) script.type = "module";
|
|
script.fetchPriority = "high";
|
|
script.src = src;
|
|
script.addEventListener("error", reject, ONE_TIME_PASSIVE_EVENT);
|
|
script.addEventListener("load", resolve, ONE_TIME_PASSIVE_EVENT);
|
|
|
|
document.head.append(script);
|
|
});
|
|
|
|
const loadStyle = (href: string): Promise<Event> =>
|
|
new Promise((resolve, reject) => {
|
|
const loadedStyles = [
|
|
...document.querySelectorAll("link[rel=stylesheet]"),
|
|
] as HTMLLinkElement[];
|
|
|
|
if (loadedStyles.some((loadedStyle) => loadedStyle.href.endsWith(href))) {
|
|
resolve(new Event("Already loaded."));
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement(
|
|
"link"
|
|
) as HTMLElementWithPriority<HTMLLinkElement>;
|
|
|
|
link.rel = "stylesheet";
|
|
link.fetchPriority = "high";
|
|
link.href = href;
|
|
link.addEventListener("error", reject, ONE_TIME_PASSIVE_EVENT);
|
|
link.addEventListener("load", resolve, ONE_TIME_PASSIVE_EVENT);
|
|
|
|
document.head.append(link);
|
|
});
|
|
|
|
export const loadFiles = async (
|
|
files?: string[],
|
|
defer?: boolean,
|
|
force?: boolean,
|
|
asModule?: boolean
|
|
): Promise<void> =>
|
|
!files || files.length === 0
|
|
? Promise.resolve()
|
|
: files.reduce(async (_promise, file) => {
|
|
await (getExtension(file) === ".css"
|
|
? loadStyle(encodeURI(file))
|
|
: loadScript(encodeURI(file), defer, force, asModule));
|
|
}, Promise.resolve());
|
|
|
|
export const getHtmlToImage = async (): Promise<
|
|
typeof HtmlToImage | undefined
|
|
> => {
|
|
await loadFiles(["/System/html-to-image/html-to-image.js"]);
|
|
|
|
const { htmlToImage } = window as unknown as Window & {
|
|
htmlToImage: typeof HtmlToImage;
|
|
};
|
|
|
|
return htmlToImage;
|
|
};
|
|
|
|
export const pxToNum = (value: number | string = 0): number =>
|
|
typeof value === "number" ? value : Number.parseFloat(value);
|
|
|
|
export const viewHeight = (): number => window.innerHeight;
|
|
|
|
export const viewWidth = (): number => window.innerWidth;
|
|
|
|
export const getWindowViewport = (): Position => ({
|
|
x: viewWidth(),
|
|
y: viewHeight() - TASKBAR_HEIGHT,
|
|
});
|
|
|
|
export const calcInitialPosition = (
|
|
{ offsetHeight }: HTMLElement,
|
|
{ right = 0, left = 0, top = 0, bottom = 0 } = {} as RelativePosition,
|
|
{ width = 0, height = 0 } = {} as Size
|
|
): Position => {
|
|
const [vh, vw] = [viewHeight(), viewWidth()];
|
|
|
|
return {
|
|
x: pxToNum(width) >= vw ? 0 : left || vw - right,
|
|
y:
|
|
pxToNum(height) + TASKBAR_HEIGHT >= vh
|
|
? 0
|
|
: top || vh - bottom - offsetHeight,
|
|
};
|
|
};
|
|
|
|
const GRID_TEMPLATE_ROWS = "grid-template-rows";
|
|
|
|
const calcGridDropPosition = (
|
|
gridElement: HTMLElement | null,
|
|
{ x = 0, y = 0 }: DragPosition
|
|
): IconPosition => {
|
|
if (!gridElement) return Object.create(null) as IconPosition;
|
|
|
|
const gridComputedStyle = window.getComputedStyle(gridElement);
|
|
const gridTemplateRows = gridComputedStyle
|
|
.getPropertyValue(GRID_TEMPLATE_ROWS)
|
|
.split(" ");
|
|
const gridTemplateColumns = gridComputedStyle
|
|
.getPropertyValue("grid-template-columns")
|
|
.split(" ");
|
|
const gridRowHeight = pxToNum(gridTemplateRows[0]);
|
|
const gridColumnWidth = pxToNum(gridTemplateColumns[0]);
|
|
const gridColumnGap = pxToNum(
|
|
gridComputedStyle.getPropertyValue("grid-column-gap")
|
|
);
|
|
const gridRowGap = pxToNum(
|
|
gridComputedStyle.getPropertyValue("grid-row-gap")
|
|
);
|
|
const paddingTop = pxToNum(gridComputedStyle.getPropertyValue("padding-top"));
|
|
|
|
return {
|
|
gridColumnStart: Math.min(
|
|
Math.ceil(x / (gridColumnWidth + gridColumnGap)),
|
|
gridTemplateColumns.length
|
|
),
|
|
gridRowStart: Math.min(
|
|
Math.ceil((y - paddingTop) / (gridRowHeight + gridRowGap)),
|
|
gridTemplateRows.length
|
|
),
|
|
};
|
|
};
|
|
|
|
export const updateIconPositionsIfEmpty = (
|
|
url: string,
|
|
gridElement: HTMLElement | null,
|
|
iconPositions: IconPositions,
|
|
sortOrders: SortOrders
|
|
): IconPositions => {
|
|
if (!gridElement) return iconPositions;
|
|
|
|
const [fileOrder = []] = sortOrders[url] || [];
|
|
const newIconPositions: IconPositions = {};
|
|
const gridComputedStyle = window.getComputedStyle(gridElement);
|
|
const gridTemplateRowCount = gridComputedStyle
|
|
.getPropertyValue(GRID_TEMPLATE_ROWS)
|
|
.split(" ").length;
|
|
|
|
fileOrder.forEach((entry, index) => {
|
|
const entryUrl = join(url, entry);
|
|
|
|
if (!iconPositions[entryUrl]) {
|
|
const gridEntry = [...gridElement.children].find((element) =>
|
|
element.querySelector(`button[aria-label="${entry}"]`)
|
|
);
|
|
|
|
if (gridEntry instanceof HTMLElement) {
|
|
const { x, y, height, width } = gridEntry.getBoundingClientRect();
|
|
|
|
newIconPositions[entryUrl] = calcGridDropPosition(gridElement, {
|
|
x: x - width,
|
|
y: y + height,
|
|
});
|
|
} else {
|
|
const position = index + 1;
|
|
const gridColumnStart = Math.ceil(position / gridTemplateRowCount);
|
|
const gridRowStart =
|
|
position - gridTemplateRowCount * (gridColumnStart - 1);
|
|
|
|
newIconPositions[entryUrl] = { gridColumnStart, gridRowStart };
|
|
}
|
|
}
|
|
});
|
|
|
|
return Object.keys(newIconPositions).length > 0
|
|
? { ...newIconPositions, ...iconPositions }
|
|
: iconPositions;
|
|
};
|
|
|
|
const calcGridPositionOffset = (
|
|
url: string,
|
|
targetUrl: string,
|
|
currentIconPositions: IconPositions,
|
|
gridDropPosition: IconPosition,
|
|
[, ...draggedEntries]: string[],
|
|
gridElement: HTMLElement
|
|
): IconPosition => {
|
|
if (currentIconPositions[url] && currentIconPositions[targetUrl]) {
|
|
return {
|
|
gridColumnStart:
|
|
currentIconPositions[url].gridColumnStart +
|
|
(gridDropPosition.gridColumnStart -
|
|
currentIconPositions[targetUrl].gridColumnStart),
|
|
gridRowStart:
|
|
currentIconPositions[url].gridRowStart +
|
|
(gridDropPosition.gridRowStart -
|
|
currentIconPositions[targetUrl].gridRowStart),
|
|
};
|
|
}
|
|
|
|
const gridComputedStyle = window.getComputedStyle(gridElement);
|
|
const gridTemplateRowCount = gridComputedStyle
|
|
.getPropertyValue(GRID_TEMPLATE_ROWS)
|
|
.split(" ").length;
|
|
const {
|
|
gridColumnStart: targetGridColumnStart,
|
|
gridRowStart: targetGridRowStart,
|
|
} = gridDropPosition;
|
|
const gridRowStart =
|
|
targetGridRowStart + draggedEntries.indexOf(basename(url)) + 1;
|
|
|
|
return gridRowStart > gridTemplateRowCount
|
|
? {
|
|
gridColumnStart:
|
|
targetGridColumnStart +
|
|
Math.ceil(gridRowStart / gridTemplateRowCount) -
|
|
1,
|
|
gridRowStart:
|
|
gridRowStart % gridTemplateRowCount || gridTemplateRowCount,
|
|
}
|
|
: {
|
|
gridColumnStart: targetGridColumnStart,
|
|
gridRowStart,
|
|
};
|
|
};
|
|
|
|
export const updateIconPositions = (
|
|
directory: string,
|
|
gridElement: HTMLElement | null,
|
|
iconPositions: IconPositions,
|
|
sortOrders: SortOrders,
|
|
dragPosition: DragPosition,
|
|
draggedEntries: string[],
|
|
setIconPositions: React.Dispatch<React.SetStateAction<IconPositions>>
|
|
): void => {
|
|
if (!gridElement) return;
|
|
|
|
const currentIconPositions = updateIconPositionsIfEmpty(
|
|
directory,
|
|
gridElement,
|
|
iconPositions,
|
|
sortOrders
|
|
);
|
|
const gridDropPosition = calcGridDropPosition(gridElement, dragPosition);
|
|
|
|
if (
|
|
draggedEntries.length > 0 &&
|
|
!Object.values(currentIconPositions).some(
|
|
({ gridColumnStart, gridRowStart }) =>
|
|
gridColumnStart === gridDropPosition.gridColumnStart &&
|
|
gridRowStart === gridDropPosition.gridRowStart
|
|
)
|
|
) {
|
|
const targetFile =
|
|
draggedEntries.find((entry) =>
|
|
entry.startsWith(document.activeElement?.textContent || "")
|
|
) || draggedEntries[0];
|
|
const targetUrl = join(directory, targetFile);
|
|
const adjustDraggedEntries = [
|
|
targetFile,
|
|
...draggedEntries.filter((entry) => entry !== targetFile),
|
|
];
|
|
const newIconPositions = Object.fromEntries(
|
|
adjustDraggedEntries
|
|
.map<[string, IconPosition]>((entryFile) => {
|
|
const url = join(directory, entryFile);
|
|
|
|
return [
|
|
url,
|
|
url === targetUrl
|
|
? gridDropPosition
|
|
: calcGridPositionOffset(
|
|
url,
|
|
targetUrl,
|
|
currentIconPositions,
|
|
gridDropPosition,
|
|
adjustDraggedEntries,
|
|
gridElement
|
|
),
|
|
];
|
|
})
|
|
.filter(
|
|
([, { gridColumnStart, gridRowStart }]) =>
|
|
gridColumnStart >= 1 && gridRowStart >= 1
|
|
)
|
|
);
|
|
|
|
setIconPositions({
|
|
...currentIconPositions,
|
|
...Object.fromEntries(
|
|
Object.entries(newIconPositions).filter(
|
|
([, { gridColumnStart, gridRowStart }]) =>
|
|
!Object.values(currentIconPositions).some(
|
|
({
|
|
gridColumnStart: currentGridColumnStart,
|
|
gridRowStart: currentRowColumnStart,
|
|
}) =>
|
|
gridColumnStart === currentGridColumnStart &&
|
|
gridRowStart === currentRowColumnStart
|
|
)
|
|
)
|
|
),
|
|
});
|
|
}
|
|
};
|
|
|
|
export const isCanvasDrawn = (canvas?: HTMLCanvasElement | null): boolean => {
|
|
if (!(canvas instanceof HTMLCanvasElement)) return false;
|
|
if (canvas.width === 0 || canvas.height === 0) return false;
|
|
|
|
const { data: pixels = [] } =
|
|
canvas
|
|
.getContext("2d", { willReadFrequently: true })
|
|
?.getImageData(0, 0, canvas.width, canvas.height) || {};
|
|
|
|
if (pixels.length === 0) return false;
|
|
|
|
const bwPixels: Record<number, number> = { 0: 0, 255: 0 };
|
|
|
|
for (const pixel of pixels) {
|
|
if (pixel !== 0 && pixel !== 255) return true;
|
|
|
|
bwPixels[pixel] += 1;
|
|
}
|
|
|
|
const isBlankCanvas =
|
|
bwPixels[0] === pixels.length ||
|
|
bwPixels[255] === pixels.length ||
|
|
(bwPixels[255] + bwPixels[0] === pixels.length &&
|
|
bwPixels[0] / 3 === bwPixels[255]);
|
|
|
|
return !isBlankCanvas;
|
|
};
|
|
|
|
const bytesInKB = 1024;
|
|
const bytesInMB = 1022976; // 1024 * 999
|
|
const bytesInGB = 1047527424; // 1024 * 1024 * 999
|
|
const bytesInTB = 1072668082176; // 1024 * 1024 * 1024 * 999
|
|
|
|
const formatNumber = (number: number): string => {
|
|
const formattedNumber = new Intl.NumberFormat("en-US", {
|
|
maximumSignificantDigits: number < 1 ? 2 : 4,
|
|
minimumSignificantDigits: number < 1 ? 2 : 3,
|
|
}).format(Number(number.toFixed(4).slice(0, -2)));
|
|
|
|
const [integer, decimal] = formattedNumber.split(".");
|
|
|
|
if (integer.length === 3) return integer;
|
|
if (integer.length === 2 && decimal.length === 2) {
|
|
return `${integer}.${decimal[0]}`;
|
|
}
|
|
|
|
return formattedNumber;
|
|
};
|
|
|
|
export const getFormattedSize = (size = 0): string => {
|
|
if (size === 1) return "1 byte";
|
|
if (size < bytesInKB) return `${size} bytes`;
|
|
if (size < bytesInMB) return `${formatNumber(size / bytesInKB)} KB`;
|
|
if (size < bytesInGB) {
|
|
return `${formatNumber(size / bytesInKB / bytesInKB)} MB`;
|
|
}
|
|
if (size < bytesInTB) {
|
|
return `${formatNumber(size / bytesInKB / bytesInKB / bytesInKB)} GB`;
|
|
}
|
|
|
|
return `${size} bytes`;
|
|
};
|
|
|
|
export const getTZOffsetISOString = (): string => {
|
|
const date = new Date();
|
|
|
|
return new Date(
|
|
date.getTime() - date.getTimezoneOffset() * 60000
|
|
).toISOString();
|
|
};
|
|
|
|
export const getUrlOrSearch = async (input: string): Promise<URL> => {
|
|
const isIpfs = input.startsWith("ipfs://");
|
|
const hasHttpSchema =
|
|
input.startsWith("http://") || input.startsWith("https://");
|
|
const hasTld =
|
|
input.endsWith(".com") ||
|
|
input.endsWith(".ca") ||
|
|
input.endsWith(".net") ||
|
|
input.endsWith(".org");
|
|
const isLocalHost = LOCAL_HOST.has(input);
|
|
|
|
try {
|
|
const url = new URL(
|
|
!isLocalHost && (hasHttpSchema || !hasTld || isIpfs)
|
|
? input
|
|
: `https://${input}`
|
|
);
|
|
|
|
if (isIpfs) {
|
|
const { getIpfsGatewayUrl } = await import("utils/ipfs");
|
|
|
|
return new URL(await getIpfsGatewayUrl(url.href));
|
|
}
|
|
|
|
return url;
|
|
} catch {
|
|
return new URL(`${GOOGLE_SEARCH_QUERY}${input}`);
|
|
}
|
|
};
|
|
|
|
let IS_FIREFOX: boolean;
|
|
|
|
export const isFirefox = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
if (IS_FIREFOX ?? false) return IS_FIREFOX;
|
|
|
|
IS_FIREFOX = /firefox/i.test(window.navigator.userAgent);
|
|
|
|
return IS_FIREFOX;
|
|
};
|
|
|
|
let IS_SAFARI: boolean;
|
|
|
|
export const isSafari = (): boolean => {
|
|
if (typeof window === "undefined") return false;
|
|
if (IS_SAFARI ?? false) return IS_SAFARI;
|
|
|
|
IS_SAFARI = /^(?:(?!chrome|android).)*safari/i.test(
|
|
window.navigator.userAgent
|
|
);
|
|
|
|
return IS_SAFARI;
|
|
};
|
|
|
|
export const haltEvent = (
|
|
event:
|
|
| Event
|
|
| React.DragEvent
|
|
| React.FocusEvent
|
|
| React.KeyboardEvent
|
|
| React.MouseEvent
|
|
): void => {
|
|
try {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
} catch {
|
|
// Ignore failured to halt event
|
|
}
|
|
};
|
|
|
|
export const createOffscreenCanvas = (
|
|
containerElement: HTMLElement,
|
|
devicePixelRatio = 1,
|
|
customSize: Size = Object.create(null) as Size
|
|
): OffscreenCanvas => {
|
|
const canvas = document.createElement("canvas");
|
|
const height = Number(customSize?.height) || containerElement.offsetHeight;
|
|
const width = Number(customSize?.width) || containerElement.offsetWidth;
|
|
|
|
canvas.style.height = `${height}px`;
|
|
canvas.style.width = `${width}px`;
|
|
|
|
canvas.height = Math.floor(height * devicePixelRatio);
|
|
canvas.width = Math.floor(width * devicePixelRatio);
|
|
|
|
containerElement.append(canvas);
|
|
|
|
return canvas.transferControlToOffscreen();
|
|
};
|
|
|
|
export const getSearchParam = (param: string): string =>
|
|
new URLSearchParams(window.location.search).get(param) || "";
|
|
|
|
export const clsx = (classes: Record<string, boolean>): string =>
|
|
Object.entries(classes)
|
|
.filter(([, isActive]) => isActive)
|
|
.map(([className]) => className)
|
|
.join(" ");
|
|
|
|
export const label = (value: string): React.HTMLAttributes<HTMLElement> => ({
|
|
"aria-label": value,
|
|
title: value,
|
|
});
|
|
|
|
export const isYouTubeUrl = (url: string): boolean =>
|
|
(url.includes("youtube.com/") || url.includes("youtu.be/")) &&
|
|
!url.includes("youtube.com/@") &&
|
|
!url.includes("/channel/") &&
|
|
!url.includes("/c/");
|
|
|
|
export const getYouTubeUrlId = (url: string): string => {
|
|
try {
|
|
const { pathname, searchParams } = new URL(url);
|
|
|
|
return searchParams.get("v") || pathname.split("/").pop() || "";
|
|
} catch {
|
|
// URL parsing failed
|
|
}
|
|
|
|
return "";
|
|
};
|
|
|
|
export const preloadLibs = (libs: string[] = []): void => {
|
|
const scripts = [...document.scripts];
|
|
const preloadedLinks = [
|
|
...document.querySelectorAll("link[rel=preload]"),
|
|
] as HTMLLinkElement[];
|
|
|
|
// eslint-disable-next-line unicorn/no-array-callback-reference
|
|
libs.map(encodeURI).forEach((lib) => {
|
|
if (
|
|
scripts.some((script) => script.src.endsWith(lib)) ||
|
|
preloadedLinks.some((preloadedLink) => preloadedLink.href.endsWith(lib))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement(
|
|
"link"
|
|
) as HTMLElementWithPriority<HTMLLinkElement>;
|
|
|
|
link.fetchPriority = "high";
|
|
link.rel = "preload";
|
|
link.href = lib;
|
|
|
|
switch (getExtension(lib)) {
|
|
case ".css":
|
|
link.as = "style";
|
|
break;
|
|
case ".htm":
|
|
case ".html":
|
|
link.rel = "prerender";
|
|
break;
|
|
case ".json":
|
|
case ".wasm":
|
|
link.as = "fetch";
|
|
link.crossOrigin = "anonymous";
|
|
break;
|
|
default:
|
|
link.as = "script";
|
|
break;
|
|
}
|
|
|
|
document.head.append(link);
|
|
});
|
|
};
|
|
|
|
export const getGifJs = async (): Promise<GIF> => {
|
|
const { default: GIFInstance } = await import("gif.js");
|
|
|
|
return new GIFInstance({
|
|
quality: 10,
|
|
workerScript: "Program Files/gif.js/gif.worker.js",
|
|
workers: Math.max(Math.floor(navigator.hardwareConcurrency / 4), 1),
|
|
});
|
|
};
|
|
|
|
export const jsonFetch = async (
|
|
url: string
|
|
): Promise<Record<string, unknown>> => {
|
|
const response = await fetch(url, HIGH_PRIORITY_REQUEST);
|
|
const json = (await response.json()) as Record<string, unknown>;
|
|
|
|
return json || {};
|
|
};
|
|
|
|
export const generatePrettyTimestamp = (): string =>
|
|
new Intl.DateTimeFormat(DEFAULT_LOCALE, TIMESTAMP_DATE_FORMAT)
|
|
.format(new Date())
|
|
.replace(/[/:]/g, "-")
|
|
.replace(",", "");
|
|
|
|
export const isFileSystemMappingSupported = (): boolean =>
|
|
typeof FileSystemHandle === "function" && "showDirectoryPicker" in window;
|
|
|
|
export const isDynamicIcon = (icon?: string): boolean =>
|
|
typeof icon === "string" &&
|
|
(icon.startsWith(ICON_PATH) ||
|
|
(icon.startsWith(USER_ICON_PATH) && !icon.startsWith(ICON_CACHE)));
|