Refactor and Split Up Some Code

This refactors some code to use CSS modules, functional React components
and splits up the Settings component into roughly one component per
setting.
This commit is contained in:
Christopher Serr
2025-03-26 21:18:57 +01:00
parent 048dbf22dc
commit d771cda89d
39 changed files with 2773 additions and 2490 deletions
+84
View File
@@ -0,0 +1,84 @@
@use "mobile.module.scss";
@use "variables.icss";
$icon-size: 40px;
$title-font-size: 40px;
$build-version-font-size: 12px;
$link-color: #56b0ff;
.about {
max-width: 700px;
@include mobile.mobile {
max-width: 100%;
}
}
.aboutInnerContainer {
background-color: variables.$light-row-color;
padding: variables.$ui-large-margin;
border: 1px solid variables.$border-color;
width: fit-content;
h2 {
margin-bottom: variables.$ui-large-margin;
}
a {
color: $link-color;
}
@include mobile.mobile {
box-sizing: border-box;
}
}
.livesplitTitle {
display: flex;
align-items: center;
gap: variables.$ui-margin;
}
.livesplitIcon {
height: $icon-size;
}
.titleText {
font-weight: bold;
font-size: $title-font-size;
}
.buildVersion {
font-size: $build-version-font-size;
}
.changelog {
margin-left: variables.$ui-large-margin;
>div {
margin: variables.$ui-large-margin 0 variables.$ui-large-margin variables.$ui-large-margin;
}
}
.contributors {
margin: 0 auto;
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: space-evenly;
align-items: end;
font-size: 14px;
a {
display: flex;
align-items: center;
flex-direction: column;
gap: 4px;
img {
width: variables.$contributor-avatar-size;
height: variables.$contributor-avatar-size;
border-radius: 50%;
}
}
}
-89
View File
@@ -1,89 +0,0 @@
@use "mobile";
@use "variables.icss";
$icon-size: 40px;
$title-font-size: 40px;
$build-version-font-size: 12px;
$link-color: #56b0ff;
.about {
.about-inner-container {
background-color: variables.$light-row-color;
padding: variables.$ui-large-margin;
border: 1px solid variables.$border-color;
width: fit-content;
.livesplit-title {
display: flex;
align-items: center;
.livesplit-icon {
height: $icon-size;
margin-right: variables.$ui-margin;
img {
height: 100%;
}
}
.title-text {
font-weight: bold;
font-size: $title-font-size;
}
}
.build-version {
font-size: $build-version-font-size;
}
h2 {
margin-bottom: variables.$ui-large-margin;
}
a {
color: $link-color;
}
.changelog {
margin-left: variables.$ui-large-margin;
> div {
margin: variables.$ui-large-margin 0 variables.$ui-large-margin
variables.$ui-large-margin;
}
}
.contributors {
margin: 0 auto;
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: space-evenly;
align-items: end;
font-size: 14px;
a {
display: flex;
align-items: center;
flex-direction: column;
gap: 4px;
img {
width: variables.$contributor-avatar-size;
height: variables.$contributor-avatar-size;
border-radius: 50%;
}
}
}
@include mobile.mobile {
box-sizing: border-box;
}
}
max-width: 700px;
@include mobile.mobile {
max-width: 100%;
}
}
@@ -1,6 +1,6 @@
@use "variables.icss";
.is-mobile + dialog > .dialog {
:global(.is-mobile) + dialog > .dialog {
min-width: auto;
}
@@ -20,13 +20,19 @@ dialog {
border: 2px solid rgba(255, 255, 255, 0.25);
border-radius: 10px;
min-width: 225px;
display: flex;
flex-direction: column;
gap: variables.$ui-large-margin;
max-width: 400px;
padding: variables.$ui-large-margin;
h1 {
font-size: 20px;
margin: 5px 0;
font-size: larger;
}
>* {
margin: 0;
}
.buttons {
@@ -49,7 +55,6 @@ dialog {
}
input {
width: 100%;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
background: transparent;
@@ -1,14 +1,14 @@
@use "mobile";
@use "mobile.module.scss";
@use "variables.icss";
#upload-drop-zone {
.uploadDropZone {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
#upload-drop-zone-overlay {
.overlay {
position: absolute;
width: calc(100% + 2 * #{variables.$main-content-margin});
height: calc(100% + 2 * #{variables.$main-content-margin});
@@ -27,7 +27,7 @@
margin: 0;
}
.overlay-text {
.overlayText {
font-size: 50px;
text-align: center;
}
@@ -1,23 +1,32 @@
@use "variables.icss";
.hotkey-box {
.hotkeyBox {
button {
margin: 0;
font-size: 16px;
min-height: 22px;
padding-top: 0;
padding-bottom: 0;
}
.hotkey-button.focused {
color: red;
}
.trash {
cursor: pointer;
}
display: grid;
grid-template-columns: 1fr 20px;
column-gap: variables.$ui-margin;
align-items: center;
}
.focused {
color: red;
}
.trash {
cursor: pointer;
}
.overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 0;
z-index: 5;
}
+40
View File
@@ -0,0 +1,40 @@
@use "Font";
.resizableLayout {
position: absolute;
top: 0;
width: inherit;
height: inherit;
:global(.react-resizable) {
width: 0 !important;
height: 0 !important;
}
}
.handleEast {
cursor: e-resize;
right: -10px;
bottom: 10px;
top: 0;
position: absolute;
width: 20px;
}
.handleSouth {
cursor: s-resize;
bottom: -10px;
left: 0;
right: 10px;
position: absolute;
height: 20px;
}
.handleSouthEast {
cursor: se-resize;
bottom: -10px;
right: -10px;
position: absolute;
width: 20px;
height: 20px;
}
-42
View File
@@ -1,42 +0,0 @@
@use "Font";
.layout {
.resizable-layout {
position: absolute;
top: 0;
width: inherit;
height: inherit;
.react-resizable {
width: 0 !important;
height: 0 !important;
}
.resizable-handle-east {
cursor: e-resize;
right: -10px;
bottom: 10px;
top: 0;
position: absolute;
width: 20px;
}
.resizable-handle-south {
cursor: s-resize;
bottom: -10px;
left: 0;
right: 10px;
position: absolute;
height: 20px;
}
.resizable-handle-south-east {
cursor: se-resize;
bottom: -10px;
right: -10px;
position: absolute;
width: 20px;
height: 20px;
}
}
}
-81
View File
@@ -9,7 +9,6 @@
$tab-bar-height: 30px;
$tab-width: 625px;
$button-width: 160px;
$label-size: 14px;
$segment-icon-size: 19px;
$mobile-game-icon-size: 100px;
@@ -326,86 +325,6 @@ $small-button-padding: 1px 3px 1px 3px;
}
}
.group {
position: relative;
border-bottom: 1px solid variables.$border-color;
> input {
font-size: 18px;
padding: $label-size + variables.$ui-margin 0
math.div(variables.$ui-margin, 2)
math.div(variables.$ui-margin, 2);
display: block;
width: 100%;
border: none;
background: transparent;
color: white;
font-family: "fira", sans-serif;
}
> input:focus {
outline: none;
}
> label {
color: hsla(50, 0%, 75%, 1);
font-size: $label-size;
font-weight: normal;
position: absolute;
pointer-events: none;
left: math.div(variables.$ui-margin, 2);
top: 0;
transition: 0.2s ease all;
-moz-transition: 0.2s ease all;
-webkit-transition: 0.2s ease all;
}
> input:focus ~ label {
color: hsla(50, 100%, 50%, 1);
}
&.invalid > input:focus ~ label {
color: hsla(0, 100%, 50%, 1);
}
> .bar {
position: relative;
display: block;
width: 100%;
}
> .bar:before,
> .bar:after {
content: "";
height: 2px;
width: 0;
bottom: 0;
position: absolute;
background: hsla(50, 100%, 50%, 1);
transition: 0.2s ease all;
-moz-transition: 0.2s ease all;
-webkit-transition: 0.2s ease all;
}
> .bar:before {
left: 50%;
}
> .bar:after {
right: 50%;
}
&.invalid > .bar:before,
&.invalid > .bar:after {
background: hsla(0, 100%, 50%, 1);
}
> input:focus ~ .bar:before,
> input:focus ~ .bar:after {
width: 50%;
}
}
.filter-table {
width: 100%;
+79
View File
@@ -0,0 +1,79 @@
@use "sass:math";
@use "variables.icss";
$label-size: 14px;
.group {
position: relative;
border-bottom: 1px solid variables.$border-color;
>input {
font-size: 18px;
padding: $label-size + variables.$ui-margin 0 math.div(variables.$ui-margin, 2) math.div(variables.$ui-margin, 2);
display: block;
width: 100%;
border: none;
background: transparent;
color: white;
font-family: inherit;
}
>input:focus {
outline: none;
}
>label {
color: hsla(50, 0%, 75%, 1);
font-size: $label-size;
font-weight: normal;
position: absolute;
pointer-events: none;
left: math.div(variables.$ui-margin, 2);
top: 0;
transition: 0.2s ease all;
}
>input:focus~label {
color: hsla(50, 100%, 50%, 1);
}
&.invalid>input:focus~label {
color: hsla(0, 100%, 50%, 1);
}
>.bar {
position: relative;
display: block;
width: 100%;
}
>.bar:before,
>.bar:after {
content: "";
height: 2px;
width: 0;
bottom: 0;
position: absolute;
background: hsla(50, 100%, 50%, 1);
transition: 0.2s ease all;
}
>.bar:before {
left: 50%;
}
>.bar:after {
right: 50%;
}
&.invalid>.bar:before,
&.invalid>.bar:after {
background: hsla(0, 100%, 50%, 1);
}
>input:focus~.bar:before,
>input:focus~.bar:after {
width: 50%;
}
}
+5
View File
@@ -0,0 +1,5 @@
@mixin mobile {
:global(.is-mobile) & {
@content;
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ import { UrlCache } from "../util/UrlCache";
import { ServerProtocol, WebRenderer } from "../livesplit-core/livesplit_core";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { LSOCommandSink } from "./LSOCommandSink";
import DialogContainer from "./components/Dialog";
import { DialogContainer } from "./components/Dialog";
import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys";
import { Menu } from "lucide-react";
@@ -350,7 +350,7 @@ export class LiveSplit extends React.Component<Props, State> {
}
public render() {
let view;
let view: React.JSX.Element | undefined;
if (this.state.menu.kind === MenuKind.RunEditor) {
view = (
<RunEditorComponent
+1 -1
View File
@@ -17,7 +17,7 @@ function colorToCss(color: Color): string {
return `rgba(${r},${g},${b},${a})`;
}
export default function ColorPicker({
export function ColorPicker({
color,
setColor,
}: {
+81 -94
View File
@@ -1,11 +1,6 @@
import * as React from "react";
import React, { useState, useEffect } from "react";
import "../../css/Dialog.scss";
export interface Props {
onShow: () => void;
onClose: () => void;
}
import * as classes from "../../css/Dialog.module.scss";
export interface Options {
title: string | React.JSX.Element;
@@ -21,7 +16,7 @@ export interface State {
}
let dialogElement: HTMLDialogElement | null = null;
let setState: ((options: Options) => void) | undefined;
let setStateFn: ((options: Options) => void) | undefined;
let resolveFn: ((_: [number, string]) => void) | undefined;
let onCloseFn: (() => void) | undefined;
let alreadyClosed = false;
@@ -39,99 +34,91 @@ export function showDialog(options: Options): Promise<[number, string]> {
}
};
}
setState?.(options);
setStateFn?.(options);
return new Promise((resolve) => (resolveFn = resolve));
}
export default class DialogContainer extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
export function DialogContainer({
onShow,
onClose,
}: {
onShow: () => void;
onClose: () => void;
}) {
const [options, setOptions] = useState<Options>({
title: "",
description: "",
buttons: [],
});
const [input, setInput] = useState("");
onCloseFn = props.onClose;
useEffect(() => {
onCloseFn = onClose;
}, [onClose]);
this.state = {
options: {
title: "",
description: "",
buttons: [],
},
input: "",
useEffect(() => {
setStateFn = (options) => {
onShow();
setOptions(options);
setInput(options.defaultText ?? "");
};
}
}, [onShow]);
public componentDidMount(): void {
setState = (options) => {
this.props.onShow();
this.setState({
options,
input: options.defaultText ?? "",
});
};
}
public render() {
return (
<dialog
ref={(element) => {
dialogElement = element;
}}
onKeyDown={(e) => {
if (e?.key === "ArrowLeft") {
e.preventDefault();
(
document.activeElement
?.previousElementSibling as any
)?.focus();
} else if (e?.key === "ArrowRight") {
e.preventDefault();
(
document.activeElement?.nextElementSibling as any
)?.focus();
}
}}
>
<div className="dialog">
<h1>{this.state.options.title}</h1>
<p>{this.state.options.description}</p>
{this.state.options.textInput && (
<input
type="text"
value={this.state.input}
autoFocus={true}
onChange={(e) =>
this.setState({ input: e.target.value })
}
onKeyDown={(e) => {
if (e?.key === "Enter") {
e.preventDefault();
this.close(0);
}
}}
/>
)}
<div className="buttons">
{this.state.options.buttons.map((button, i) => {
return (
<button
autoFocus={
i === 0 && !this.state.options.textInput
}
onClick={() => this.close(i)}
>
{button}
</button>
);
})}
</div>
</div>
</dialog>
);
}
private close(i: number) {
const handleClose = (i: number) => {
alreadyClosed = true;
dialogElement?.close();
resolveFn?.([i, this.state.input]);
this.props.onClose();
}
resolveFn?.([i, input]);
onClose();
};
return (
<dialog
ref={(element) => {
dialogElement = element;
}}
onKeyDown={(e) => {
if (e?.key === "ArrowLeft") {
e.preventDefault();
(
document.activeElement?.previousElementSibling as any
)?.focus();
} else if (e?.key === "ArrowRight") {
e.preventDefault();
(
document.activeElement?.nextElementSibling as any
)?.focus();
}
}}
>
<div className={classes.dialog}>
<h1>{options.title}</h1>
<p>{options.description}</p>
{options.textInput && (
<input
type="text"
value={input}
autoFocus={true}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e?.key === "Enter") {
e.preventDefault();
handleClose(0);
}
}}
/>
)}
<div className={classes.buttons}>
{options.buttons.map((button, i) => (
<button
key={i}
autoFocus={i === 0 && !options.textInput}
onClick={() => handleClose(i)}
>
{button}
</button>
))}
</div>
</div>
</dialog>
);
}
+43 -32
View File
@@ -1,37 +1,38 @@
import * as React from "react";
import React, { useEffect, useRef } from "react";
import { toast } from "react-toastify";
import "../../css/DragUpload.scss";
import * as classes from "../../css/DragUpload.module.scss";
export interface Props {
export function DragUpload({
children,
importLayout,
importSplits,
}: {
children: React.ReactNode;
importLayout?: (file: File) => Promise<void>;
importSplits(file: File): Promise<void>;
}
importSplits: (file: File) => Promise<void>;
}) {
const dropZoneRef = useRef<HTMLDivElement>(null);
const dropZoneOverlayRef = useRef<HTMLDivElement>(null);
export default class DragUpload extends React.Component<Props> {
public componentDidMount() {
const dropZone = document.getElementById("upload-drop-zone");
const dropZoneOverlay = document.getElementById(
"upload-drop-zone-overlay",
);
const importLayout = this.props.importLayout;
const importSplits = this.props.importSplits;
useEffect(() => {
const dropZone = dropZoneRef.current;
const dropZoneOverlay = dropZoneOverlayRef.current;
if (dropZone === null) {
if (!dropZone) {
return;
}
dropZone.addEventListener("dragenter", (event) => {
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (dropZoneOverlay) {
dropZoneOverlay.style.visibility = "visible";
}
});
};
dropZone.addEventListener("dragleave", (event) => {
const handleDragLeave = (event: DragEvent) => {
if (
dropZoneOverlay &&
(event.pageX < 10 ||
@@ -41,14 +42,14 @@ export default class DragUpload extends React.Component<Props> {
) {
dropZoneOverlay.style.visibility = "hidden";
}
});
};
dropZone.addEventListener("dragover", (event) => {
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
});
};
dropZone.addEventListener("drop", (event) => {
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -73,17 +74,27 @@ export default class DragUpload extends React.Component<Props> {
}
}
return null;
});
}
};
public render() {
return (
<div id="upload-drop-zone">
<div id="upload-drop-zone-overlay">
<div className="overlay-text">Waiting for drop...</div>
</div>
{this.props.children}
dropZone.addEventListener("dragenter", handleDragEnter);
dropZone.addEventListener("dragleave", handleDragLeave);
dropZone.addEventListener("dragover", handleDragOver);
dropZone.addEventListener("drop", handleDrop);
return () => {
dropZone.removeEventListener("dragenter", handleDragEnter);
dropZone.removeEventListener("dragleave", handleDragLeave);
dropZone.removeEventListener("dragover", handleDragOver);
dropZone.removeEventListener("drop", handleDrop);
};
}, [importLayout, importSplits]);
return (
<div ref={dropZoneRef} className={classes.uploadDropZone}>
<div ref={dropZoneOverlayRef} className={classes.overlay}>
<div className={classes.overlayText}>Waiting for drop...</div>
</div>
);
}
{children}
</div>
);
}
+87 -116
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import { Option, map, expect } from "../../util/OptionUtil";
import React, { useCallback, useEffect, useState } from "react";
import { Option, expect } from "../../util/OptionUtil";
import { hotkeySystem } from "../LiveSplit";
import { Circle, Trash } from "lucide-react";
import "../../css/HotkeyButton.scss";
import * as classes from "../../css/HotkeyButton.module.scss";
function resolveKey(keyCode: string): Promise<string> | string {
return expect(
@@ -12,104 +12,45 @@ function resolveKey(keyCode: string): Promise<string> | string {
).resolve(keyCode);
}
export interface Props {
export function HotkeyButton({
value,
setValue,
}: {
value: Option<string>;
setValue: (value: Option<string>) => void;
}
}) {
const [listener, setListener] = useState<Option<EventListenerObject>>(null);
const [intervalHandle, setIntervalHandle] = useState<Option<number>>(null);
const [resolvedKey, setResolvedKey] = useState<Option<string>>(null);
export interface State {
listener: Option<EventListenerObject>;
intervalHandle: Option<number>;
resolvedKey: Option<string>;
}
export default class HotkeyButton extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
listener: null,
intervalHandle: null,
resolvedKey: null,
};
this.updateResolvedKey(props.value);
}
public componentDidUpdate(previousProps: Props) {
if (previousProps.value !== this.props.value) {
this.updateResolvedKey(this.props.value);
}
}
private async updateResolvedKey(value: Option<string>): Promise<void> {
let resolvedKey = "";
if (value != null) {
const matches = value.match(/(.+)\+\s*(.+)$/);
if (matches != null) {
resolvedKey = `${matches[1]}+ ${await resolveKey(matches[2])}`;
} else {
resolvedKey = await resolveKey(value);
const updateResolvedKey = useCallback(
async (value: Option<string>): Promise<void> => {
let resolvedKey = "";
if (value != null) {
const matches = value.match(/(.+)\+\s*(.+)$/);
if (matches != null) {
resolvedKey = `${matches[1]}+ ${await resolveKey(
matches[2],
)}`;
} else {
resolvedKey = await resolveKey(value);
}
}
}
this.setState({ resolvedKey });
}
setResolvedKey(resolvedKey);
},
[],
);
public render() {
let buttonText: Option<string> | React.JSX.Element = null;
if (this.props.value != null) {
buttonText = this.state.resolvedKey;
} else if (this.state.listener != null) {
buttonText = (
<Circle strokeWidth={0} size={16} fill="currentColor" />
);
}
useEffect(() => {
updateResolvedKey(value);
}, [value, updateResolvedKey]);
return (
<div className="hotkey-box">
<button
className={`hotkey-button tooltip ${
this.state.listener != null ? "focused" : ""
}`}
onClick={() => this.focusButton()}
>
{buttonText}
<span className="tooltip-text">
Click to record a hotkey. You may also use buttons on a
gamepad. Global hotkeys are currently not possible.
Gamepad buttons work globally.
</span>
</button>
{map(this.props.value, () => (
<Trash
className="trash"
strokeWidth={2.5}
size={20}
onClick={() => this.props.setValue(null)}
/>
))}
{this.state.listener != null && (
<div
style={{
bottom: "0px",
left: "0px",
position: "fixed",
right: "0px",
top: "0px",
zIndex: 5,
}}
onClick={() => this.blurButton()}
/>
)}
</div>
);
}
const focusButton = () => {
let newListener = listener;
let newIntervalHandle = intervalHandle;
private focusButton() {
let listener = this.state.listener;
let intervalHandle = this.state.intervalHandle;
if (listener === null) {
listener = {
if (newListener == null) {
newListener = {
handleEvent: (ev: KeyboardEvent) => {
if (ev.repeat) {
return;
@@ -144,17 +85,17 @@ export default class HotkeyButton extends React.Component<Props, State> {
text += "Shift + ";
}
text += ev.code;
this.props.setValue(text);
setValue(text);
ev.preventDefault();
},
};
window.addEventListener("keydown", listener);
window.addEventListener("keydown", newListener);
}
if (intervalHandle === null) {
if (newIntervalHandle == null) {
const oldButtonState: boolean[][] = [];
intervalHandle = window.setInterval(() => {
newIntervalHandle = window.setInterval(() => {
const gamepads = navigator.getGamepads();
let gamepadIdx = 0;
@@ -163,14 +104,14 @@ export default class HotkeyButton extends React.Component<Props, State> {
oldButtonState[gamepadIdx] = [];
}
if (gamepad !== null) {
if (gamepad != null) {
let buttonIdx = 0;
for (const button of gamepad.buttons) {
const oldState =
oldButtonState[gamepadIdx]?.[buttonIdx] ??
false;
if (button.pressed && !oldState) {
this.props.setValue(`Gamepad${buttonIdx}`);
setValue(`Gamepad${buttonIdx}`);
}
oldButtonState[gamepadIdx][buttonIdx] =
@@ -185,22 +126,52 @@ export default class HotkeyButton extends React.Component<Props, State> {
}, 1000 / 60.0);
}
this.setState({
listener,
intervalHandle,
});
setListener(newListener);
setIntervalHandle(newIntervalHandle);
};
const blurButton = () => {
if (listener != null) {
window.removeEventListener("keydown", listener);
}
if (intervalHandle != null) {
window.clearInterval(intervalHandle);
}
setListener(null);
setIntervalHandle(null);
};
let buttonText: Option<string> | React.JSX.Element = null;
if (value != null) {
buttonText = resolvedKey;
} else if (listener != null) {
buttonText = <Circle strokeWidth={0} size={16} fill="currentColor" />;
}
private blurButton() {
if (this.state.listener != null) {
window.removeEventListener("keydown", this.state.listener);
}
if (this.state.intervalHandle != null) {
window.clearTimeout(this.state.intervalHandle);
}
this.setState({
listener: null,
intervalHandle: null,
});
}
return (
<div className={classes.hotkeyBox}>
<button
className={`tooltip ${listener != null ? classes.focused : ""}`}
onClick={focusButton}
>
{buttonText}
<span className="tooltip-text">
Click to record a hotkey. You may also use buttons on a
gamepad. Global hotkeys are currently not possible. Gamepad
buttons work globally.
</span>
</button>
{value && (
<Trash
className={classes.trash}
strokeWidth={2.5}
size={20}
onClick={() => setValue(null)}
/>
)}
{listener != null && (
<div className={classes.overlay} onClick={blurButton} />
)}
</div>
);
}
+79 -92
View File
@@ -6,9 +6,18 @@ import AutoRefresh from "../../util/AutoRefresh";
import { UrlCache } from "../../util/UrlCache";
import { GeneralSettings } from "../views/MainSettings";
import "../../css/Layout.scss";
import * as classes from "../../css/Layout.module.scss";
export interface Props {
export default function Layout({
getState,
layoutUrlCache,
allowResize,
width,
height,
generalSettings,
renderer,
onResize,
}: {
getState: () => LayoutStateRef;
layoutUrlCache: UrlCache;
allowResize: boolean;
@@ -16,100 +25,78 @@ export interface Props {
height: number;
generalSettings: GeneralSettings;
renderer: WebRenderer;
onResize(width: number, height: number): void;
}
export default class Layout extends React.Component<Props, unknown> {
public refreshLayout() {
const layoutState = this.props.getState();
const newDims = this.props.renderer.render(
onResize: (width: number, height: number) => void;
}) {
const update = () => {
const layoutState = getState();
const newDims = renderer.render(
layoutState.ptr,
this.props.layoutUrlCache.imageCache.ptr,
layoutUrlCache.imageCache.ptr,
);
if (newDims !== undefined) {
this.props.onResize(newDims[0], newDims[1]);
if (newDims != null) {
onResize(newDims[0], newDims[1]);
}
}
};
public render() {
return (
<AutoRefresh
frameRate={this.props.generalSettings.frameRate}
update={() => this.refreshLayout()}
>
return (
<AutoRefresh frameRate={generalSettings.frameRate} update={update}>
<div style={{ width, height }}>
<div
className="layout"
style={{
width: this.props.width,
height: this.props.height,
style={{ width, height }}
ref={(element) => {
element?.appendChild(renderer.element());
}}
>
<div
style={{ width: "inherit", height: "inherit" }}
ref={(element) => {
element?.appendChild(this.props.renderer.element());
}}
/>
{this.props.allowResize && (
<div className="resizable-layout">
<ResizableBox
axis="x"
width={this.props.width}
height={this.props.height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className="resizable-handle-east"
/>
}
onResize={(_event, data) =>
this.props.onResize(
data.size.width,
data.size.height,
)
}
/>
<ResizableBox
axis="y"
width={this.props.width}
height={this.props.height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className="resizable-handle-south"
/>
}
onResize={(_event, data) =>
this.props.onResize(
data.size.width,
data.size.height,
)
}
/>
<ResizableBox
axis="both"
width={this.props.width}
height={this.props.height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className="resizable-handle-south-east"
/>
}
onResize={(_event, data) =>
this.props.onResize(
data.size.width,
data.size.height,
)
}
/>
</div>
)}
</div>
</AutoRefresh>
);
}
/>
{allowResize && (
<div className={classes.resizableLayout}>
<ResizableBox
axis="x"
width={width}
height={height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className={classes.handleEast}
/>
}
onResize={(_event, data) =>
onResize(data.size.width, data.size.height)
}
/>
<ResizableBox
axis="y"
width={width}
height={height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className={classes.handleSouth}
/>
}
onResize={(_event, data) =>
onResize(data.size.width, data.size.height)
}
/>
<ResizableBox
axis="both"
width={width}
height={height}
minConstraints={[100, 40]}
handle={
<div
onClick={(e) => e.stopPropagation()}
className={classes.handleSouthEast}
/>
}
onResize={(_event, data) =>
onResize(data.size.width, data.size.height)
}
/>
</div>
)}
</div>
</AutoRefresh>
);
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { AccuracyJson } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
export function Accuracy<T>({
value,
setValue,
factory,
}: {
value: AccuracyJson;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromAccuracy(e.target.value),
"Unexpected Accuracy",
),
)
}
>
<option value="Seconds">Seconds</option>
<option value="Tenths">Tenths</option>
<option value="Hundredths">Hundredths</option>
<option value="Milliseconds">Milliseconds</option>
</select>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { Alignment } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
export function Alignment<T>({
value,
setValue,
factory,
}: {
value: Alignment;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromAlignment(e.target.value),
"Unexpected Alignment",
),
)
}
>
<option value="Auto">Automatic</option>
<option value="Left">Left</option>
<option value="Center">Center</option>
</select>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react";
import { Color } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
import { ColorPicker } from "../ColorPicker";
import { Switch } from "../Switch";
export function Color<T>({
value,
setValue,
factory,
}: {
value: Color;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<ColorPicker
color={value}
setColor={(color) =>
setValue(
factory.fromColor(
color[0],
color[1],
color[2],
color[3],
),
)
}
/>
</div>
);
}
export function OptionalColor<T>({
value,
setValue,
factory,
}: {
value: Color | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
const children: React.ReactNode[] = [];
if (value !== null) {
children.push(
<ColorPicker
color={value}
setColor={(color) =>
setValue(
factory.fromOptionalColor(
color[0],
color[1],
color[2],
color[3],
),
)
}
/>,
);
}
return (
<div className="settings-value-box optional-value">
<Switch
checked={value !== null}
setIsChecked={(checked) => {
if (checked) {
setValue(factory.fromOptionalColor(1, 1, 1, 1));
} else {
setValue(factory.fromOptionalEmptyColor());
}
}}
/>
{children}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import {
ColumnKind,
ColumnStartWith,
ColumnUpdateWith,
ColumnUpdateTrigger,
} from "../../../livesplit-core";
import { SettingValueFactory } from ".";
export function ColumnKind<T>({
value,
setValue,
factory,
}: {
value: ColumnKind;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromColumnKind(e.target.value),
"Unexpected Column Kind value",
),
)
}
>
<option value="Time">Time</option>
<option value="Variable">Variable</option>
</select>
</div>
);
}
export function ColumnStartWith<T>({
value,
setValue,
factory,
}: {
value: ColumnStartWith;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromColumnStartWith(e.target.value),
"Unexpected Column Start With value",
),
)
}
>
<option value="Empty">Empty</option>
<option value="ComparisonTime">Comparison Time</option>
<option value="ComparisonSegmentTime">
Comparison Segment Time
</option>
<option value="PossibleTimeSave">Possible Time Save</option>
</select>
</div>
);
}
export function ColumnUpdateWith<T>({
value,
setValue,
factory,
}: {
value: ColumnUpdateWith;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromColumnUpdateWith(e.target.value),
"Unexpected Column Update With value",
),
)
}
>
<option value="DontUpdate">Don't Update</option>
<option value="SplitTime">Split Time</option>
<option value="Delta">Time Ahead / Behind</option>
<option value="DeltaWithFallback">
Time Ahead / Behind or Split Time If Empty
</option>
<option value="SegmentTime">Segment Time</option>
<option value="SegmentDelta">Time Saved / Lost</option>
<option value="SegmentDeltaWithFallback">
Time Saved / Lost or Segment Time If Empty
</option>
</select>
</div>
);
}
export function ColumnUpdateTrigger<T>({
value,
setValue,
factory,
}: {
value: ColumnUpdateTrigger;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromColumnUpdateTrigger(e.target.value),
"Unexpected Column Update Trigger value",
),
)
}
>
<option value="OnStartingSegment">On Starting Segment</option>
<option value="Contextual">Contextual</option>
<option value="OnEndingSegment">On Ending Segment</option>
</select>
</div>
);
}
@@ -0,0 +1,37 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { DigitsFormatJson } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
export function DigitsFormat<T>({
value,
setValue,
factory,
}: {
value: DigitsFormatJson;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromDigitsFormat(e.target.value),
"Unexpected Digits Format",
),
)
}
>
<option value="SingleDigitSeconds">1</option>
<option value="DoubleDigitSeconds">01</option>
<option value="SingleDigitMinutes">0:01</option>
<option value="DoubleDigitMinutes">00:01</option>
<option value="SingleDigitHours">0:00:01</option>
<option value="DoubleDigitHours">00:00:01</option>
</select>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { Font } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
import { Switch } from "../Switch";
import * as FontList from "../../../util/FontList";
export function Font<T>({
value,
setValue,
factory,
loadedCallback,
}: {
value: Font | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
loadedCallback: () => void;
}) {
const children = [
<Switch
checked={value !== null}
setIsChecked={(value) => {
if (value) {
setValue(
expect(
factory.fromFont("", "normal", "normal", "normal"),
"Unexpected Font",
),
);
} else {
setValue(factory.fromEmptyFont());
}
}}
/>,
];
if (value !== null) {
// FIXME: We should do a proper promise hook.
FontList.load(loadedCallback);
const { family, style, weight, stretch } = value;
const styles = FontList.knownStyles.get(family);
if (FontList.knownFamilies.length > 0) {
children.push(
<select
style={{
fontFamily: family,
}}
value={family}
onChange={(e) =>
setValue(
expect(
factory.fromFont(
e.target.value,
style,
weight,
stretch,
),
"Unexpected Font",
),
)
}
>
<option value=""></option>
{FontList.knownFamilies.map((n) => (
<option value={n} style={{ fontFamily: n }}>
{n}
</option>
))}
</select>,
);
} else {
children.push(
<input
className="text-box"
value={family}
onChange={(e) =>
setValue(
expect(
factory.fromFont(
e.target.value,
style,
weight,
stretch,
),
"Unexpected Font",
),
)
}
/>,
);
}
children.push(
<>Style</>,
<select
value={style}
onChange={(e) =>
setValue(
expect(
factory.fromFont(
family,
e.target.value,
weight,
stretch,
),
"Unexpected Font",
),
)
}
>
<option value="normal">Normal</option>
<option value="italic">Italic</option>
</select>,
<>Weight</>,
<select
value={weight}
onChange={(e) =>
setValue(
expect(
factory.fromFont(
family,
style,
e.target.value,
stretch,
),
"Unexpected Font",
),
)
}
>
{FontList.FONT_WEIGHTS.map(([_, value, name]) => (
<option
style={{
color: styles?.has(value) ? "white" : "grey",
}}
value={value}
>
{name}
</option>
))}
</select>,
<>Stretch</>,
<select
value={stretch}
onChange={(e) =>
setValue(
expect(
factory.fromFont(
family,
style,
weight,
e.target.value,
),
"Unexpected Font",
),
)
}
>
{FontList.FONT_STRETCHES.map(([_, value, name]) => (
<option
style={{
color: styles?.has(value) ? "white" : "grey",
}}
value={value}
>
{name}
</option>
))}
</select>,
);
}
return <div className="settings-value-box optional-value">{children}</div>;
}
+384
View File
@@ -0,0 +1,384 @@
import * as React from "react";
import {
Color,
DeltaGradient,
Gradient,
ListGradient,
} from "../../../livesplit-core";
import { SettingValueFactory } from ".";
import { assertNever, expect, Option } from "../../../util/OptionUtil";
import { ColorPicker } from "../ColorPicker";
export function Gradient<T>({
value,
setValue,
factory,
}: {
value: Gradient;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
let type: string | undefined;
let color1: Color | undefined;
let color2: Color | undefined;
if (value !== "Transparent") {
type = Object.keys(value)[0];
if ("Plain" in value) {
color1 = value.Plain;
} else if ("Vertical" in value) {
[color1, color2] = value.Vertical;
} else if ("Horizontal" in value) {
[color1, color2] = value.Horizontal;
} else {
assertNever(value);
}
} else {
type = "Transparent";
}
const colorsToValue = (
type: string,
color1: Option<Color>,
color2: Option<Color>,
) => {
color1 = color1 ?? [0, 0, 0, 0];
color2 = color2 ?? color1;
switch (type) {
case "Transparent":
return factory.fromTransparentGradient();
case "Plain":
return factory.fromColor(
color1[0],
color1[1],
color1[2],
color1[3],
);
case "Vertical":
return factory.fromVerticalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
case "Horizontal":
return factory.fromHorizontalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
default:
throw new Error("Unexpected Gradient Type");
}
};
const children: React.JSX.Element[] = [
<select
value={type}
onChange={(e) =>
setValue(colorsToValue(e.target.value, color1, color2))
}
>
<option value="Transparent">Transparent</option>
<option value="Plain">Plain</option>
<option value="Vertical">Vertical</option>
<option value="Horizontal">Horizontal</option>
</select>,
];
if (color1) {
children.push(
<ColorPicker
color={color1}
setColor={(color) =>
setValue(colorsToValue(type, color, color2))
}
/>,
);
}
if (color2) {
children.push(
<ColorPicker
color={color2}
setColor={(color) =>
setValue(colorsToValue(type, color1, color))
}
/>,
);
}
if (color2) {
return <div className="settings-value-box two-colors">{children}</div>;
} else if (color1) {
return <div className="settings-value-box one-color">{children}</div>;
} else {
return <div className="settings-value-box">{children}</div>;
}
}
export function DeltaGradient<T>({
value,
setValue,
factory,
}: {
value: DeltaGradient;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
let type: string | undefined;
let color1: Color | undefined;
let color2: Color | undefined;
if (typeof value !== "string") {
[type] = Object.keys(value);
if ("Plain" in value) {
color1 = value.Plain;
} else if ("Vertical" in value) {
[color1, color2] = value.Vertical;
} else if ("Horizontal" in value) {
[color1, color2] = value.Horizontal;
} else {
assertNever(value);
}
} else {
type = value;
}
const colorsToValue = (
type: string,
color1: Option<Color>,
color2: Option<Color>,
) => {
color1 = color1 ?? [0, 0, 0, 0];
color2 = color2 ?? color1;
switch (type) {
case "Transparent":
return factory.fromTransparentGradient();
case "Plain":
return factory.fromColor(
color1[0],
color1[1],
color1[2],
color1[3],
);
case "Vertical":
return factory.fromVerticalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
case "Horizontal":
return factory.fromHorizontalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
default:
return expect(
factory.fromDeltaGradient(type),
"Unexpected Gradient Type",
);
}
};
const children: React.JSX.Element[] = [
<select
value={type}
onChange={(e) =>
setValue(colorsToValue(e.target.value, color1, color2))
}
>
<option value="Transparent">Transparent</option>
<option value="Plain">Plain</option>
<option value="Vertical">Vertical</option>
<option value="Horizontal">Horizontal</option>
<option value="DeltaPlain">Plain Delta</option>
<option value="DeltaVertical">Vertical Delta</option>
<option value="DeltaHorizontal">Horizontal Delta</option>
</select>,
];
if (color1) {
children.push(
<ColorPicker
color={color1}
setColor={(color) =>
setValue(colorsToValue(type, color, color2))
}
/>,
);
}
if (color2) {
children.push(
<ColorPicker
color={color2}
setColor={(color) =>
setValue(colorsToValue(type, color1, color))
}
/>,
);
}
if (color2) {
return <div className="settings-value-box two-colors">{children}</div>;
} else if (color1) {
return <div className="settings-value-box one-color">{children}</div>;
} else {
return <div className="settings-value-box">{children}</div>;
}
}
export function ListGradient<T>({
value,
setValue,
factory,
}: {
value: ListGradient;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
let type: string | undefined;
let color1: Color | undefined;
let color2: Color | undefined;
if ("Alternating" in value) {
type = Object.keys(value)[0];
[color1, color2] = value.Alternating;
} else {
const gradient = value.Same;
if (gradient !== "Transparent") {
type = Object.keys(gradient)[0];
if ("Plain" in gradient) {
color1 = gradient.Plain;
} else if ("Vertical" in gradient) {
[color1, color2] = gradient.Vertical;
} else if ("Horizontal" in gradient) {
[color1, color2] = gradient.Horizontal;
} else {
assertNever(gradient);
}
} else {
type = "Transparent";
}
}
const colorsToValue = (
type: string,
color1: Option<Color>,
color2: Option<Color>,
) => {
color1 = color1 ?? [0, 0, 0, 0];
color2 = color2 ?? color1;
switch (type) {
case "Transparent":
return factory.fromTransparentGradient();
case "Plain":
return factory.fromColor(
color1[0],
color1[1],
color1[2],
color1[3],
);
case "Vertical":
return factory.fromVerticalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
case "Horizontal":
return factory.fromHorizontalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
case "Alternating":
return factory.fromAlternatingGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
default:
throw new Error("Unexpected Gradient Type");
}
};
const children: React.JSX.Element[] = [
<select
value={type}
onChange={(e) =>
setValue(colorsToValue(e.target.value, color1, color2))
}
>
<option value="Transparent">Transparent</option>
<option value="Plain">Plain</option>
<option value="Vertical">Vertical</option>
<option value="Horizontal">Horizontal</option>
<option value="Alternating">Alternating</option>
</select>,
];
if (color1) {
children.push(
<ColorPicker
color={color1}
setColor={(color) =>
setValue(colorsToValue(type, color, color2))
}
/>,
);
}
if (color2) {
children.push(
<ColorPicker
color={color2}
setColor={(color) =>
setValue(colorsToValue(type, color1, color))
}
/>,
);
}
if (color2) {
return <div className="settings-value-box two-colors">{children}</div>;
} else if (color1) {
return <div className="settings-value-box one-color">{children}</div>;
} else {
return <div className="settings-value-box">{children}</div>;
}
}
@@ -0,0 +1,236 @@
import * as React from "react";
import { Option, assertNever, expect } from "../../../util/OptionUtil";
import { Color, LayoutBackground } from "../../../livesplit-core";
import { ColorPicker } from "../ColorPicker";
import { SettingValueFactory } from ".";
import { UrlCache } from "../../../util/UrlCache";
import { toast } from "react-toastify";
import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../../util/FileUtil";
export function LayoutBackground<T>({
value,
setValue,
factory,
editorUrlCache,
}: {
value: LayoutBackground;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
editorUrlCache: UrlCache;
}) {
let type: string;
let color1: Option<Color> = null;
let color2: Option<Color> = null;
let imageId =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let brightness = 100;
let opacity = 100;
let blur = 0;
const children: React.JSX.Element[] = [];
const colorsToValue = (
type: string,
color1: Option<Color>,
color2: Option<Color>,
) => {
color1 = color1 ?? [0, 0, 0, 0];
color2 = color2 ?? color1;
switch (type) {
case "Transparent":
return factory.fromTransparentGradient();
case "Plain":
return factory.fromColor(
color1[0],
color1[1],
color1[2],
color1[3],
);
case "Vertical":
return factory.fromVerticalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
case "Horizontal":
return factory.fromHorizontalGradient(
color1[0],
color1[1],
color1[2],
color1[3],
color2[0],
color2[1],
color2[2],
color2[3],
);
default:
return expect(
factory.fromBackgroundImage(
imageId,
brightness / 100,
opacity / 100,
blur / 100,
),
"Unexpected layout background",
);
}
};
if (typeof value !== "string") {
[type] = Object.keys(value);
if ("Plain" in value) {
color1 = value.Plain;
} else if ("Vertical" in value) {
[color1, color2] = value.Vertical;
} else if ("Horizontal" in value) {
[color1, color2] = value.Horizontal;
} else if ("image" in value) {
imageId = value.image;
brightness = 100 * value.brightness;
opacity = 100 * value.opacity;
blur = 100 * value.blur;
type = "Image";
const imageUrl = editorUrlCache.cache(imageId);
children.push(
<div
className="color-picker-button"
style={{
background: imageUrl
? `url("${imageUrl}") center / cover`
: undefined,
}}
onClick={async (_) => {
const maybeFile =
await openFileAsArrayBuffer(FILE_EXT_IMAGES);
if (maybeFile === undefined) {
return;
}
if (maybeFile instanceof Error) {
toast.error(
`Failed to read the file: ${maybeFile.message}`,
);
return;
}
const [file] = maybeFile;
const imageId =
editorUrlCache.imageCache.cacheFromArray(
new Uint8Array(file),
true,
);
editorUrlCache.cache(imageId);
const value = expect(
factory.fromBackgroundImage(
imageId,
brightness / 100,
opacity / 100,
blur / 100,
),
"Unexpected layout background",
);
setValue(value);
}}
/>,
<div
style={{
gridTemplateColumns: "max-content 1fr",
columnGap: "8px",
rowGap: "8px",
alignItems: "center",
display: "grid",
gridColumn: "1 / 3",
}}
>
Brightness
<input
type="range"
min="0"
max="100"
value={brightness}
onChange={(e) => {
brightness = Number(e.target.value);
setValue(colorsToValue(type, color1, color2));
}}
/>
Opacity
<input
type="range"
min="0"
max="100"
value={opacity}
onChange={(e) => {
opacity = Number(e.target.value);
setValue(colorsToValue(type, color1, color2));
}}
/>
Blur
<input
type="range"
min="0"
max="100"
value={blur}
onChange={(e) => {
blur = Number(e.target.value);
setValue(colorsToValue(type, color1, color2));
}}
/>
</div>,
);
} else {
assertNever(value);
}
} else {
type = value;
}
children.splice(
0,
0,
<select
value={type}
onChange={(e) => {
setValue(colorsToValue(e.target.value, color1, color2));
}}
>
<option value="Transparent">Transparent</option>
<option value="Plain">Plain</option>
<option value="Vertical">Vertical</option>
<option value="Horizontal">Horizontal</option>
<option value="Image">Image</option>
</select>,
);
if (color1) {
children.push(
<ColorPicker
color={color1}
setColor={(color) => {
setValue(colorsToValue(type, color, color2));
}}
/>,
);
}
if (color2) {
children.push(
<ColorPicker
color={color2}
setColor={(color) => {
setValue(colorsToValue(type, color1, color));
}}
/>,
);
}
if (color2) {
return <div className="settings-value-box two-colors">{children}</div>;
} else if (color1 || type === "Image") {
return <div className="settings-value-box one-color">{children}</div>;
} else {
return <div className="settings-value-box">{children}</div>;
}
}
@@ -0,0 +1,33 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { LayoutDirection } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
export function LayoutDirection<T>({
value,
setValue,
factory,
}: {
value: LayoutDirection;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<select
value={value}
onChange={(e) => {
setValue(
expect(
factory.fromLayoutDirection(e.target.value),
"Unexpected Layout Direction",
),
);
}}
>
<option value="Vertical">Vertical</option>
<option value="Horizontal">Horizontal</option>
</select>
</div>
);
}
@@ -0,0 +1,43 @@
import * as React from "react";
import { Option } from "../../../util/OptionUtil";
import { LiveSplitServer } from "../../../api/LiveSplitServer";
import "../../../css/LiveSplitServerButton.scss";
export function ServerConnectionButton({
value,
connectOrDisconnect,
}: {
value: {
url: string | undefined;
connection: Option<LiveSplitServer>;
};
connectOrDisconnect: () => void;
}) {
return (
<div className="settings-value-box">
<button
className="livesplit-server-button"
onClick={connectOrDisconnect}
>
{(() => {
const connectionState =
value.connection?.getConnectionState() ??
WebSocket.CLOSED;
switch (connectionState) {
case WebSocket.OPEN:
return <div>Disconnect</div>;
case WebSocket.CLOSED:
return <div>Connect</div>;
case WebSocket.CONNECTING:
return <div>Connecting...</div>;
case WebSocket.CLOSING:
return <div>Disconnecting...</div>;
default:
throw new Error("Unknown WebSocket State");
}
})()}
</button>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import * as React from "react";
import { SettingValueFactory } from ".";
import { Trash } from "lucide-react";
import { Switch } from "../Switch";
export function String<T>({
value,
setValue,
factory,
}: {
value: string;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box">
<input
className="text-box"
value={value}
onChange={(e) => setValue(factory.fromString(e.target.value))}
/>
</div>
);
}
export function OptionalString<T>({
value,
setValue,
factory,
}: {
value: string | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
const children = [
<Switch
checked={value !== null}
setIsChecked={(checked) => {
if (checked) {
setValue(factory.fromOptionalString(""));
} else {
setValue(factory.fromOptionalEmptyString());
}
}}
/>,
];
if (value !== null) {
children.push(
<input
className="text-box"
value={value}
onChange={(e) =>
setValue(factory.fromOptionalString(e.target.value))
}
/>,
);
}
return <div className="settings-value-box optional-value">{children}</div>;
}
export function RemovableString<T>({
value,
setValue,
factory,
}: {
value: string | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
return (
<div className="settings-value-box removable-string">
<input
className="text-box"
value={value ?? ""}
onChange={(e) => {
if (factory.fromRemovableString) {
setValue(factory.fromRemovableString(e.target.value));
} else {
throw Error("Method is not implemented");
}
}}
/>
<Trash
className="trash"
strokeWidth={2.5}
size={20}
onClick={() => {
if (factory.fromRemovableEmptyString) {
setValue(factory.fromRemovableEmptyString());
} else {
throw Error("Method is not implemented");
}
}}
/>
</div>
);
}
export function Comparison<T>({
value,
setValue,
factory,
allComparisons,
}: {
value: string | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
allComparisons: string[];
}) {
return (
<div className="settings-value-box">
<select
value={value ?? ""}
onChange={(e) => {
if (e.target.value !== "") {
setValue(factory.fromOptionalString(e.target.value));
} else {
setValue(factory.fromOptionalEmptyString());
}
}}
>
<option value="">Current Comparison</option>
{allComparisons.map((comparison) => (
<option>{comparison}</option>
))}
</select>
</div>
);
}
export function CustomVariable<T>({
value,
setValue,
factory,
allVariables,
}: {
value: string;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
allVariables: Set<string>;
}) {
if (allVariables.size === 0) {
return (
<div className="settings-value-box">
<span className="tooltip" style={{ textAlign: "center" }}>
No variables available
<span className="tooltip-text">
Custom variables can be defined in the Variables tab
when editing splits. Additional custom variables can be
provided automatically by auto splitters.
</span>
</span>
</div>
);
} else {
return (
<div className="settings-value-box">
<select
value={value ?? ""}
onChange={(e) =>
setValue(factory.fromString(e.target.value))
}
>
<option value="" />
{Array.from(allVariables).map((variable) => (
<option>{variable}</option>
))}
</select>
</div>
);
}
}
@@ -0,0 +1,54 @@
import * as React from "react";
import { expect } from "../../../util/OptionUtil";
import { TimingMethodJson } from "../../../livesplit-core";
import { SettingValueFactory } from ".";
import { Switch } from "../Switch";
export function OptionalTimingMethod<T>({
value,
setValue,
factory,
}: {
value: TimingMethodJson | null;
setValue: (value: T) => void;
factory: SettingValueFactory<T>;
}) {
const children = [
<Switch
checked={value !== null}
setIsChecked={(value) => {
if (value) {
setValue(
expect(
factory.fromOptionalTimingMethod("RealTime"),
"Unexpected Optional Timing Method",
),
);
} else {
setValue(factory.fromOptionalEmptyTimingMethod());
}
}}
/>,
];
if (value !== null) {
children.push(
<select
value={value}
onChange={(e) =>
setValue(
expect(
factory.fromOptionalTimingMethod(e.target.value),
"Unexpected Optional Timing Method",
),
)
}
>
<option value="RealTime">Real Time</option>
<option value="GameTime">Game Time</option>
</select>,
);
}
return <div className="settings-value-box optional-value">{children}</div>;
}
+645
View File
@@ -0,0 +1,645 @@
import * as React from "react";
import { SettingsDescriptionValueJson } from "../../../livesplit-core";
import { assertNever, Option } from "../../../util/OptionUtil";
import { HotkeyButton } from "../HotkeyButton";
import { UrlCache } from "../../../util/UrlCache";
import { LiveSplitServer } from "../../../api/LiveSplitServer";
import { showDialog } from "../Dialog";
import { Switch } from "../Switch";
import { ServerConnectionButton } from "./ServerConnectionButton";
import { LayoutBackground } from "./LayoutBackground";
import { Font } from "./Font";
import "../../../css/Tooltip.scss";
import { LayoutDirection } from "./LayoutDirection";
import {
ColumnKind,
ColumnStartWith,
ColumnUpdateWith,
ColumnUpdateTrigger,
} from "./Column";
import { Alignment } from "./Alignment";
import { OptionalTimingMethod } from "./TimingMethod";
import { DeltaGradient, Gradient, ListGradient } from "./Gradient";
import { Color, OptionalColor } from "./Color";
import { DigitsFormat } from "./DigitsFormat";
import { Accuracy } from "./Accuracy";
import {
Comparison,
CustomVariable,
OptionalString,
RemovableString,
String,
} from "./String";
export interface Props<T> {
context: string;
setValue: (index: number, value: T) => void;
state: ExtendedSettingsDescriptionJson;
factory: SettingValueFactory<T>;
editorUrlCache: UrlCache;
allComparisons: string[];
allVariables: Set<string>;
}
export interface ExtendedSettingsDescriptionJson {
fields: ExtendedSettingsDescriptionFieldJson[];
}
export interface ExtendedSettingsDescriptionFieldJson {
text: string | React.JSX.Element;
tooltip: string | React.JSX.Element;
value: ExtendedSettingsDescriptionValueJson;
}
export type ExtendedSettingsDescriptionValueJson =
| SettingsDescriptionValueJson
| { RemovableString: string | null }
| {
ServerConnection: {
url: string | undefined;
connection: Option<LiveSplitServer>;
};
};
export interface SettingValueFactory<T> {
fromBool(v: boolean): T;
fromUint(value: number): T;
fromInt(value: number): T;
fromString(value: string): T;
fromOptionalString(value: string): T;
fromOptionalEmptyString(): T;
fromRemovableString?(value: string): T;
fromRemovableEmptyString?(): T;
fromAccuracy(value: string): T | null;
fromDigitsFormat(value: string): T | null;
fromOptionalTimingMethod(value: string): T | null;
fromOptionalEmptyTimingMethod(): T;
fromColor(r: number, g: number, b: number, a: number): T;
fromOptionalColor(r: number, g: number, b: number, a: number): T;
fromOptionalEmptyColor(): T;
fromTransparentGradient(): T;
fromVerticalGradient(
r1: number,
g1: number,
b1: number,
a1: number,
r2: number,
g2: number,
b2: number,
a2: number,
): T;
fromHorizontalGradient(
r1: number,
g1: number,
b1: number,
a1: number,
r2: number,
g2: number,
b2: number,
a2: number,
): T;
fromAlternatingGradient(
r1: number,
g1: number,
b1: number,
a1: number,
r2: number,
g2: number,
b2: number,
a2: number,
): T;
fromAlignment(value: string): T | null;
fromColumnKind(value: string): T | null;
fromColumnStartWith(value: string): T | null;
fromColumnUpdateWith(value: string): T | null;
fromColumnUpdateTrigger(value: string): T | null;
fromLayoutDirection(value: string): T | null;
fromFont(
name: string,
style: string,
weight: string,
stretch: string,
): T | null;
fromEmptyFont(): T;
fromDeltaGradient(value: string): T | null;
fromBackgroundImage(
imageId: string,
brightness: number,
opacity: number,
blur: number,
): T | null;
}
export class JsonSettingValueFactory
implements SettingValueFactory<ExtendedSettingsDescriptionValueJson>
{
public fromBool(v: boolean): ExtendedSettingsDescriptionValueJson {
return { Bool: v };
}
public fromUint(_: number): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromInt(_: number): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromString(v: string): ExtendedSettingsDescriptionValueJson {
return { String: v };
}
public fromOptionalString(_: string): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromOptionalEmptyString(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromRemovableString(
v: string,
): ExtendedSettingsDescriptionValueJson {
return { RemovableString: v };
}
public fromRemovableEmptyString(): ExtendedSettingsDescriptionValueJson {
return { RemovableString: null };
}
public fromAccuracy(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromDigitsFormat(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromOptionalTimingMethod(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromOptionalEmptyTimingMethod(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromColor(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromOptionalColor(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromOptionalEmptyColor(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromTransparentGradient(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromVerticalGradient(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromHorizontalGradient(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromAlternatingGradient(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromAlignment(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromColumnKind(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromColumnStartWith(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromColumnUpdateWith(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromColumnUpdateTrigger(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromLayoutDirection(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromFont(
_name: string,
_style: string,
_weight: string,
_stretch: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromEmptyFont(): ExtendedSettingsDescriptionValueJson {
throw new Error("Not implemented");
}
public fromDeltaGradient(
_: string,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
public fromBackgroundImage(
_imageId: string,
_brightness: number,
_opacity: number,
_blur: number,
): ExtendedSettingsDescriptionValueJson | null {
throw new Error("Not implemented");
}
}
export class SettingsComponent<T> extends React.Component<Props<T>> {
public render() {
const settingsRows: React.JSX.Element[] = [];
const { factory } = this.props;
this.props.state.fields.forEach((field, valueIndex) => {
const { value } = field;
let component;
if ("Bool" in value) {
component = (
<div className="settings-value-box">
<Switch
checked={value.Bool}
setIsChecked={(value) => {
this.props.setValue(
valueIndex,
factory.fromBool(value),
);
}}
/>
</div>
);
} else if ("UInt" in value) {
component = (
<div className="settings-value-box">
<input
type="number"
className="number text-box"
value={value.UInt}
min="0"
onChange={(e) => {
this.props.setValue(
valueIndex,
factory.fromUint(e.target.valueAsNumber),
);
}}
/>
</div>
);
} else if ("Int" in value) {
component = (
<div className="settings-value-box">
<input
type="number"
className="number text-box"
value={value.Int}
onChange={(e) => {
this.props.setValue(
valueIndex,
factory.fromInt(e.target.valueAsNumber),
);
}}
/>
</div>
);
} else if ("String" in value) {
// FIXME: This is a hack that we need for now until the way
// settings are represented is refactored.
if (
typeof field.text === "string" &&
/^Variable/.test(field.text)
) {
component = (
<CustomVariable
value={value.String}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
allVariables={this.props.allVariables}
/>
);
} else {
component = (
<String
value={value.String}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
}
} else if ("OptionalString" in value) {
// FIXME: This is a hack that we need for now until the way
// settings are represented is refactored.
if (
typeof field.text === "string" &&
/^Comparison( \d)?$/.test(field.text)
) {
component = (
<Comparison
allComparisons={this.props.allComparisons}
value={value.OptionalString}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else {
component = (
<OptionalString
value={value.OptionalString}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
}
} else if ("RemovableString" in value) {
component = (
<RemovableString
value={value.RemovableString}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("Accuracy" in value) {
component = (
<Accuracy
value={value.Accuracy}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("DigitsFormat" in value) {
component = (
<DigitsFormat
value={value.DigitsFormat}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("Color" in value) {
component = (
<Color
value={value.Color}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("OptionalColor" in value) {
component = (
<OptionalColor
value={value.OptionalColor}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("Gradient" in value) {
component = (
<Gradient
value={value.Gradient}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("ListGradient" in value) {
component = (
<ListGradient
value={value.ListGradient}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("OptionalTimingMethod" in value) {
component = (
<OptionalTimingMethod
value={value.OptionalTimingMethod}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("Alignment" in value) {
component = (
<Alignment
value={value.Alignment}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("ColumnKind" in value) {
component = (
<ColumnKind
value={value.ColumnKind}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("ColumnStartWith" in value) {
component = (
<ColumnStartWith
value={value.ColumnStartWith}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("ColumnUpdateWith" in value) {
component = (
<ColumnUpdateWith
value={value.ColumnUpdateWith}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("ColumnUpdateTrigger" in value) {
component = (
<ColumnUpdateTrigger
value={value.ColumnUpdateTrigger}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("CustomCombobox" in value) {
const isError =
value.CustomCombobox.mandatory &&
!value.CustomCombobox.value;
component = (
<div className="settings-value-box">
<select
value={value.CustomCombobox.value}
onChange={(e) => {
this.props.setValue(
valueIndex,
factory.fromString(e.target.value),
);
}}
style={{
border: isError
? "1px solid rgb(255, 0, 0)"
: undefined,
}}
>
{value.CustomCombobox.list.map((v) => (
<option value={v}>{v}</option>
))}
</select>
</div>
);
} else if ("Hotkey" in value) {
component = (
<div className="settings-value-box">
<HotkeyButton
value={value.Hotkey}
setValue={(value) => {
if (value != null) {
this.props.setValue(
valueIndex,
factory.fromOptionalString(value),
);
} else {
this.props.setValue(
valueIndex,
factory.fromOptionalEmptyString(),
);
}
}}
/>
</div>
);
} else if ("LayoutDirection" in value) {
component = (
<LayoutDirection
value={value.LayoutDirection}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("Font" in value) {
component = (
<Font
value={value.Font}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
loadedCallback={() => this.setState({})}
/>
);
} else if ("DeltaGradient" in value) {
component = (
<DeltaGradient
value={value.DeltaGradient}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
/>
);
} else if ("LayoutBackground" in value) {
component = (
<LayoutBackground
value={value.LayoutBackground}
setValue={(value) =>
this.props.setValue(valueIndex, value)
}
factory={this.props.factory}
editorUrlCache={this.props.editorUrlCache}
/>
);
} else if ("ServerConnection" in value) {
component = (
<ServerConnectionButton
value={value.ServerConnection}
connectOrDisconnect={() =>
this.connectToServerOrDisconnect(
valueIndex,
value.ServerConnection.url,
value.ServerConnection.connection,
)
}
/>
);
} else {
assertNever(value);
}
settingsRows.push(
<tr key={`${this.props.context}$${valueIndex}`}>
<td className="tooltip">
{field.text}
<span className="tooltip-text">{field.tooltip}</span>
</td>
<td>{component}</td>
</tr>,
);
});
return (
<table className="table settings-table">
<tbody className="table-body">{settingsRows}</tbody>
</table>
);
}
// FIXME: Move to the component if possible.
private async connectToServerOrDisconnect(
valueIndex: number,
serverUrl: string | undefined,
connection: Option<LiveSplitServer>,
) {
if (connection) {
connection.close();
return;
}
const [result, url] = await showDialog({
title: "Connect to Server",
description: "Specify the WebSocket URL:",
textInput: true,
defaultText: serverUrl,
buttons: ["Connect", "Cancel"],
});
if (result !== 0) {
return;
}
this.props.setValue(valueIndex, this.props.factory.fromString(url));
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import React from "react";
import * as classes from "../../css/Switch.module.scss";
export default function Switch({
export function Switch({
checked,
setIsChecked,
}: {
+46 -43
View File
@@ -1,53 +1,56 @@
import * as React from "react";
export interface Props {
import * as classes from "../../css/TextBox.module.scss";
export function TextBox({
className,
value,
onChange,
onBlur,
label,
invalid,
list,
}: {
className?: string;
value?: any;
onChange?: React.EventHandler<React.ChangeEvent<HTMLInputElement>>;
onBlur?: React.EventHandler<React.FocusEvent<HTMLInputElement>>;
value?: string | number | readonly string[];
onChange?: React.ChangeEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
label: string;
invalid?: boolean;
small?: boolean;
list?: [string, string[]];
}
}) {
let outerClassName = classes.group;
if (invalid) {
outerClassName += ` ${classes.invalid}`;
}
export class TextBox extends React.Component<Props> {
public render() {
let className = "group";
if (this.props.invalid) {
className += " invalid";
}
if (this.props.small) {
className += " small";
}
let name;
let list;
if (this.props.list !== undefined) {
name = this.props.list[0];
list = (
<datalist id={name}>
{this.props.list[1].map((n, i) => (
<option key={i} value={n} />
))}
</datalist>
);
}
return (
<div className={className}>
<input
list={name}
type="text text-box"
required
className={this.props.className}
value={this.props.value}
onChange={this.props.onChange}
onBlur={this.props.onBlur}
/>
{list}
<span className="bar"></span>
<label>{this.props.label}</label>
</div>
let name;
let listElement;
if (list !== undefined) {
name = list[0];
listElement = (
<datalist id={name}>
{list[1].map((n, i) => (
<option key={i} value={n} />
))}
</datalist>
);
}
return (
<div className={outerClassName}>
<input
list={name}
type="text text-box"
required
className={className}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
{listElement}
<span className={classes.bar}></span>
<label>{label}</label>
</div>
);
}
+79 -89
View File
@@ -5,11 +5,7 @@ import { Markdown } from "../../util/Markdown";
import { ArrowLeft } from "lucide-react";
import * as variables from "../../css/variables.icss.scss";
import "../../css/About.scss";
export interface Props {
callbacks: Callbacks;
}
import * as classes from "../../css/About.module.scss";
interface Callbacks {
renderViewWithSidebar(
@@ -21,96 +17,90 @@ interface Callbacks {
const contributorAvatarSize = parseFloat(variables.contributorAvatarSize);
export class About extends React.Component<Props> {
public render() {
const renderedView = this.renderView();
const sidebarContent = this.renderSidebarContent();
return this.props.callbacks.renderViewWithSidebar(
renderedView,
sidebarContent,
);
}
export function About({ callbacks }: { callbacks: Callbacks }) {
const renderedView = renderView();
const sidebarContent = renderSidebarContent(callbacks);
return callbacks.renderViewWithSidebar(renderedView, sidebarContent);
}
private renderView() {
const idealAvatarResolution = Math.round(
devicePixelRatio * contributorAvatarSize,
);
function renderView() {
const idealAvatarResolution = Math.round(
devicePixelRatio * contributorAvatarSize,
);
return (
<div className="about">
<div className="about-inner-container">
<div className="livesplit-title">
<span className="livesplit-icon">
<img src={LiveSplitIcon} alt="LiveSplit Logo" />
</span>
<div className="title-text">LiveSplit One</div>
</div>
<p className="build-version">
<a
href={`https://github.com/LiveSplit/LiveSplitOne/commit/${COMMIT_HASH}`}
target="_blank"
>
Version: {BUILD_DATE}
</a>
</p>
<p>
LiveSplit One is a multiplatform version of LiveSplit,
the sleek, highly-customizable timer for speedrunners.
</p>
<p>
<a
href="https://github.com/LiveSplit/LiveSplitOne"
target="_blank"
>
View Source Code on GitHub
</a>
</p>
<h2>Recent Changes</h2>
<div className="changelog">
{CHANGELOG.map((change) => (
<>
<a
href={`https://github.com/LiveSplit/LiveSplitOne/commit/${change.id}`}
target="_blank"
>
{change.date}
</a>
<Markdown
markdown={change.message}
unsafe={true}
/>
</>
))}
</div>
<h2>Contributors</h2>
<div className="contributors">
{CONTRIBUTORS_LIST.map((contributor) => (
return (
<div className={classes.about}>
<div className={classes.aboutInnerContainer}>
<div className={classes.livesplitTitle}>
<img
className={classes.livesplitIcon}
src={LiveSplitIcon}
alt="LiveSplit Logo"
/>
<div className={classes.titleText}>LiveSplit One</div>
</div>
<p className={classes.buildVersion}>
<a
href={`https://github.com/LiveSplit/LiveSplitOne/commit/${COMMIT_HASH}`}
target="_blank"
>
Version: {BUILD_DATE}
</a>
</p>
<p>
LiveSplit One is a multiplatform version of LiveSplit, the
sleek, highly-customizable timer for speedrunners.
</p>
<p>
<a
href="https://github.com/LiveSplit/LiveSplitOne"
target="_blank"
>
View Source Code on GitHub
</a>
</p>
<h2>Recent Changes</h2>
<div className={classes.changelog}>
{CHANGELOG.map((change) => (
<>
<a
href={`https://github.com/${contributor.name}`}
href={`https://github.com/LiveSplit/LiveSplitOne/commit/${change.id}`}
target="_blank"
>
<img
src={`https://avatars.githubusercontent.com/u/${contributor.id}?s=${idealAvatarResolution}&v=4`}
onError={(e) => (e.target as any).remove()}
/>
{contributor.name}
{change.date}
</a>
))}
</div>
<Markdown markdown={change.message} unsafe={true} />
</>
))}
</div>
<h2>Contributors</h2>
<div className={classes.contributors}>
{CONTRIBUTORS_LIST.map((contributor) => (
<a
href={`https://github.com/${contributor.name}`}
target="_blank"
>
<img
src={`https://avatars.githubusercontent.com/u/${contributor.id}?s=${idealAvatarResolution}&v=4`}
onError={(e) => (e.target as any).remove()}
/>
{contributor.name}
</a>
))}
</div>
</div>
);
}
private renderSidebarContent() {
return (
<div className="sidebar-buttons">
<h1>About</h1>
<hr />
<button onClick={(_) => this.props.callbacks.openTimerView()}>
<ArrowLeft strokeWidth={2.5} /> Back
</button>
</div>
);
}
</div>
);
}
function renderSidebarContent(callbacks: Callbacks) {
return (
<div className="sidebar-buttons">
<h1>About</h1>
<hr />
<button onClick={(_) => callbacks.openTimerView()}>
<ArrowLeft strokeWidth={2.5} /> Back
</button>
</div>
);
}
-2
View File
@@ -212,7 +212,6 @@ export class RunEditor extends React.Component<Props, State> {
value={this.state.editor.offset}
onChange={(e) => this.handleOffsetChange(e)}
onBlur={(_) => this.handleOffsetBlur()}
small
invalid={!this.state.offsetIsValid}
label="Start Timer At"
/>
@@ -225,7 +224,6 @@ export class RunEditor extends React.Component<Props, State> {
this.handleAttemptsChange(e)
}
onBlur={(_) => this.handleAttemptsBlur()}
small
invalid={!this.state.attemptCountIsValid}
label="Attempts"
/>
+1 -1
View File
@@ -17,7 +17,7 @@ import {
FILE_EXT_SPLITS,
} from "../../util/FileUtil";
import { Option, bug, maybeDisposeAndThen } from "../../util/OptionUtil";
import DragUpload from "../components/DragUpload";
import { DragUpload } from "../components/DragUpload";
import { GeneralSettings } from "./MainSettings";
import { LSOCommandSink } from "../LSOCommandSink";
import { showDialog } from "../components/Dialog";
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "../../livesplit-core";
import * as LiveSplit from "../../livesplit-core";
import { Option, expect } from "../../util/OptionUtil";
import DragUpload from "../components/DragUpload";
import { DragUpload } from "../components/DragUpload";
import Layout from "../components/Layout";
import { UrlCache } from "../../util/UrlCache";
import { WebRenderer } from "../../livesplit-core/livesplit_core";
@@ -119,7 +119,7 @@ export class TimerView extends React.Component<Props, State> {
}
}}
style={{
display: "inline-block",
width: "fit-content",
cursor: this.props.generalSettings
.showControlButtons
? "pointer"
+40 -32
View File
@@ -5,55 +5,63 @@ import {
batteryAwareFrameRate,
} from "./FrameRate";
export interface Props {
interface State {
reqId?: number;
previousTime: number;
frameRate: FrameRateSetting;
update(): void;
children: React.ReactNode;
update: () => void;
}
export default class AutoRefresh extends React.Component<Props> {
private reqId?: number;
private previousTime: number = 0;
export default function AutoRefresh({
frameRate,
update,
children,
}: {
frameRate: FrameRateSetting;
update: () => void;
children: React.ReactNode;
}) {
const { current: state } = React.useRef<State>({
previousTime: 0,
frameRate,
update,
});
state.frameRate = frameRate;
state.update = update;
public componentDidMount() {
this.startAnimation();
}
const animate = React.useCallback(() => {
state.reqId = requestAnimationFrame(animate);
public componentWillUnmount() {
if (this.reqId) {
cancelAnimationFrame(this.reqId);
}
}
public render() {
return this.props.children;
}
private startAnimation() {
this.previousTime = 0;
this.animate();
}
private animate() {
this.reqId = requestAnimationFrame(() => this.animate());
let frameRate = this.props.frameRate;
let frameRate = state.frameRate;
if (frameRate === FRAME_RATE_AUTOMATIC) {
frameRate = batteryAwareFrameRate;
}
if (typeof frameRate === "number") {
const currentTime = performance.now();
const elapsed = currentTime - this.previousTime;
const elapsed = currentTime - state.previousTime;
const refreshInterval = 1000 / frameRate;
if (elapsed < refreshInterval) {
return;
}
this.previousTime = currentTime - (elapsed % refreshInterval);
state.previousTime = currentTime - (elapsed % refreshInterval);
}
this.props.update();
}
state.update();
}, []);
React.useEffect(() => {
state.previousTime = 0;
animate();
return () => {
if (state.reqId) {
cancelAnimationFrame(state.reqId);
}
};
}, []);
return children;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import React from "react";
import * as React from "react";
import { toast } from "react-toastify";
export type Option<T> = T | null | undefined;