Merge pull request #1030 from CryZe/reorganizing

Do some Initial Refactoring of the Code

1. This splits up the UI code into a separate views and components folder to clarify the purpose of each file.

2. This refactors some code to use CSS modules, functional React components and splits up the Settings component into roughly one component per setting.

This is just the beginning of more code refactoring.
This commit is contained in:
Christopher Serr
2025-03-26 21:35:35 +01:00
committed by GitHub
49 changed files with 3042 additions and 2750 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;
}
}
-114
View File
@@ -1,114 +0,0 @@
import * as React from "react";
import { ResizableBox } from "react-resizable";
import { LayoutStateRef } from "../livesplit-core";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import AutoRefresh from "../util/AutoRefresh";
import { UrlCache } from "../util/UrlCache";
import "../css/Layout.scss";
import { GeneralSettings } from "../ui/MainSettings";
export interface Props {
getState: () => LayoutStateRef;
layoutUrlCache: UrlCache;
allowResize: boolean;
width: number;
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(
layoutState.ptr,
this.props.layoutUrlCache.imageCache.ptr,
);
if (newDims !== undefined) {
this.props.onResize(newDims[0], newDims[1]);
}
}
public render() {
return (
<AutoRefresh
frameRate={this.props.generalSettings.frameRate}
update={() => this.refreshLayout()}
>
<div
className="layout"
style={{
width: this.props.width,
height: this.props.height,
}}
>
<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>
);
}
}
@@ -4,7 +4,7 @@ import { RunRef, Run, TimingMethod } from "../livesplit-core";
import {
GeneralSettings,
MANUAL_GAME_TIME_SETTINGS_DEFAULT,
} from "../ui/MainSettings";
} from "../ui/views/MainSettings";
import { FRAME_RATE_AUTOMATIC } from "../util/FrameRate";
export type HotkeyConfigSettings = unknown;
-116
View File
@@ -1,116 +0,0 @@
import * as React from "react";
import LiveSplitIcon from "../assets/icon.svg";
import { Markdown } from "../util/Markdown";
import * as variables from "../css/variables.icss.scss";
import { ArrowLeft } from "lucide-react";
import "../css/About.scss";
export interface Props {
callbacks: Callbacks;
}
interface Callbacks {
renderViewWithSidebar(
renderedView: React.JSX.Element,
sidebarContent: React.JSX.Element,
): React.JSX.Element;
openTimerView(): void;
}
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,
);
}
private 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) => (
<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>
</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>
);
}
}
-137
View File
@@ -1,137 +0,0 @@
import * as React from "react";
import "../css/Dialog.scss";
export interface Props {
onShow: () => void;
onClose: () => void;
}
export interface Options {
title: string | React.JSX.Element;
description: string | React.JSX.Element;
textInput?: boolean;
defaultText?: string;
buttons: string[];
}
export interface State {
options: Options;
input: string;
}
let dialogElement: HTMLDialogElement | null = null;
let setState: ((options: Options) => void) | undefined;
let resolveFn: ((_: [number, string]) => void) | undefined;
let onCloseFn: (() => void) | undefined;
let alreadyClosed = false;
export function showDialog(options: Options): Promise<[number, string]> {
if (dialogElement) {
dialogElement.showModal();
alreadyClosed = false;
dialogElement.setAttribute("disabled", "");
const closeWith = options.buttons.length - 1;
dialogElement.onclose = () => {
if (!alreadyClosed) {
resolveFn?.([closeWith, ""]);
onCloseFn?.();
}
};
}
setState?.(options);
return new Promise((resolve) => (resolveFn = resolve));
}
export default class DialogContainer extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
onCloseFn = props.onClose;
this.state = {
options: {
title: "",
description: "",
buttons: [],
},
input: "",
};
}
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) {
alreadyClosed = true;
dialogElement?.close();
resolveFn?.([i, this.state.input]);
this.props.onClose();
}
}
-206
View File
@@ -1,206 +0,0 @@
import * as React from "react";
import { Option, map, expect } from "../util/OptionUtil";
import { hotkeySystem } from "./LiveSplit";
import { Circle, Trash } from "lucide-react";
import "../css/HotkeyButton.scss";
function resolveKey(keyCode: string): Promise<string> | string {
return expect(
hotkeySystem,
"The Hotkey System should always be initialized.",
).resolve(keyCode);
}
export interface Props {
value: Option<string>;
setValue: (value: Option<string>) => void;
}
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);
}
}
this.setState({ 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" />
);
}
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>
);
}
private focusButton() {
let listener = this.state.listener;
let intervalHandle = this.state.intervalHandle;
if (listener === null) {
listener = {
handleEvent: (ev: KeyboardEvent) => {
if (ev.repeat) {
return;
}
let text = "";
if (
ev.ctrlKey &&
ev.code !== "ControlLeft" &&
ev.code !== "ControlRight"
) {
text += "Ctrl + ";
}
if (
ev.altKey &&
ev.code !== "AltLeft" &&
ev.code !== "AltRight"
) {
text += "Alt + ";
}
if (
ev.metaKey &&
ev.code !== "MetaLeft" &&
ev.code !== "MetaRight"
) {
text += "Meta + ";
}
if (
ev.shiftKey &&
ev.code !== "ShiftLeft" &&
ev.code !== "ShiftRight"
) {
text += "Shift + ";
}
text += ev.code;
this.props.setValue(text);
ev.preventDefault();
},
};
window.addEventListener("keydown", listener);
}
if (intervalHandle === null) {
const oldButtonState: boolean[][] = [];
intervalHandle = window.setInterval(() => {
const gamepads = navigator.getGamepads();
let gamepadIdx = 0;
for (const gamepad of gamepads) {
if (gamepadIdx >= oldButtonState.length) {
oldButtonState[gamepadIdx] = [];
}
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}`);
}
oldButtonState[gamepadIdx][buttonIdx] =
button.pressed;
buttonIdx++;
}
}
gamepadIdx++;
}
}, 1000 / 60.0);
}
this.setState({
listener,
intervalHandle,
});
}
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,
});
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ import {
} from "../livesplit-core";
import { WebCommandSink } from "../livesplit-core/livesplit_core";
import { assert } from "../util/OptionUtil";
import { showDialog } from "./Dialog";
import { showDialog } from "./components/Dialog";
interface Callbacks {
handleEvent(event: Event): void;
+9 -9
View File
@@ -28,23 +28,23 @@ import {
maybeDisposeAndThen,
panic,
} from "../util/OptionUtil";
import { LayoutEditor as LayoutEditorComponent } from "./LayoutEditor";
import { RunEditor as RunEditorComponent } from "./RunEditor";
import { LayoutEditor as LayoutEditorComponent } from "./views/LayoutEditor";
import { RunEditor as RunEditorComponent } from "./views/RunEditor";
import {
GeneralSettings,
MainSettings as SettingsEditorComponent,
} from "./MainSettings";
import { TimerView } from "./TimerView";
import { About } from "./About";
import { SplitsSelection, EditingInfo } from "./SplitsSelection";
import { LayoutView } from "./LayoutView";
} from "./views/MainSettings";
import { TimerView } from "./views/TimerView";
import { About } from "./views/About";
import { SplitsSelection, EditingInfo } from "./views/SplitsSelection";
import { LayoutView } from "./views/LayoutView";
import { ToastContainer, toast } from "react-toastify";
import * as Storage from "../storage";
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 "./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
-1752
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
import * as React from "react";
export interface Props {
className?: string;
value?: any;
onChange?: React.EventHandler<React.ChangeEvent<HTMLInputElement>>;
onBlur?: React.EventHandler<React.FocusEvent<HTMLInputElement>>;
label: string;
invalid?: boolean;
small?: boolean;
list?: [string, string[]];
}
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>
);
}
}
@@ -1,9 +1,9 @@
import React, { ChangeEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { Color } from "../livesplit-core";
import { Color } from "../../livesplit-core";
import { Pipette } from "lucide-react";
import * as classes from "../css/ColorPicker.module.scss";
import * as classes from "../../css/ColorPicker.module.scss";
const EyeDropper = (window as any).EyeDropper;
const hasEyeDropper = !!EyeDropper;
@@ -17,7 +17,7 @@ function colorToCss(color: Color): string {
return `rgba(${r},${g},${b},${a})`;
}
export default function ColorPicker({
export function ColorPicker({
color,
setColor,
}: {
@@ -1,7 +1,7 @@
import React, { createContext, useContext, ReactNode } from "react";
import { expect } from "../util/OptionUtil";
import { expect } from "../../util/OptionUtil";
import * as classes from "../css/ContextMenu.module.css";
import * as classes from "../../css/ContextMenu.module.css";
export interface Position {
x: number;
+124
View File
@@ -0,0 +1,124 @@
import React, { useState, useEffect } from "react";
import * as classes from "../../css/Dialog.module.scss";
export interface Options {
title: string | React.JSX.Element;
description: string | React.JSX.Element;
textInput?: boolean;
defaultText?: string;
buttons: string[];
}
export interface State {
options: Options;
input: string;
}
let dialogElement: HTMLDialogElement | null = null;
let setStateFn: ((options: Options) => void) | undefined;
let resolveFn: ((_: [number, string]) => void) | undefined;
let onCloseFn: (() => void) | undefined;
let alreadyClosed = false;
export function showDialog(options: Options): Promise<[number, string]> {
if (dialogElement) {
dialogElement.showModal();
alreadyClosed = false;
dialogElement.setAttribute("disabled", "");
const closeWith = options.buttons.length - 1;
dialogElement.onclose = () => {
if (!alreadyClosed) {
resolveFn?.([closeWith, ""]);
onCloseFn?.();
}
};
}
setStateFn?.(options);
return new Promise((resolve) => (resolveFn = resolve));
}
export function DialogContainer({
onShow,
onClose,
}: {
onShow: () => void;
onClose: () => void;
}) {
const [options, setOptions] = useState<Options>({
title: "",
description: "",
buttons: [],
});
const [input, setInput] = useState("");
useEffect(() => {
onCloseFn = onClose;
}, [onClose]);
useEffect(() => {
setStateFn = (options) => {
onShow();
setOptions(options);
setInput(options.defaultText ?? "");
};
}, [onShow]);
const handleClose = (i: number) => {
alreadyClosed = true;
dialogElement?.close();
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>
);
}
@@ -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>
);
}
+177
View File
@@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useState } from "react";
import { Option, expect } from "../../util/OptionUtil";
import { hotkeySystem } from "../LiveSplit";
import { Circle, Trash } from "lucide-react";
import * as classes from "../../css/HotkeyButton.module.scss";
function resolveKey(keyCode: string): Promise<string> | string {
return expect(
hotkeySystem,
"The Hotkey System should always be initialized.",
).resolve(keyCode);
}
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);
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);
}
}
setResolvedKey(resolvedKey);
},
[],
);
useEffect(() => {
updateResolvedKey(value);
}, [value, updateResolvedKey]);
const focusButton = () => {
let newListener = listener;
let newIntervalHandle = intervalHandle;
if (newListener == null) {
newListener = {
handleEvent: (ev: KeyboardEvent) => {
if (ev.repeat) {
return;
}
let text = "";
if (
ev.ctrlKey &&
ev.code !== "ControlLeft" &&
ev.code !== "ControlRight"
) {
text += "Ctrl + ";
}
if (
ev.altKey &&
ev.code !== "AltLeft" &&
ev.code !== "AltRight"
) {
text += "Alt + ";
}
if (
ev.metaKey &&
ev.code !== "MetaLeft" &&
ev.code !== "MetaRight"
) {
text += "Meta + ";
}
if (
ev.shiftKey &&
ev.code !== "ShiftLeft" &&
ev.code !== "ShiftRight"
) {
text += "Shift + ";
}
text += ev.code;
setValue(text);
ev.preventDefault();
},
};
window.addEventListener("keydown", newListener);
}
if (newIntervalHandle == null) {
const oldButtonState: boolean[][] = [];
newIntervalHandle = window.setInterval(() => {
const gamepads = navigator.getGamepads();
let gamepadIdx = 0;
for (const gamepad of gamepads) {
if (gamepadIdx >= oldButtonState.length) {
oldButtonState[gamepadIdx] = [];
}
if (gamepad != null) {
let buttonIdx = 0;
for (const button of gamepad.buttons) {
const oldState =
oldButtonState[gamepadIdx]?.[buttonIdx] ??
false;
if (button.pressed && !oldState) {
setValue(`Gamepad${buttonIdx}`);
}
oldButtonState[gamepadIdx][buttonIdx] =
button.pressed;
buttonIdx++;
}
}
gamepadIdx++;
}
}, 1000 / 60.0);
}
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" />;
}
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>
);
}
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react";
import { ResizableBox } from "react-resizable";
import { LayoutStateRef } from "../../livesplit-core";
import { WebRenderer } from "../../livesplit-core/livesplit_core";
import AutoRefresh from "../../util/AutoRefresh";
import { UrlCache } from "../../util/UrlCache";
import { GeneralSettings } from "../views/MainSettings";
import * as classes from "../../css/Layout.module.scss";
export default function Layout({
getState,
layoutUrlCache,
allowResize,
width,
height,
generalSettings,
renderer,
onResize,
}: {
getState: () => LayoutStateRef;
layoutUrlCache: UrlCache;
allowResize: boolean;
width: number;
height: number;
generalSettings: GeneralSettings;
renderer: WebRenderer;
onResize: (width: number, height: number) => void;
}) {
const update = () => {
const layoutState = getState();
const newDims = renderer.render(
layoutState.ptr,
layoutUrlCache.imageCache.ptr,
);
if (newDims != null) {
onResize(newDims[0], newDims[1]);
}
};
return (
<AutoRefresh frameRate={generalSettings.frameRate} update={update}>
<div style={{ width, height }}>
<div
style={{ width, height }}
ref={(element) => {
element?.appendChild(renderer.element());
}}
/>
{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>
);
}
+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,8 +1,8 @@
import React from "react";
import * as classes from "../css/Switch.module.scss";
import * as classes from "../../css/Switch.module.scss";
export default function Switch({
export function Switch({
checked,
setIsChecked,
}: {
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import * as classes from "../../css/TextBox.module.scss";
export function TextBox({
className,
value,
onChange,
onBlur,
label,
invalid,
list,
}: {
className?: string;
value?: string | number | readonly string[];
onChange?: React.ChangeEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
label: string;
invalid?: boolean;
list?: [string, string[]];
}) {
let outerClassName = classes.group;
if (invalid) {
outerClassName += ` ${classes.invalid}`;
}
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>
);
}
+106
View File
@@ -0,0 +1,106 @@
import * as React from "react";
import LiveSplitIcon from "../../assets/icon.svg";
import { Markdown } from "../../util/Markdown";
import { ArrowLeft } from "lucide-react";
import * as variables from "../../css/variables.icss.scss";
import * as classes from "../../css/About.module.scss";
interface Callbacks {
renderViewWithSidebar(
renderedView: React.JSX.Element,
sidebarContent: React.JSX.Element,
): React.JSX.Element;
openTimerView(): void;
}
const contributorAvatarSize = parseFloat(variables.contributorAvatarSize);
export function About({ callbacks }: { callbacks: Callbacks }) {
const renderedView = renderView();
const sidebarContent = renderSidebarContent(callbacks);
return callbacks.renderViewWithSidebar(renderedView, sidebarContent);
}
function renderView() {
const idealAvatarResolution = Math.round(
devicePixelRatio * contributorAvatarSize,
);
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/LiveSplit/LiveSplitOne/commit/${change.id}`}
target="_blank"
>
{change.date}
</a>
<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>
</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>
);
}
@@ -1,15 +1,20 @@
import React, { useState } from "react";
import * as LiveSplit from "../livesplit-core";
import { SettingsComponent } from "./Settings";
import { UrlCache } from "../util/UrlCache";
import Layout from "../layout/Layout";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import * as LiveSplit from "../../livesplit-core";
import { SettingsComponent } from "../components/Settings";
import { UrlCache } from "../../util/UrlCache";
import Layout from "../components/Layout";
import { WebRenderer } from "../../livesplit-core/livesplit_core";
import { GeneralSettings } from "./MainSettings";
import { LSOCommandSink } from "./LSOCommandSink";
import { ContextMenu, MenuItem, Separator, Position } from "./ContextMenu";
import { LSOCommandSink } from "../LSOCommandSink";
import {
ContextMenu,
MenuItem,
Separator,
Position,
} from "../components/ContextMenu";
import { ArrowDown, ArrowUp, Check, Copy, Plus, Trash, X } from "lucide-react";
import "../css/LayoutEditor.scss";
import "../../css/LayoutEditor.scss";
export interface Props {
editor: LiveSplit.LayoutEditor;
@@ -4,14 +4,14 @@ import {
LayoutStateRefMut,
TimerPhase,
TimingMethod,
} from "../livesplit-core";
} from "../../livesplit-core";
import { TimerView } from "./TimerView";
import { UrlCache } from "../util/UrlCache";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import { UrlCache } from "../../util/UrlCache";
import { WebRenderer } from "../../livesplit-core/livesplit_core";
import { GeneralSettings } from "./MainSettings";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { Option } from "../util/OptionUtil";
import { LSOCommandSink } from "./LSOCommandSink";
import { LiveSplitServer } from "../../api/LiveSplitServer";
import { Option } from "../../util/OptionUtil";
import { LSOCommandSink } from "../LSOCommandSink";
import {
ArrowLeft,
Circle,
@@ -1,24 +1,27 @@
import * as React from "react";
import { JsonSettingValueFactory, SettingsComponent } from "./Settings";
import {
JsonSettingValueFactory,
SettingsComponent,
} from "../components/Settings";
import {
SettingsDescriptionJson,
SettingValue,
HotkeyConfig,
} from "../livesplit-core";
} from "../../livesplit-core";
import { toast } from "react-toastify";
import { UrlCache } from "../util/UrlCache";
import { UrlCache } from "../../util/UrlCache";
import {
FRAME_RATE_AUTOMATIC as FRAME_RATE_BATTERY_AWARE,
FRAME_RATE_MATCH_SCREEN as FRAME_RATE_MATCH_SCREEN,
FrameRateSetting,
} from "../util/FrameRate";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { Option } from "../util/OptionUtil";
import { LSOCommandSink } from "./LSOCommandSink";
} from "../../util/FrameRate";
import { LiveSplitServer } from "../../api/LiveSplitServer";
import { Option } from "../../util/OptionUtil";
import { LSOCommandSink } from "../LSOCommandSink";
import { Check, FlaskConical, X } from "lucide-react";
import "../css/SettingsEditor.scss";
import "../../css/SettingsEditor.scss";
export interface GeneralSettings {
frameRate: FrameRateSetting;
@@ -1,11 +1,11 @@
import * as React from "react";
import * as LiveSplit from "../livesplit-core";
import * as LiveSplit from "../../livesplit-core";
import {
FILE_EXT_IMAGES,
FILE_EXT_SPLITS,
openFileAsArrayBuffer,
} from "../util/FileUtil";
import { TextBox } from "./TextBox";
} from "../../util/FileUtil";
import { TextBox } from "../components/TextBox";
import { toast } from "react-toastify";
import {
downloadGameList,
@@ -25,26 +25,26 @@ import {
gameListLength,
platformListLength,
regionListLength,
} from "../api/GameList";
import { Category, Run, Variable, getRun } from "../api/SpeedrunCom";
import { Option, expect, map } from "../util/OptionUtil";
import { formatLeaderboardTime } from "../util/TimeUtil";
import { resolveEmbed } from "./Embed";
} from "../../api/GameList";
import { Category, Run, Variable, getRun } from "../../api/SpeedrunCom";
import { Option, expect, map } from "../../util/OptionUtil";
import { formatLeaderboardTime } from "../../util/TimeUtil";
import { resolveEmbed } from "../Embed";
import {
SettingsComponent,
JsonSettingValueFactory,
ExtendedSettingsDescriptionFieldJson,
ExtendedSettingsDescriptionValueJson,
} from "./Settings";
import { Markdown, replaceFlag } from "../util/Markdown";
import { UrlCache } from "../util/UrlCache";
} from "../components/Settings";
import { Markdown, replaceFlag } from "../../util/Markdown";
import { UrlCache } from "../../util/UrlCache";
import { GeneralSettings } from "./MainSettings";
import { showDialog } from "./Dialog";
import { corsBustingFetch } from "../platform/CORS";
import { ContextMenu, MenuItem, Position } from "./ContextMenu";
import { showDialog } from "../components/Dialog";
import { corsBustingFetch } from "../../platform/CORS";
import { ContextMenu, MenuItem, Position } from "../components/ContextMenu";
import { Check, X } from "lucide-react";
import "../css/RunEditor.scss";
import "../../css/RunEditor.scss";
export interface Props {
editor: LiveSplit.RunEditor;
@@ -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"
/>
@@ -7,20 +7,20 @@ import {
loadSplits,
storeRunWithoutDisposing,
storeSplitsKey,
} from "../storage";
import { Run, Segment, TimerPhase } from "../livesplit-core";
} from "../../storage";
import { Run, Segment, TimerPhase } from "../../livesplit-core";
import { toast } from "react-toastify";
import {
openFileAsArrayBuffer,
exportFile,
convertFileToArrayBuffer,
FILE_EXT_SPLITS,
} from "../util/FileUtil";
import { Option, bug, maybeDisposeAndThen } from "../util/OptionUtil";
import DragUpload from "./DragUpload";
} from "../../util/FileUtil";
import { Option, bug, maybeDisposeAndThen } from "../../util/OptionUtil";
import { DragUpload } from "../components/DragUpload";
import { GeneralSettings } from "./MainSettings";
import { LSOCommandSink } from "./LSOCommandSink";
import { showDialog } from "./Dialog";
import { LSOCommandSink } from "../LSOCommandSink";
import { showDialog } from "../components/Dialog";
import {
ArrowLeft,
Circle,
@@ -34,7 +34,7 @@ import {
Upload,
} from "lucide-react";
import "../css/SplitsSelection.scss";
import "../../css/SplitsSelection.scss";
export interface EditingInfo {
splitsKey?: number;
@@ -4,19 +4,19 @@ import {
TimeSpan,
LayoutStateRefMut,
TimerPhase,
} from "../livesplit-core";
import * as LiveSplit from "../livesplit-core";
import { Option, expect } from "../util/OptionUtil";
import DragUpload from "./DragUpload";
import Layout from "../layout/Layout";
import { UrlCache } from "../util/UrlCache";
import { WebRenderer } from "../livesplit-core/livesplit_core";
} from "../../livesplit-core";
import * as LiveSplit from "../../livesplit-core";
import { Option, expect } from "../../util/OptionUtil";
import { DragUpload } from "../components/DragUpload";
import Layout from "../components/Layout";
import { UrlCache } from "../../util/UrlCache";
import { WebRenderer } from "../../livesplit-core/livesplit_core";
import {
GeneralSettings,
MANUAL_GAME_TIME_MODE_SEGMENT_TIMES,
} from "./MainSettings";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { LSOCommandSink } from "./LSOCommandSink";
import { LiveSplitServer } from "../../api/LiveSplitServer";
import { LSOCommandSink } from "../LSOCommandSink";
import {
ArrowDown,
ArrowUp,
@@ -30,9 +30,9 @@ import {
X,
} from "lucide-react";
import LiveSplitIcon from "../assets/icon.svg";
import LiveSplitIcon from "../../assets/icon.svg";
import "../css/TimerView.scss";
import "../../css/TimerView.scss";
export interface Props {
isDesktop: boolean;
@@ -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;