From 8b1538408e2a0a40756d7147a7cb9b2dac034f41 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 18 Jun 2024 21:11:24 +0200 Subject: [PATCH] Introduce Custom Dialogs This introduces custom dialogs to LiveSplit One. Previously we've used the builtin browser dialogs that are very limited in what they can do. Additionally this introduces a bunch of smaller changes: - The splits editor is now more optimized and only searches for the game name whenever it or the list of games changes. - The PWA badge now resets when the app is reloaded. - Guests on the leaderboard can now have flags. - If a user encounters a bug, there's now dedicated toast popups that don't disappear and direct the user to the issue tracker. - The Sum of Best cleaner now ensures the run can't be modified while the cleaning is in progress, which would lead to memory corruption, especially with the async nature of the new dialogs. - Cancelling out of opening files doesn't lead to an error anymore. The following changes are introduced by updating `livesplit-core`: - When resizing images / reencoding them as PNG, we now use a higher compression setting. - Invalid timer states are not unrepresentable anymore. --- livesplit-core | 2 +- src/api/GameList.ts | 12 + src/css/Dialog.scss | 52 ++++ src/css/main.scss | 8 + src/index.tsx | 32 +-- src/ui/Dialog.tsx | 103 ++++++++ src/ui/LSOEventSink.ts | 13 +- src/ui/LiveSplit.tsx | 47 ++-- src/ui/RunEditor.tsx | 280 ++++++++++++++------- src/ui/Settings.tsx | 21 +- src/ui/SplitsSelection.tsx | 55 ++-- src/util/FileUtil.ts | 17 +- src/util/{OptionUtil.ts => OptionUtil.tsx} | 20 +- 13 files changed, 511 insertions(+), 151 deletions(-) create mode 100644 src/css/Dialog.scss create mode 100644 src/ui/Dialog.tsx rename src/util/{OptionUtil.ts => OptionUtil.tsx} (69%) diff --git a/livesplit-core b/livesplit-core index 5e48065a8..4ebb4bb6f 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 5e48065a8bc8fb3c93e4499ff0c3f99798d08760 +Subproject commit 4ebb4bb6fc06528e5b9ed935e18191150cee2b33 diff --git a/src/api/GameList.ts b/src/api/GameList.ts index 3f43a7288..ca90fa952 100644 --- a/src/api/GameList.ts +++ b/src/api/GameList.ts @@ -110,6 +110,10 @@ export async function downloadGameInfo(gameName: string): Promise { } } +export function gameListLength(): number { + return gameList.length; +} + export function downloadGameList(): Promise { if (gameListPromise == null) { gameListPromise = (async () => { @@ -127,6 +131,10 @@ export function downloadGameList(): Promise { return gameListPromise; } +export function platformListLength(): number { + return platformList.size; +} + export function downloadPlatformList(): Promise { if (platformListPromise == null) { platformListPromise = (async () => { @@ -139,6 +147,10 @@ export function downloadPlatformList(): Promise { return platformListPromise; } +export function regionListLength(): number { + return regionList.size; +} + export function downloadRegionList(): Promise { if (regionListPromise == null) { regionListPromise = (async () => { diff --git a/src/css/Dialog.scss b/src/css/Dialog.scss new file mode 100644 index 000000000..ff833b291 --- /dev/null +++ b/src/css/Dialog.scss @@ -0,0 +1,52 @@ +@import 'variables'; + +dialog { + color: #eee; + background: $main-background-color; + border: 2px solid $border-color; + border-radius: 10px; + min-width: 225px; + max-width: 400px; + padding: $ui-large-margin; + + h1 { + font-size: 20px; + margin: 5px 0; + } + + .buttons { + button { + font-size: 16px; + margin: 0; + min-width: 80px; + + &:focus { + border-color: #888; + } + } + + display: flex; + flex-direction: row; + justify-content: flex-end; + column-gap: $ui-margin; + } + + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + input { + width: 100%; + border: none; + border-bottom: 1px solid hsla(0, 0%, 100%, 0.25); + background: transparent; + color: white; + text-overflow: ellipsis; + font-family: "fira", sans-serif; + font-size: 16px; + + &:focus { + outline: 0; + } + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 2fa7fb481..944593828 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -29,6 +29,14 @@ body { border: 2px solid $border-color !important; border-radius: 10px !important; min-height: 48px !important; + + &.toast-bug { + border-color: red !important; + + a { + color: red; + } + } } } diff --git a/src/index.tsx b/src/index.tsx index c46a7482e..7000deb31 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,12 +30,10 @@ try { { LiveSplit }, React, { createRoot }, - { ToastContainer }, ] = await Promise.all([ import("./ui/LiveSplit"), import("react"), import("react-dom/client"), - import("react-toastify"), ]); try { @@ -86,25 +84,17 @@ try { const container = document.getElementById("base"); const root = createRoot(container!); root.render( -
- - -
, + , ); } catch (e: any) { if (e.name === "InvalidStateError") { diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx new file mode 100644 index 000000000..e22ed91c9 --- /dev/null +++ b/src/ui/Dialog.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; + +import "../css/Dialog.scss"; + +export interface Options { + title: string | JSX.Element, + description: string | 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; + +export function showDialog(options: Options): Promise<[number, string]> { + if (dialogElement) { + dialogElement.showModal(); + const closeWith = options.buttons.length - 1; + dialogElement.onclose = () => { + resolveFn?.([closeWith, ""]); + }; + } + setState?.(options); + return new Promise((resolve) => resolveFn = resolve); +} + +export default class DialogContainer extends React.Component { + constructor(props: unknown) { + super(props); + + this.state = { + options: { + title: "", + description: "", + buttons: [], + }, + input: "", + }; + } + + public componentDidMount(): void { + setState = (options) => this.setState({ + options, + input: options.defaultText ?? "", + }); + } + + public render() { + return 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(); + } + }} + > +

{this.state.options.title}

+

{this.state.options.description}

+ { + this.state.options.textInput && this.setState({ input: e.target.value })} + onKeyDown={(e) => { + if (e?.key === "Enter") { + e.preventDefault(); + dialogElement?.close(); + resolveFn?.([0, this.state.input]); + } + }} + /> + } +
+ { + this.state.options.buttons.map((button, i) => { + return ; + }) + } +
+
; + } +} diff --git a/src/ui/LSOEventSink.ts b/src/ui/LSOEventSink.ts index 0e1029665..7e9a9f245 100644 --- a/src/ui/LSOEventSink.ts +++ b/src/ui/LSOEventSink.ts @@ -1,5 +1,6 @@ import { EventSink, EventSinkRef, ImageCacheRefMut, LayoutEditorRefMut, LayoutRefMut, LayoutStateRefMut, Run, RunRef, TimeSpan, TimeSpanRef, Timer, TimerPhase, TimingMethod } from "../livesplit-core"; import { WebEventSink } from "../livesplit-core/livesplit_core"; +import { showDialog } from "./Dialog"; export class LSOEventSink { private eventSink: EventSink; @@ -49,10 +50,18 @@ export class LSOEventSink { this.splitsModifiedChanged(); } - public reset(): void { + public async reset(): Promise { let updateSplits = true; if (this.timer.currentAttemptHasNewBestTimes()) { - updateSplits = confirm("You have beaten some of your best times. Do you want to update them?"); + const [result] = await showDialog({ + title: "Save Best Times?", + description: "You have beaten some of your best times. Do you want to update them?", + buttons: ["Yes", "No", "Don't Reset"], + }); + if (result === 2) { + return; + } + updateSplits = result === 0; } this.timer.reset(updateSplits); diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 9ea68d5c5..7f6016aa1 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -15,13 +15,15 @@ import { TimerView } from "./TimerView"; import { About } from "./About"; import { SplitsSelection, EditingInfo } from "./SplitsSelection"; import { LayoutView } from "./LayoutView"; -import { toast } from "react-toastify"; +import { ToastContainer, toast } from "react-toastify"; import * as Storage from "../storage"; import { UrlCache } from "../util/UrlCache"; import { WebRenderer } from "../livesplit-core/livesplit_core"; -import variables from "../css/variables.scss"; import { LiveSplitServer } from "../api/LiveSplitServer"; import { LSOEventSink } from "./LSOEventSink"; +import DialogContainer from "./Dialog"; + +import variables from "../css/variables.scss"; import "react-toastify/dist/ReactToastify.css"; import "../css/LiveSplit.scss"; @@ -138,7 +140,7 @@ export class LiveSplit extends React.Component { () => this.currentTimingMethodChanged(), () => this.currentPhaseChanged(), () => this.currentSplitChanged(), - () => this.comparisonsListChanged(), + () => this.comparisonListChanged(), () => this.splitsModifiedChanged(), () => this.onReset(), ); @@ -229,6 +231,8 @@ export class LiveSplit extends React.Component { layoutModified: false, }; + this.updateBadge(); + this.mediaQueryChanged = this.mediaQueryChanged.bind(this); } @@ -312,8 +316,9 @@ export class LiveSplit extends React.Component { } public render() { + let view; if (this.state.menu.kind === MenuKind.RunEditor) { - return { generalSettings={this.state.generalSettings} />; } else if (this.state.menu.kind === MenuKind.LayoutEditor) { - return { callbacks={this} />; } else if (this.state.menu.kind === MenuKind.SettingsEditor) { - return { allComparisons={this.state.allComparisons} />; } else if (this.state.menu.kind === MenuKind.About) { - return ; + view = ; } else if (this.state.menu.kind === MenuKind.Splits) { - return { splitsModified={this.state.splitsModified} />; } else if (this.state.menu.kind === MenuKind.Timer) { - return { layoutModified={this.state.layoutModified} />; } else if (this.state.menu.kind === MenuKind.Layout) { - return { layoutModified={this.state.layoutModified} />; } - // Only get here if the type is invalid - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw Error(`Invalid menu: ${this.state.menu}`); + + return <> + {view} + + + ; } public renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element) { @@ -487,7 +500,11 @@ export class LiveSplit extends React.Component { } public async importLayout() { - const [file] = await openFileAsString(); + const maybeFile = await openFileAsString(); + if (maybeFile === undefined) { + return; + } + const [file] = maybeFile; try { this.importLayoutFromString(file); } catch (err) { @@ -832,7 +849,7 @@ export class LiveSplit extends React.Component { } } - comparisonsListChanged(): void { + comparisonListChanged(): void { if (this.state != null) { this.setState({ allComparisons: this.state.eventSink.getAllComparisons(), diff --git a/src/ui/RunEditor.tsx b/src/ui/RunEditor.tsx index 83bb17907..ec3d4706d 100644 --- a/src/ui/RunEditor.tsx +++ b/src/ui/RunEditor.tsx @@ -8,6 +8,9 @@ import { downloadGameList, searchGames, getCategories, downloadCategories, downloadLeaderboard, getLeaderboard, downloadPlatformList, getPlatforms, downloadRegionList, getRegions, downloadGameInfo, getGameInfo, downloadGameInfoByGameId, downloadCategoriesByGameId, + gameListLength, + platformListLength, + regionListLength, } from "../api/GameList"; import { Category, Run, Variable, getRun } from "../api/SpeedrunCom"; import { Option, expect, map, assert } from "../util/OptionUtil"; @@ -21,6 +24,7 @@ import { import { renderMarkdown, replaceFlag } from "../util/Markdown"; import { UrlCache } from "../util/UrlCache"; import { GeneralSettings } from "./SettingsEditor"; +import { showDialog } from "./Dialog"; import "../css/RunEditor.scss"; @@ -33,6 +37,7 @@ export interface Props { } export interface State { editor: LiveSplit.RunEditorStateJson, + foundGames: string[], offsetIsValid: boolean, attemptCountIsValid: boolean, rowState: RowState, @@ -80,12 +85,14 @@ export class RunEditor extends React.Component { constructor(props: Props) { super(props); - const state = props.editor.stateAsJson(props.runEditorUrlCache.imageCache) as LiveSplit.RunEditorStateJson; + const state: LiveSplit.RunEditorStateJson = props.editor.stateAsJson(props.runEditorUrlCache.imageCache) as LiveSplit.RunEditorStateJson; + const foundGames = searchGames(state.game); props.runEditorUrlCache.collect(); this.state = { attemptCountIsValid: true, editor: state, + foundGames, offsetIsValid: true, rowState: { bestSegmentTime: "", @@ -196,7 +203,7 @@ export class RunEditor extends React.Component { label="Game" list={[ "run-editor-game-list", - searchGames(this.state.editor.game), + this.state.foundGames, ]} /> @@ -444,9 +451,14 @@ export class RunEditor extends React.Component { ); } - private addCustomVariable() { - const variableName = prompt("Variable Name:"); - if (variableName !== null) { + private async addCustomVariable() { + const [result, variableName] = await showDialog({ + title: "Add Variable", + description: "Specify the name of the custom variable you want to add:", + textInput: true, + buttons: ["OK", "Cancel"], + }); + if (result === 0) { this.props.editor.addCustomVariable(variableName); this.update(); } @@ -1104,9 +1116,16 @@ export class RunEditor extends React.Component { , ]; } else { + const possibleMatch = /^\[([a-z]+)\](.+)$/.exec(p.name); + let name = p.name; + let flag; + if (possibleMatch !== null) { + flag = replaceFlag(possibleMatch[1]); + name = possibleMatch[2]; + } return [ i !== 0 ? ", " : null, - {p.name} + {flag}{name} ]; } }) @@ -1450,9 +1469,15 @@ export class RunEditor extends React.Component { }; } - private generateGoalComparison() { - const goalTime = prompt("Goal Time:"); - if (goalTime !== null) { + private async generateGoalComparison() { + const [result, goalTime] = await showDialog({ + title: "Generate Goal Comparison", + description: "Specify the time you want to achieve:", + textInput: true, + buttons: ["Generate", "Cancel"], + }); + + if (result === 0) { if (this.props.editor.parseAndGenerateGoalComparison(goalTime)) { this.update(); } else { @@ -1461,45 +1486,70 @@ export class RunEditor extends React.Component { } } - private copyComparison(comparisonToCopy?: string) { - const comparison = comparisonToCopy ?? prompt("Comparison Name:"); - if (comparison !== null) { - let newName: string | undefined; - if (comparison.endsWith(" Copy")) { - const before = comparison.substring(0, comparison.length - " Copy".length); - newName = `${before} Copy 2`; - } else { - const regexMatch = /^(.* Copy )(\d+)$/.exec(comparison); - if (regexMatch !== null) { - const copyNumber = Number(regexMatch[2]); - newName = `${regexMatch[1]}${copyNumber + 1}`; - } else { - newName = `${comparison} Copy`; - } + private async copyComparison(comparisonToCopy?: string) { + let comparison = comparisonToCopy; + if (comparison === undefined) { + const [result, comparisonName] = await showDialog({ + title: "Copy Comparison", + description: "Specify the name of the comparison you want to copy:", + textInput: true, + buttons: ["Copy", "Cancel"], + }); + if (result !== 0) { + return; } + comparison = comparisonName; + } - if (this.props.editor.copyComparison(comparison, newName)) { - this.update(); + let newName: string | undefined; + if (comparison.endsWith(" Copy")) { + const before = comparison.substring(0, comparison.length - " Copy".length); + newName = `${before} Copy 2`; + } else { + const regexMatch = /^(.* Copy )(\d+)$/.exec(comparison); + if (regexMatch !== null) { + const copyNumber = Number(regexMatch[2]); + newName = `${regexMatch[1]}${copyNumber + 1}`; } else { - toast.error("Failed copying the comparison. The comparison may not exist."); + newName = `${comparison} Copy`; } } + + if (this.props.editor.copyComparison(comparison, newName)) { + this.update(); + } else { + toast.error("Failed copying the comparison. The comparison may not exist."); + } } - private cleanSumOfBest() { + private async cleanSumOfBest() { + const ptr = this.props.editor.ptr; { using cleaner = this.props.editor.cleanSumOfBest(); + this.props.editor.ptr = 0; + let first = true; while (true) { using potentialCleanUp = cleaner.nextPotentialCleanUp(); if (!potentialCleanUp) { + if (first) { + toast.info("There is nothing to clean up."); + } break; } - const message = potentialCleanUp.message(); - if (confirm(message)) { + first = false; + const [result] = await showDialog({ + title: "Clean?", + description: potentialCleanUp.message(), + buttons: ["Yes", "No", "Cancel"], + }); + if (result === 0) { cleaner.apply(potentialCleanUp); + } else if (result === 2) { + break; } } } + this.props.editor.ptr = ptr; this.update(); } @@ -1514,15 +1564,25 @@ export class RunEditor extends React.Component { } private async importComparison() { - const [data, file] = await openFileAsArrayBuffer(); + const maybeFile = await openFileAsArrayBuffer(); + if (maybeFile === undefined) { + return; + } + const [data, file] = maybeFile; using result = LiveSplit.Run.parseArray(new Uint8Array(data), ""); if (!result.parsedSuccessfully()) { toast.error("Couldn't parse the splits."); return; } using run = result.unwrap(); - const comparisonName = prompt("Comparison Name:", file.name.replace(/\.[^/.]+$/, "")); - if (comparisonName === null) { + const [dialogResult, comparisonName] = await showDialog({ + title: "Import Comparison", + description: "Specify the name of the comparison you want to import:", + textInput: true, + buttons: ["Import", "Cancel"], + defaultText: file.name.replace(/\.[^/.]+$/, ""), + }); + if (dialogResult !== 0) { return; } const valid = this.props.editor.importComparison(run, comparisonName); @@ -1533,9 +1593,15 @@ export class RunEditor extends React.Component { } } - private addComparison() { - const comparisonName = prompt("Comparison Name:"); - if (comparisonName !== null) { + private async addComparison() { + const [result, comparisonName] = await showDialog({ + title: "Add Comparison", + description: "Specify the name of the comparison you want to add:", + textInput: true, + buttons: ["Add", "Cancel"], + }); + + if (result === 0) { const valid = this.props.editor.addComparison(comparisonName); if (valid) { this.update(); @@ -1545,9 +1611,16 @@ export class RunEditor extends React.Component { } } - private renameComparison(comparison: string) { - const newName = prompt("Comparison Name:", comparison); - if (newName !== null) { + private async renameComparison(comparison: string) { + const [result, newName] = await showDialog({ + title: "Rename Comparison", + description: "Specify the new name of the comparison:", + textInput: true, + buttons: ["Rename", "Cancel"], + defaultText: comparison, + }); + + if (result === 0) { const valid = this.props.editor.renameComparison(comparison, newName); if (valid) { this.update(); @@ -1564,7 +1637,11 @@ export class RunEditor extends React.Component { private async changeSegmentIcon(index: number) { this.props.editor.selectOnly(index); - const [file] = await openFileAsArrayBuffer(); + const maybeFile = await openFileAsArrayBuffer(); + if (maybeFile === undefined) { + return; + } + const [file] = maybeFile; this.props.editor.activeSetIconFromArray(new Uint8Array(file)); this.update(); } @@ -1579,7 +1656,11 @@ export class RunEditor extends React.Component { } private async changeGameIcon() { - const [file] = await openFileAsArrayBuffer(); + const maybeFile = await openFileAsArrayBuffer(); + if (maybeFile === undefined) { + return; + } + const [file] = maybeFile; this.props.editor.setGameIconFromArray(new Uint8Array(file)); this.maybeUpdate(); } @@ -1600,22 +1681,26 @@ export class RunEditor extends React.Component { * the leaderboards. We don't want to update the editor if it has been * disposed in the meantime. */ - private maybeUpdate() { + private maybeUpdate(options: { switchTab?: Tab, search?: boolean } = {}) { if (this.props.editor.ptr === 0) { return; } - this.update(); + this.update(options); } - private update(switchTab?: Tab) { - const intendedTab = switchTab ?? this.state.tab; + private update(options: { switchTab?: Tab, search?: boolean } = {}) { + const intendedTab = options.switchTab ?? this.state.tab; const shouldShowTab = this.shouldShowTab(intendedTab); const newActiveTab = shouldShowTab ? intendedTab : Tab.RealTime; - const state = this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache); + const state: LiveSplit.RunEditorStateJson = this.props.editor.stateAsJson( + this.props.runEditorUrlCache.imageCache, + ); + if (options.search) { + this.setState({ foundGames: searchGames(state.game) }); + } this.props.runEditorUrlCache.collect(); this.setState({ - ...this.state, editor: state, tab: newActiveTab, }); @@ -1630,7 +1715,7 @@ export class RunEditor extends React.Component { this.refreshLeaderboard(event.target.value, this.state.editor.category); this.resetTotalLeaderboardState(); } - this.update(); + this.update({ search: true }); } private handleCategoryChange(event: any) { @@ -1646,7 +1731,6 @@ export class RunEditor extends React.Component { private handleOffsetChange(event: any) { const valid = this.props.editor.parseAndSetOffset(event.target.value); this.setState({ - ...this.state, editor: { ...this.state.editor, offset: event.target.value, @@ -1657,7 +1741,6 @@ export class RunEditor extends React.Component { private handleOffsetBlur() { this.setState({ - ...this.state, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), offsetIsValid: true, }); @@ -1667,7 +1750,6 @@ export class RunEditor extends React.Component { private handleAttemptsChange(event: any) { const valid = this.props.editor.parseAndSetAttemptCount(event.target.value); this.setState({ - ...this.state, attemptCountIsValid: valid, editor: { ...this.state.editor, @@ -1678,7 +1760,6 @@ export class RunEditor extends React.Component { private handleAttemptsBlur() { this.setState({ - ...this.state, attemptCountIsValid: true, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), }); @@ -1705,7 +1786,6 @@ export class RunEditor extends React.Component { }; this.setState({ - ...this.state, editor, rowState, }); @@ -1718,7 +1798,6 @@ export class RunEditor extends React.Component { private handleSplitTimeChange(event: any) { this.setState({ - ...this.state, rowState: { ...this.state.rowState, splitTime: event.target.value, @@ -1729,7 +1808,6 @@ export class RunEditor extends React.Component { private handleSegmentTimeChange(event: any) { this.setState({ - ...this.state, rowState: { ...this.state.rowState, segmentTime: event.target.value, @@ -1740,7 +1818,6 @@ export class RunEditor extends React.Component { private handleBestSegmentTimeChange(event: any) { this.setState({ - ...this.state, rowState: { ...this.state.rowState, bestSegmentTime: event.target.value, @@ -1757,7 +1834,6 @@ export class RunEditor extends React.Component { comparisonTimesChanged[comparisonIndex] = true; this.setState({ - ...this.state, rowState: { ...this.state.rowState, comparisonTimes, @@ -1772,7 +1848,6 @@ export class RunEditor extends React.Component { } this.setState({ - ...this.state, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), rowState: { ...this.state.rowState, @@ -1789,7 +1864,6 @@ export class RunEditor extends React.Component { } this.setState({ - ...this.state, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), rowState: { ...this.state.rowState, @@ -1806,7 +1880,6 @@ export class RunEditor extends React.Component { } this.setState({ - ...this.state, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), rowState: { ...this.state.rowState, @@ -1827,7 +1900,6 @@ export class RunEditor extends React.Component { comparisonTimesChanged[comparisonIndex] = false; this.setState({ - ...this.state, editor: this.props.editor.stateAsJson(this.props.runEditorUrlCache.imageCache), rowState: { ...this.state.rowState, @@ -1884,7 +1956,7 @@ export class RunEditor extends React.Component { } } this.resetTotalLeaderboardState(); - this.update(tab); + this.update({ switchTab: tab }); } private shouldShowTab(tab: Tab) { @@ -1921,48 +1993,76 @@ export class RunEditor extends React.Component { } private async downloadBoxArt() { - const gameName = this.state.editor.game; - await downloadGameInfo(gameName); - const game = getGameInfo(gameName); - if (game !== undefined) { - const uri = game.assets["cover-medium"].uri; - if (uri.startsWith("https://")) { - const response = await fetch(uri); - const buffer = await response.arrayBuffer(); - this.props.editor.setGameIconFromArray(new Uint8Array(buffer)); - this.maybeUpdate(); + try { + const gameName = this.state.editor.game; + await downloadGameInfo(gameName); + const game = getGameInfo(gameName); + if (game !== undefined) { + const uri = game.assets["cover-medium"].uri; + if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/blankcover.png") { + const response = await fetch(uri); + const buffer = await response.arrayBuffer(); + this.props.editor.setGameIconFromArray(new Uint8Array(buffer)); + this.maybeUpdate(); + } else { + toast.error("The game doesn't have a box art."); + } + } else { + toast.error("Couldn't find the game."); } + } catch { + toast.error("Couldn't download the box art."); } } private async downloadIcon() { - const gameName = this.state.editor.game; - await downloadGameInfo(gameName); - const game = getGameInfo(gameName); - if (game !== undefined) { - const uri = game.assets.icon.uri; - if (uri.startsWith("https://")) { - const response = await fetch(uri); - const buffer = await response.arrayBuffer(); - this.props.editor.setGameIconFromArray(new Uint8Array(buffer)); - this.maybeUpdate(); + try { + const gameName = this.state.editor.game; + await downloadGameInfo(gameName); + const game = getGameInfo(gameName); + if (game !== undefined) { + const uri = game.assets.icon.uri; + if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/1st.png") { + const response = await fetch(uri); + const buffer = await response.arrayBuffer(); + this.props.editor.setGameIconFromArray(new Uint8Array(buffer)); + this.maybeUpdate(); + } else { + toast.error("The game doesn't have an icon."); + } + } else { + toast.error("Couldn't find the game."); } + } catch { + toast.error("Couldn't download the icon."); } } private async refreshGameList() { + const before = gameListLength(); await downloadGameList(); - this.maybeUpdate(); + const after = gameListLength(); + if (before !== after) { + this.maybeUpdate({ search: true }); + } } private async refreshPlatformList() { + const before = platformListLength(); await downloadPlatformList(); - this.maybeUpdate(); + const after = platformListLength(); + if (before !== after) { + this.maybeUpdate(); + } } private async refreshRegionList() { + const before = regionListLength(); await downloadRegionList(); - this.maybeUpdate(); + const after = regionListLength(); + if (before !== after) { + this.maybeUpdate(); + } } private async refreshGameInfo(gameName: string) { @@ -2062,8 +2162,14 @@ export class RunEditor extends React.Component { return; } - const idOrUrl = prompt("Specify the speedrun.com ID or URL of the run:"); - if (idOrUrl === null) { + const [result, idOrUrl] = await showDialog({ + title: "Associate Run", + description: "Specify the speedrun.com ID or URL of the run:", + textInput: true, + buttons: ["Associate", "Cancel"], + }); + + if (result !== 0) { return; } const pattern = /^(?:(?:https?:\/\/)?(?:www\.)?speedrun\.com\/(?:\w+\/)?run\/)?(\w+)$/; diff --git a/src/ui/Settings.tsx b/src/ui/Settings.tsx index 0015c9a28..5bc384271 100644 --- a/src/ui/Settings.tsx +++ b/src/ui/Settings.tsx @@ -8,6 +8,7 @@ import { UrlCache } from "../util/UrlCache"; import { openFileAsArrayBuffer } from "../util/FileUtil"; import * as FontList from "../util/FontList"; import { LiveSplitServer } from "../api/LiveSplitServer"; +import { showDialog } from "./Dialog"; import "../css/Tooltip.scss"; import "../css/LiveSplitServerButton.scss"; @@ -1247,10 +1248,14 @@ export class SettingsComponent extends React.Component> {
{ - const [file] = await openFileAsArrayBuffer(); + const maybeFile = await openFileAsArrayBuffer(); + if (maybeFile === undefined) { + return; + } + const [file] = maybeFile; const imageId = this.props.editorUrlCache.imageCache.cacheFromArray( new Uint8Array(file), true, @@ -1429,13 +1434,19 @@ export class SettingsComponent extends React.Component> { ); } - private connectToServerOrDisconnect(valueIndex: number, serverUrl: string | undefined, connection: Option) { + private async connectToServerOrDisconnect(valueIndex: number, serverUrl: string | undefined, connection: Option) { if (connection) { connection.close(); return; } - const url = prompt("Specify the WebSocket URL:", serverUrl); - if (!url) { + 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)); diff --git a/src/ui/SplitsSelection.tsx b/src/ui/SplitsSelection.tsx index 1b41ae962..a93739b3b 100644 --- a/src/ui/SplitsSelection.tsx +++ b/src/ui/SplitsSelection.tsx @@ -7,11 +7,12 @@ import { Run, Segment, TimerPhase } from "../livesplit-core"; import * as SplitsIO from "../util/SplitsIO"; import { toast } from "react-toastify"; import { openFileAsArrayBuffer, exportFile, convertFileToArrayBuffer } from "../util/FileUtil"; -import { Option, maybeDisposeAndThen } from "../util/OptionUtil"; +import { Option, bug, maybeDisposeAndThen } from "../util/OptionUtil"; import DragUpload from "./DragUpload"; import { ContextMenuTrigger, ContextMenu, MenuItem } from "react-contextmenu"; import { GeneralSettings } from "./SettingsEditor"; import { LSOEventSink } from "./LSOEventSink"; +import { showDialog } from "./Dialog"; import "../css/SplitsSelection.scss"; @@ -217,10 +218,11 @@ export class SplitsSelection extends React.Component { ); } - private async getRunFromKey(key: number): Promise { + private async getRunFromKey(key: number): Promise { const splitsData = await loadSplits(key); if (splitsData === undefined) { - throw Error("The splits key is invalid."); + bug("The splits key is invalid."); + return; } using result = Run.parseArray(new Uint8Array(splitsData), ""); @@ -228,19 +230,28 @@ export class SplitsSelection extends React.Component { if (result.parsedSuccessfully()) { return result.unwrap(); } else { - throw Error("Couldn't parse the splits."); + bug("Couldn't parse the splits."); + return; } } private async openSplits(key: number) { const isModified = this.props.eventSink.hasBeenModified(); - if (isModified && !confirm( - "Your current splits are modified and have unsaved changes. Do you want to continue and discard those changes?", - )) { - return; + if (isModified) { + const [result] = await showDialog({ + title: "Discard Changes?", + description: "Your current splits are modified and have unsaved changes. Do you want to continue and discard those changes?", + buttons: ["Yes", "No"], + }); + if (result === 1) { + return; + } } using run = await this.getRunFromKey(key); + if (run === undefined) { + return; + } maybeDisposeAndThen( this.props.eventSink.setRun(run), () => toast.error("The loaded splits are invalid."), @@ -274,7 +285,9 @@ export class SplitsSelection extends React.Component { private async editSplits(splitsKey: number) { const run = await this.getRunFromKey(splitsKey); - this.props.callbacks.openRunEditor({ splitsKey, run }); + if (run !== undefined) { + this.props.callbacks.openRunEditor({ splitsKey, run }); + } } private async copySplits(key: number) { @@ -283,9 +296,12 @@ export class SplitsSelection extends React.Component { } private async deleteSplits(key: number) { - if (!confirm( - "Are you sure you want to delete the splits? This operation can not be undone.", - )) { + const [result] = await showDialog({ + title: "Delete Splits?", + description: "Are you sure you want to delete the splits? This operation can not be undone.", + buttons: ["Yes", "No"], + }); + if (result !== 0) { return; } @@ -299,6 +315,9 @@ export class SplitsSelection extends React.Component { private async importSplits() { const splits = await openFileAsArrayBuffer(); + if (splits === undefined) { + return; + } try { await this.importSplitsFromArrayBuffer(splits); } catch (err: any) { @@ -358,8 +377,16 @@ export class SplitsSelection extends React.Component { } private async importSplitsFromSplitsIO() { - let id = prompt("Specify the Splits.io URL or ID:"); - if (!id) { + const response = await showDialog({ + title: "Import Splits from Splits.io", + description: "Specify the Splits.io URL or ID:", + textInput: true, + buttons: ["Import", "Cancel"], + }); + const result = response[0]; + let id = response[1]; + + if (result !== 0) { return; } if (id.indexOf("https://splits.io/") === 0) { diff --git a/src/util/FileUtil.ts b/src/util/FileUtil.ts index d6842da23..aed81b630 100644 --- a/src/util/FileUtil.ts +++ b/src/util/FileUtil.ts @@ -5,14 +5,14 @@ import { Option } from "./OptionUtil"; // @ts-expect-error Unused variable due to above issue let fileInputElement = null; // eslint-disable-line -function openFile(): Promise { - return new Promise((resolve, reject) => { +function openFile(): Promise { + return new Promise((resolve) => { const input = document.createElement("input"); input.setAttribute("type", "file"); input.onchange = () => { const file: Option = input.files?.[0]; if (file === undefined) { - reject(); + resolve(undefined); return; } resolve(file); @@ -31,12 +31,16 @@ export async function convertFileToArrayBuffer(file: File): Promise<[ArrayBuffer resolve([contents, file]); } }; + // FIXME: onerror reader.readAsArrayBuffer(file); }); } -export async function openFileAsArrayBuffer(): Promise<[ArrayBuffer, File]> { +export async function openFileAsArrayBuffer(): Promise<[ArrayBuffer, File] | undefined> { const file = await openFile(); + if (file === undefined) { + return undefined; + } return convertFileToArrayBuffer(file); } @@ -53,8 +57,11 @@ export async function convertFileToString(file: File): Promise<[string, File]> { }); } -export async function openFileAsString(): Promise<[string, File]> { +export async function openFileAsString(): Promise<[string, File] | undefined> { const file = await openFile(); + if (file === undefined) { + return undefined; + } return convertFileToString(file); } diff --git a/src/util/OptionUtil.ts b/src/util/OptionUtil.tsx similarity index 69% rename from src/util/OptionUtil.ts rename to src/util/OptionUtil.tsx index 7da2bc083..4a1bba349 100644 --- a/src/util/OptionUtil.ts +++ b/src/util/OptionUtil.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { toast } from "react-toastify"; export type Option = T | null | undefined; @@ -14,10 +15,27 @@ interface Disposable { } export function panic(message: string): never { - toast.error(`Bug: ${message}`); + bug(message); throw new Error(message); } +export function bug(message: string): void { + toast.error( + <> + You encountered a bug: +

{message}

+ Please report this issue here. + , + { + autoClose: false, + className: "toast-class toast-bug", + }, + ); +} + export function assertNever(x: never): never { return x; } export function assert(condition: boolean, message: string): asserts condition {