mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-22 17:47:16 +00:00
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
import JSZip from "jszip";
|
|
import { xml2js } from "xml-js";
|
|
import { XmlNode } from "./types";
|
|
import MakiObject from "./runtime/MakiObject";
|
|
|
|
let nextId = 0;
|
|
export function getId(): number {
|
|
return nextId++;
|
|
}
|
|
|
|
// Breadth-first tree map
|
|
// TODO: Type this so the return type is different than the initial type
|
|
// NOTE: This does not apply the callback to the root node. It probably should,
|
|
// but I'm too lazy to figure out how to write it.
|
|
export function mapTreeBreadth<T extends { children: T[] }>(
|
|
node: T,
|
|
cb: (node: T, parent: T) => T
|
|
): T {
|
|
const children = node.children || [];
|
|
const mappedChildren = children.map(child => {
|
|
const newChild = cb(child, node);
|
|
return mapTreeBreadth(newChild, cb);
|
|
});
|
|
return { ...node, children: mappedChildren };
|
|
}
|
|
|
|
export function isPromise(obj: any): boolean {
|
|
return obj && typeof obj.then === "function";
|
|
}
|
|
|
|
export function isString(obj: any): boolean {
|
|
return typeof obj === "string";
|
|
}
|
|
|
|
export function isObject(obj: any): boolean {
|
|
return obj === Object(obj);
|
|
}
|
|
|
|
// Convert windows filename slashes to forward slashes
|
|
function fixFilenameSlashes(filename: string): string {
|
|
return filename.replace(/\\/g, "/");
|
|
}
|
|
|
|
export function getCaseInsensitveFile(
|
|
zip: JSZip,
|
|
filename: string
|
|
): JSZip.JSZipObject {
|
|
// TODO: Escape `file` for rejex characters
|
|
return zip.file(new RegExp(fixFilenameSlashes(filename), "i"))[0];
|
|
}
|
|
|
|
// Read a
|
|
export async function readXml(
|
|
zip: JSZip,
|
|
filepath: string
|
|
): Promise<XmlNode | null> {
|
|
const file = await getCaseInsensitveFile(zip, filepath);
|
|
if (file == null) {
|
|
return null;
|
|
}
|
|
const text = await file.async("text");
|
|
// @ts-ignore Due to the way it's config object interface works, xml2js is
|
|
// bascially impossible to type. For example, you can specify what key to use
|
|
// for `elements`. We choose `children` but the types assume the default
|
|
// `elements`.
|
|
return xml2js(text, { compact: false, elementsKey: "children" });
|
|
}
|
|
|
|
export async function readUint8array(
|
|
zip: JSZip,
|
|
filepath: string
|
|
): Promise<Uint8Array | null> {
|
|
const file = await getCaseInsensitveFile(zip, filepath);
|
|
if (file == null) {
|
|
return null;
|
|
}
|
|
return file.async("uint8array");
|
|
}
|
|
|
|
// I any of the values in `arr` are themselves arrays, interpolate the nested
|
|
// array into the top level array.
|
|
function flatten<T>(arr: Array<T | T[]>): T[] {
|
|
const newArr: T[] = [];
|
|
arr.forEach(item => {
|
|
if (Array.isArray(item)) {
|
|
newArr.push(...item);
|
|
} else {
|
|
newArr.push(item);
|
|
}
|
|
});
|
|
return newArr;
|
|
}
|
|
|
|
// Map an async function over an array. If the value returned from the mapper is
|
|
// an array, it recursively maps the function over that array's values, and then
|
|
// interpoates the resulting flat array into the top level array of results.
|
|
export async function asyncFlatMap<T, R>(
|
|
arr: Array<T>,
|
|
mapper: (value: T) => Promise<T[] | R>
|
|
): Promise<R[]> {
|
|
const mapped = await Promise.all(arr.map(mapper));
|
|
const childPromises = mapped.map(async item => {
|
|
if (Array.isArray(item)) {
|
|
return asyncFlatMap(item, mapper);
|
|
}
|
|
return item;
|
|
});
|
|
return flatten(await Promise.all(childPromises));
|
|
}
|
|
|
|
// Apply a mapper function to all nodes in a tree using `asyncFlatMap`. This
|
|
// allows mapper to conditionally return either a single node, or an array of
|
|
// nodes.
|
|
//
|
|
// The tree should have the form of objects nodes, each of which may
|
|
// have a property `children` which is an array of nodes.
|
|
//
|
|
// Note: The root node will not be transformed by the mapper, since the mapper
|
|
// could potentially return multiple nodes.
|
|
export async function asyncTreeFlatMap<T extends { children: T[] }>(
|
|
node: T,
|
|
mapper: (node: T) => Promise<T[] | T>
|
|
): Promise<T> {
|
|
const { children } = node;
|
|
if (children == null) {
|
|
return node;
|
|
}
|
|
|
|
const mappedChildren = await asyncFlatMap(children, mapper);
|
|
|
|
const recursedChildren = await Promise.all(
|
|
mappedChildren.map(child =>
|
|
asyncTreeFlatMap(
|
|
// @ts-ignore FixMe
|
|
child,
|
|
mapper
|
|
)
|
|
)
|
|
);
|
|
|
|
return { ...node, children: recursedChildren };
|
|
}
|
|
|
|
// Given an XML file and a zip which it came from, replace all `<inline />` elements
|
|
// with the contents of the file to be included.
|
|
export async function inlineIncludes(
|
|
xml: XmlNode,
|
|
zip: JSZip
|
|
): Promise<XmlNode> {
|
|
return asyncTreeFlatMap(xml, async node => {
|
|
if (node.name !== "include") {
|
|
return node;
|
|
}
|
|
if (node.attributes.file == null) {
|
|
return node;
|
|
}
|
|
// TODO: Normalize file names so that they hit the same cache
|
|
const includedFile = await readXml(zip, node.attributes.file);
|
|
if (includedFile == null) {
|
|
console.warn(
|
|
`Tried to include a file that could not be found: ${
|
|
node.attributes.file
|
|
}`
|
|
);
|
|
return [];
|
|
}
|
|
return includedFile.children;
|
|
});
|
|
}
|
|
|
|
export function unimplementedWarning(name: string): void {
|
|
console.warn(`Executing unimplemented MAKI function: ${name}`);
|
|
}
|
|
|
|
// Bredth-first search in a tree that returns the path to the node
|
|
export function findPathToNode<T extends { children: T[] }>(
|
|
node: T,
|
|
predicate: (candidate: T) => boolean
|
|
): number[] | null {
|
|
if (predicate(node)) {
|
|
return [];
|
|
}
|
|
const children = node.children || [];
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i];
|
|
|
|
const found = findPathToNode(child, predicate);
|
|
if (found != null) {
|
|
return [i, ...found];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Bredth-first search in a tree
|
|
export function findInTree<T extends { children: T[] }>(
|
|
node: T,
|
|
predicate: (node: T) => boolean
|
|
): T | null {
|
|
if (predicate(node)) {
|
|
return node;
|
|
}
|
|
const children = node.children || [];
|
|
for (const child of children) {
|
|
const found = findInTree(child, predicate);
|
|
if (found != null) {
|
|
return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function findParent<T extends { parent: T | null }>(
|
|
node: T,
|
|
predicate: (node: T) => boolean
|
|
): T | null {
|
|
let n = node;
|
|
while (n.parent) {
|
|
n = n.parent;
|
|
if (predicate(n)) {
|
|
return n;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Operations on trees
|
|
export function findParentNodeOfType<
|
|
T extends { parent: T | null; name: string }
|
|
>(node: T, type: Set<string>): T | null {
|
|
return findParent(node, n => type.has(n.name));
|
|
}
|
|
|
|
export function findParentOrCurrentNodeOfType<
|
|
T extends { parent: T | null; name: string }
|
|
>(node: T, type: Set<string>): T | null {
|
|
if (type.has(node.name)) {
|
|
return node;
|
|
}
|
|
return findParentNodeOfType(node, type);
|
|
}
|
|
|
|
export function findDescendantByTypeAndId<
|
|
T extends { children: T[]; name: string; attributes?: { id?: string } }
|
|
>(node: T, type: string | null, id: string): T | null {
|
|
if (node.children.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const lowerCaseId = id.toLowerCase();
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
const child = node.children[i];
|
|
if (
|
|
(!type || child.name === type) &&
|
|
child.attributes &&
|
|
(child.attributes.id !== undefined &&
|
|
child.attributes.id.toLowerCase() === lowerCaseId)
|
|
) {
|
|
return child;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
const child = node.children[i];
|
|
const descendant = findDescendantByTypeAndId(child, type, id);
|
|
if (descendant) {
|
|
return descendant;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findDirectDescendantById<
|
|
T extends { children: T[]; attributes?: { id?: string } }
|
|
>(node: T, id: string): T | undefined {
|
|
const lowerCaseId = id.toLowerCase();
|
|
return node.children.find(item =>
|
|
Boolean(
|
|
item.attributes &&
|
|
item.attributes.id &&
|
|
item.attributes.id.toLowerCase() === lowerCaseId
|
|
)
|
|
);
|
|
}
|
|
|
|
function* iterateLexicalScope<
|
|
T extends { children: T[]; parent: T | undefined }
|
|
>(node: T): IterableIterator<T> {
|
|
let currentNode = node;
|
|
while (currentNode.parent) {
|
|
const { parent } = currentNode;
|
|
const { children } = parent;
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i];
|
|
if (child === currentNode) {
|
|
break;
|
|
}
|
|
yield child;
|
|
}
|
|
currentNode = parent;
|
|
}
|
|
}
|
|
|
|
// Search up the tree for a node that is in `node`'s lexical that matches `predicate`.
|
|
function findInLexicalScope<T extends { children: T[]; parent: T | undefined }>(
|
|
node: T,
|
|
predicate: (node: T) => boolean
|
|
): T | null {
|
|
for (const child of iterateLexicalScope(node)) {
|
|
if (predicate(child)) {
|
|
return child;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Search up the tree for <Elements> nodes that are in node's lexical scope.
|
|
// return the first child of an <Elements> that matches id
|
|
export function findElementById(
|
|
node: MakiObject,
|
|
id: string
|
|
): MakiObject | null {
|
|
for (const child of iterateLexicalScope(node)) {
|
|
if (child.getclassname && child.getclassname() === "Elements") {
|
|
const element = findDirectDescendantById(child, id);
|
|
if (element) {
|
|
return element;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Search up the tree for a <GroupDef> node that is in node's lexical scope and matches id.
|
|
export function findGroupDefById(
|
|
node: MakiObject,
|
|
id: String
|
|
): MakiObject | null {
|
|
return findInLexicalScope(node, child => {
|
|
return (
|
|
child.getclassname &&
|
|
child.getclassname() === "GroupDef" &&
|
|
// @ts-ignore We don't have good typing for attributes
|
|
child.attributes.id === id
|
|
);
|
|
});
|
|
}
|
|
|
|
// Search down the tree for <Elements> nodes that are in node's lexical scope.
|
|
// return the first child of an <Elements> that matches id unless we find
|
|
// node first, in that case we didn't find the element
|
|
// TODO: this might be overly generous, including some definitions that
|
|
// shouldn't be accessible. But it's working for now :-X
|
|
export function findXmlElementById(
|
|
node: XmlNode,
|
|
id: string,
|
|
root: XmlNode
|
|
): XmlNode | null {
|
|
if (root.uid === node.uid) {
|
|
// Search ends if we find the node that initiated the search, since it means we weren't able to
|
|
// find the match in its scope
|
|
// Return the node itself as a kind of sentinel value to look for, since finding the node is an
|
|
// ending condition for the search
|
|
return node;
|
|
} else if (root.name === "elements") {
|
|
const element = findDirectDescendantById(root, id);
|
|
if (element) {
|
|
return element;
|
|
}
|
|
} else {
|
|
const children = root.children || [];
|
|
for (const child of children) {
|
|
const element = findXmlElementById(node, id, child);
|
|
if (element) {
|
|
if (element.uid === node.uid) {
|
|
// This happens when we find the node before we find the declaration, which means it
|
|
// either doesn't exist or it wouldn't be in scope
|
|
return null;
|
|
}
|
|
return element;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// This is intentionally async since we may want to sub it out for an async
|
|
// function in a node environment
|
|
export async function getUrlFromBlob(blob: Blob): Promise<string> {
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
async function loadImage(
|
|
imgUrl: string
|
|
): Promise<{ width: number; height: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.addEventListener("load", () => {
|
|
resolve(img);
|
|
});
|
|
img.addEventListener("error", e => {
|
|
reject(e);
|
|
});
|
|
img.src = imgUrl;
|
|
});
|
|
}
|
|
|
|
/* global FontFace */
|
|
// Types for FontFace provided by @types/css-font-loading-module
|
|
// TODO: Offer some way to clean this up
|
|
export async function loadFont(fontUrl: string, name: string) {
|
|
// Note: Incompatible with non-chromium Edge (#901)
|
|
const font = new FontFace(name, `url(${fontUrl})`);
|
|
const loadedFont = await font.load();
|
|
document.fonts.add(loadedFont);
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
|
function remove() {
|
|
document.fonts.delete(loadedFont);
|
|
}
|
|
}
|
|
|
|
export async function getSizeFromUrl(
|
|
imgUrl: string
|
|
): Promise<{ width: number; height: number }> {
|
|
const { width, height } = await loadImage(imgUrl);
|
|
return { width, height };
|
|
}
|
|
|
|
let mousePosition = { x: 0, y: 0 };
|
|
function handleMouseMove(e: MouseEvent): void {
|
|
mousePosition = { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
// It's possible we are in a Node envionment
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
}
|
|
|
|
export function getMousePosition() {
|
|
return mousePosition;
|
|
}
|