Make the Skin component use hooks and type it

This also abstracts some generic utility components
This commit is contained in:
Jordan Eldredge 2019-12-23 17:06:36 -08:00
parent af4990d5af
commit 7bd8c702ac
5 changed files with 209 additions and 171 deletions

View file

@ -0,0 +1,34 @@
import React, { useMemo, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
type Props = {
children: { [id: string]: string[] };
};
// this.props.children should be an object containing arrays of strings. The
// keys are ids, and the arrays are arrays of polygon point strings
export default function ClipPaths({ children }: Props) {
const paths = useMemo(() => {
return document.createElement("div");
}, []);
useLayoutEffect(() => {
document.body.appendChild(paths);
return () => paths.remove();
}, [paths]);
return createPortal(
<svg height={0} width={0}>
<defs>
{Object.keys(children).map(id => (
<clipPath id={id} key={id}>
{children[id].map((points, i) => (
<polygon points={points} key={i} />
))}
</clipPath>
))}
</defs>
</svg>,
paths
);
}

23
js/components/Css.tsx Normal file
View file

@ -0,0 +1,23 @@
import { createPortal } from "react-dom";
import { useMemo, useLayoutEffect } from "react";
type Props = {
children: string;
id: string;
};
export default function Css({ children, id }: Props) {
const style = useMemo(() => {
const s = document.createElement("style");
s.type = "text/css";
s.id = id;
return s;
}, [id]);
useLayoutEffect(() => {
document.head.appendChild(style);
return () => style.remove();
}, [style]);
return createPortal(children, style);
}

View file

@ -1,171 +0,0 @@
// Dynamically set the css background images for all the sprites
import React from "react";
import { createPortal } from "react-dom";
import { connect } from "react-redux";
import { LETTERS } from "../constants";
import { imageSelectors, cursorSelectors } from "../skinSelectors";
const CSS_PREFIX = "#webamp";
const mapRegionNamesToIds = {
normal: "mainWindowClipPath",
windowshade: "shadeMainWindowClipPath",
equalizer: "equalizerWindowClipPath",
equalizerws: "shadeEqualizerWindowClipPath",
};
const mapRegionNamesToMatcher = {
normal: "#main-window:not(.shade)",
windowshade: "#main-window.shade",
equalizer: "#equalizer-window:not(.shade)",
equalizerws: "#equalizer-window.shade",
};
const numExIsUsed = skinImages => !!skinImages.DIGIT_0_EX;
function Css({ children }) {
const style = React.useMemo(() => {
const s = document.createElement("style");
s.type = "text/css";
s.id = "webamp-skin";
return s;
}, []);
React.useLayoutEffect(() => {
document.head.appendChild(style);
return () => style.remove();
}, [style]);
return createPortal(children, style);
}
// this.props.children should be an object containing arrays of strings. The
// keys are ids, and the arrays are arrays of polygon point strings
function ClipPaths({ children }) {
const paths = React.useMemo(() => {
return document.createElement("div");
}, []);
React.useLayoutEffect(() => {
document.body.appendChild(paths);
return () => paths.remove();
}, [paths]);
return createPortal(
<svg height={0} width={0}>
<defs>
{Object.keys(children).map(id => (
<clipPath id={id} key={id}>
{children[id].map((points, i) => (
<polygon points={points} key={i} />
))}
</clipPath>
))}
</defs>
</svg>,
paths
);
}
const FALLBACKS = {
MAIN_BALANCE_BACKGROUND: "MAIN_VOLUME_BACKGROUND",
MAIN_BALANCE_THUMB: "MAIN_VOLUME_THUMB",
MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED",
};
function cssRulesFromProps(props) {
const { skinImages, skinCursors, skinGenLetterWidths } = props;
if (!skinImages || !skinCursors) {
return null;
}
const cssRules = [];
Object.keys(imageSelectors).forEach(imageName => {
const imageUrl = skinImages[imageName] || skinImages[FALLBACKS[imageName]];
if (imageUrl) {
imageSelectors[imageName].forEach(selector => {
cssRules.push(
`${CSS_PREFIX} ${selector} {background-image: url(${imageUrl})}`
);
});
}
});
if (skinGenLetterWidths != null) {
LETTERS.forEach(letter => {
const width = skinGenLetterWidths[`GEN_TEXT_${letter}`];
const selectedWidth = skinGenLetterWidths[`GEN_TEXT_SELECTED_${letter}`];
cssRules.push(
`${CSS_PREFIX} .gen-text-${letter.toLowerCase()} {width: ${width}px;}`
);
cssRules.push(
`${CSS_PREFIX} .selected .gen-text-${letter.toLowerCase()} {width: ${selectedWidth}px;}`
);
});
}
Object.keys(cursorSelectors).forEach(cursorName => {
const cursorUrl = skinCursors[cursorName];
if (cursorUrl) {
cursorSelectors[cursorName].forEach(selector => {
cssRules.push(
`${
// TODO: Fix this hack
// Maybe our CSS name spacing should be based on some other class/id
// than the one we use for defining the main div.
// That way it could be shared by both the player and the context menu.
selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX
} ${selector} {cursor: url(${cursorUrl}), auto}`
);
});
}
});
if (numExIsUsed(skinImages)) {
// This alternate number file requires that the minus sign be
// formatted differently.
cssRules.push(
`${CSS_PREFIX} .status #time #minus-sign { top: 0px; left: -1px; width: 9px; height: 13px; }`
);
}
for (const [regionName, polygons] of Object.entries(props.skinRegion)) {
if (polygons) {
const matcher = mapRegionNamesToMatcher[regionName];
const id = mapRegionNamesToIds[regionName];
cssRules.push(`${CSS_PREFIX} ${matcher} { clip-path: url(#${id}); }`);
}
}
return cssRules;
}
function clipPathsFromProps(props) {
const clipPaths = {};
for (const [regionName, polygons] of Object.entries(props.skinRegion)) {
if (polygons) {
const id = mapRegionNamesToIds[regionName];
clipPaths[id] = polygons;
}
}
return clipPaths;
}
const Skin = props => {
const cssRules = cssRulesFromProps(props);
if (cssRules == null) {
return null;
}
const clipPaths = clipPathsFromProps(props);
return (
<div>
<Css>{cssRules.join("\n")}</Css>
<ClipPaths>{clipPaths}</ClipPaths>
</div>
);
};
export default connect(state => ({
skinImages: state.display.skinImages,
skinCursors: state.display.skinCursors,
skinRegion: state.display.skinRegion,
skinGenLetterWidths: state.display.skinGenLetterWidths,
}))(Skin);

132
js/components/Skin.tsx Normal file
View file

@ -0,0 +1,132 @@
// Dynamically set the css background images for all the sprites
import React from "react";
import { LETTERS } from "../constants";
import { imageSelectors, cursorSelectors } from "../skinSelectors";
import { useTypedSelector } from "../hooks";
import * as Selectors from "../selectors";
import { SkinImages } from "../types";
import { createSelector } from "reselect";
import Css from "./Css";
import ClipPaths from "./ClipPaths";
const CSS_PREFIX = "#webamp";
const mapRegionNamesToIds: { [key: string]: string } = {
normal: "mainWindowClipPath",
windowshade: "shadeMainWindowClipPath",
equalizer: "equalizerWindowClipPath",
equalizerws: "shadeEqualizerWindowClipPath",
};
const mapRegionNamesToMatcher: { [key: string]: string } = {
normal: "#main-window:not(.shade)",
windowshade: "#main-window.shade",
equalizer: "#equalizer-window:not(.shade)",
equalizerws: "#equalizer-window.shade",
};
const numExIsUsed = (skinImages: SkinImages) => Boolean(skinImages.DIGIT_0_EX);
const FALLBACKS: { [key: string]: string } = {
MAIN_BALANCE_BACKGROUND: "MAIN_VOLUME_BACKGROUND",
MAIN_BALANCE_THUMB: "MAIN_VOLUME_THUMB",
MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED",
};
const getCssRules = createSelector(
Selectors.getSkinImages,
Selectors.getSkinCursors,
Selectors.getSkinLetterWidths,
Selectors.getSkinRegion,
(skinImages, skinCursors, skinGenLetterWidths, skinRegion): string | null => {
if (!skinImages || !skinCursors) {
return null;
}
const cssRules = [];
Object.keys(imageSelectors).forEach(imageName => {
const imageUrl =
skinImages[imageName] || skinImages[FALLBACKS[imageName]];
if (imageUrl) {
imageSelectors[imageName].forEach(selector => {
cssRules.push(
`${CSS_PREFIX} ${selector} {background-image: url(${imageUrl})}`
);
});
}
});
if (skinGenLetterWidths != null) {
LETTERS.forEach(letter => {
const width = skinGenLetterWidths[`GEN_TEXT_${letter}`];
const selectedWidth =
skinGenLetterWidths[`GEN_TEXT_SELECTED_${letter}`];
cssRules.push(
`${CSS_PREFIX} .gen-text-${letter.toLowerCase()} {width: ${width}px;}`
);
cssRules.push(
`${CSS_PREFIX} .selected .gen-text-${letter.toLowerCase()} {width: ${selectedWidth}px;}`
);
});
}
Object.keys(cursorSelectors).forEach(cursorName => {
const cursorUrl = skinCursors[cursorName];
if (cursorUrl) {
cursorSelectors[cursorName].forEach(selector => {
cssRules.push(
`${
// TODO: Fix this hack
// Maybe our CSS name spacing should be based on some other class/id
// than the one we use for defining the main div.
// That way it could be shared by both the player and the context menu.
selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX
} ${selector} {cursor: url(${cursorUrl}), auto}`
);
});
}
});
if (numExIsUsed(skinImages)) {
// This alternate number file requires that the minus sign be
// formatted differently.
cssRules.push(
`${CSS_PREFIX} .status #time #minus-sign { top: 0px; left: -1px; width: 9px; height: 13px; }`
);
}
for (const [regionName, polygons] of Object.entries(skinRegion)) {
if (polygons) {
const matcher = mapRegionNamesToMatcher[regionName];
const id = mapRegionNamesToIds[regionName];
cssRules.push(`${CSS_PREFIX} ${matcher} { clip-path: url(#${id}); }`);
}
}
return cssRules.join("\n");
}
);
const getClipPaths = createSelector(Selectors.getSkinRegion, skinRegion => {
const clipPaths: { [id: string]: string[] } = {};
for (const [regionName, polygons] of Object.entries(skinRegion)) {
if (polygons) {
const id = mapRegionNamesToIds[regionName];
clipPaths[id] = polygons;
}
}
return clipPaths;
});
export default function Skin() {
const cssRules = useTypedSelector(getCssRules);
const clipPaths = useTypedSelector(getClipPaths);
if (cssRules == null) {
return null;
}
return (
<>
<Css id="webamp-skin">{cssRules}</Css>
<ClipPaths>{clipPaths}</ClipPaths>
</>
);
}

View file

@ -10,6 +10,10 @@ import {
TransitionType,
MediaStatus,
TimeMode,
SkinImages,
Cursors,
SkinRegion,
GenLetterWidths,
} from "./types";
import { createSelector, defaultMemoize } from "reselect";
import * as Utils from "./utils";
@ -677,6 +681,22 @@ export function getRandomizePresets(state: AppState): boolean {
return state.milkdrop.randomize;
}
export function getSkinImages(state: AppState): SkinImages {
return state.display.skinImages;
}
export function getSkinCursors(state: AppState): Cursors | null {
return state.display.skinCursors;
}
export function getSkinRegion(state: AppState): SkinRegion {
return state.display.skinRegion;
}
export function getSkinLetterWidths(state: AppState): GenLetterWidths | null {
return state.display.skinGenLetterWidths;
}
export function getPreampLineUrl(state: AppState): string | null {
return state.display.skinImages.EQ_PREAMP_LINE;
}