Add indicators for unsaved changes (#912)

This adds indicators to various parts of the UI to indicate that there
are unsaved changes. The indicators are mainly shown as white dots in
the sidebar highlighting what is unsaved. The title is also updated to
reflect this. The Badging API is also used to show a badge on the "app
icon" if it's installed as a PWA. In addition to the splits, the layouts
are also now properly tracked when it comes to unsaved changes.

Changelog: There are now indicators in the user interface that remind
you about unsaved changes to your splits and layouts.
This commit is contained in:
Christopher Serr
2024-06-04 22:37:19 +00:00
committed by GitHub
parent af383c7062
commit 0902aa339b
6 changed files with 85 additions and 16 deletions
+6
View File
@@ -67,6 +67,12 @@ $h2-font-size: 22px;
}
}
}
.modified-icon {
position: absolute;
font-size: 10px;
padding-left: 5px;
}
}
.choose-comparison {
+5
View File
@@ -11,6 +11,7 @@ export class LSOEventSink {
private currentPhaseChanged: () => void,
private currentSplitChanged: () => void,
private comparisonListChanged: () => void,
private splitsModifiedChanged: () => void,
private onReset: () => void,
) {
this.eventSink = new EventSink(new WebEventSink(this).intoGeneric());
@@ -30,6 +31,7 @@ export class LSOEventSink {
this.currentPhaseChanged();
this.currentSplitChanged();
this.splitsModifiedChanged();
}
public split(): void {
@@ -44,6 +46,7 @@ export class LSOEventSink {
this.currentPhaseChanged();
this.currentSplitChanged();
this.splitsModifiedChanged();
}
public reset(): void {
@@ -167,6 +170,7 @@ export class LSOEventSink {
this.currentPhaseChanged();
this.currentSplitChanged();
this.comparisonListChanged();
this.splitsModifiedChanged();
return result;
}
@@ -177,6 +181,7 @@ export class LSOEventSink {
public markAsUnmodified(): void {
this.timer.markAsUnmodified();
this.splitsModifiedChanged();
}
public getRun(): RunRef {
+8
View File
@@ -27,6 +27,8 @@ export interface Props {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}
interface Callbacks {
@@ -69,6 +71,8 @@ export class LayoutView extends React.Component<Props> {
currentPhase={this.props.currentPhase}
currentSplitIndex={this.props.currentSplitIndex}
allComparisons={this.props.allComparisons}
splitsModified={this.props.splitsModified}
layoutModified={this.props.layoutModified}
/>;
const sidebarContent = this.renderSidebarContent();
return this.props.callbacks.renderViewWithSidebar(renderedView, sidebarContent);
@@ -84,6 +88,10 @@ export class LayoutView extends React.Component<Props> {
</button>
<button onClick={(_) => this.props.callbacks.saveLayout()}>
<i className="fa fa-save" aria-hidden="true" /> Save
{
this.props.layoutModified &&
<i className="fa fa-circle modified-icon" aria-hidden="true" />
}
</button>
<button onClick={(_) => this.props.callbacks.importLayout()}>
<i className="fa fa-download" aria-hidden="true" /> Import
+51 -16
View File
@@ -85,6 +85,8 @@ export interface State {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}
export let hotkeySystem: Option<HotkeySystem> = null;
@@ -131,6 +133,7 @@ export class LiveSplit extends React.Component<Props, State> {
() => this.currentPhaseChanged(),
() => this.currentSplitChanged(),
() => this.comparisonsListChanged(),
() => this.splitsModifiedChanged(),
() => this.onReset(),
);
@@ -211,6 +214,8 @@ export class LiveSplit extends React.Component<Props, State> {
currentPhase: eventSink.currentPhase(),
currentSplitIndex: eventSink.currentSplitIndex(),
allComparisons: eventSink.getAllComparisons(),
splitsModified: eventSink.hasBeenModified(),
layoutModified: false,
};
this.mediaQueryChanged = this.mediaQueryChanged.bind(this);
@@ -234,13 +239,12 @@ export class LiveSplit extends React.Component<Props, State> {
this.resizeEvent = { handleEvent: () => this.handleAutomaticResize() };
window.addEventListener("resize", this.resizeEvent, false);
window.onbeforeunload = (e: BeforeUnloadEvent) => {
const hasBeenModified = this.state.eventSink.hasBeenModified();
if (hasBeenModified) {
e.returnValue = "There are unsaved changes. Do you really want to close LiveSplit One?";
return e.returnValue;
window.onbeforeunload = () => {
if (this.state.splitsModified || this.state.layoutModified) {
return "There are unsaved changes. Do you really want to close LiveSplit One?";
} else {
return;
}
return null;
};
// This is bound in the constructor
@@ -333,6 +337,7 @@ export class LiveSplit extends React.Component<Props, State> {
eventSink={this.state.eventSink}
openedSplitsKey={this.state.openedSplitsKey}
callbacks={this}
splitsModified={this.state.splitsModified}
/>;
} else if (this.state.menu.kind === MenuKind.Timer) {
return <TimerView
@@ -354,6 +359,8 @@ export class LiveSplit extends React.Component<Props, State> {
currentPhase={this.state.currentPhase}
currentSplitIndex={this.state.currentSplitIndex}
allComparisons={this.state.allComparisons}
splitsModified={this.state.splitsModified}
layoutModified={this.state.layoutModified}
/>;
} else if (this.state.menu.kind === MenuKind.Layout) {
return <LayoutView
@@ -375,6 +382,8 @@ export class LiveSplit extends React.Component<Props, State> {
currentPhase={this.state.currentPhase}
currentSplitIndex={this.state.currentSplitIndex}
allComparisons={this.state.allComparisons}
splitsModified={this.state.splitsModified}
layoutModified={this.state.layoutModified}
/>;
}
// Only get here if the type is invalid
@@ -412,9 +421,8 @@ export class LiveSplit extends React.Component<Props, State> {
);
}
public openTimerView(layout: Layout = this.state.layout) {
public openTimerView() {
this.setState({
layout,
menu: { kind: MenuKind.Timer },
sidebarOpen: false,
});
@@ -452,7 +460,7 @@ export class LiveSplit extends React.Component<Props, State> {
try {
const layout = this.state.layout.settingsAsJson();
await Storage.storeLayout(layout);
toast.info("Layout saved successfully.");
this.setState({ layoutModified: false }, () => this.updateBadge());
} catch (_) {
toast.error("Failed to save the layout.");
}
@@ -544,12 +552,11 @@ export class LiveSplit extends React.Component<Props, State> {
const layoutEditor = this.state.menu.editor;
const layout = layoutEditor.close();
if (save) {
this.state.layout[Symbol.dispose]();
this.openTimerView(layout);
this.setLayout(layout);
} else {
layout[Symbol.dispose]();
this.openTimerView();
}
this.openTimerView();
}
public openSettingsEditor() {
@@ -669,9 +676,13 @@ export class LiveSplit extends React.Component<Props, State> {
private setLayout(layout: Layout) {
this.state.layout[Symbol.dispose]();
this.setState({
layout,
});
this.setState(
{
layout,
layoutModified: true,
},
() => this.updateBadge(),
);
}
private setRun(run: Run, callback: () => void) {
@@ -737,7 +748,6 @@ export class LiveSplit extends React.Component<Props, State> {
if (this.state.openedSplitsKey !== openedSplitsKey) {
this.setSplitsKey(openedSplitsKey);
}
toast.info("Splits saved successfully.");
} catch (_) {
toast.error("Failed to save the splits.");
}
@@ -796,4 +806,29 @@ export class LiveSplit extends React.Component<Props, State> {
this.saveSplits();
}
}
splitsModifiedChanged(): void {
if (this.state != null) {
const splitsModified = this.state.eventSink.hasBeenModified();
this.setState({ splitsModified }, () => this.updateBadge());
}
}
private updateBadge(): void {
if (this.state.splitsModified || this.state.layoutModified) {
try {
navigator?.setAppBadge();
} catch {
// It's fine if this fails.
}
document.title = "*LiveSplit One";
} else {
try {
navigator?.clearAppBadge();
} catch {
// It's fine if this fails.
}
document.title = "LiveSplit One";
}
}
}
+5
View File
@@ -25,6 +25,7 @@ export interface Props {
openedSplitsKey?: number,
callbacks: Callbacks,
generalSettings: GeneralSettings,
splitsModified: boolean,
}
interface State {
@@ -195,6 +196,10 @@ export class SplitsSelection extends React.Component<Props, State> {
</button>
<button onClick={(_) => this.saveSplits()}>
<i className="fa fa-save" aria-hidden="true" /> Save
{
this.props.splitsModified &&
<i className="fa fa-circle modified-icon" aria-hidden="true" />
}
</button>
<button onClick={(_) => this.exportTimerSplits()}>
<i className="fa fa-upload" aria-hidden="true" /> Export
+10
View File
@@ -33,6 +33,8 @@ export interface Props {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}
export interface State {
manualGameTime: string,
@@ -209,9 +211,17 @@ export class TimerView extends React.Component<Props, State> {
<hr className="livesplit-title-separator" />
<button onClick={(_) => this.props.callbacks.openSplitsView()}>
<i className="fa fa-list" aria-hidden="true" /> Splits
{
this.props.splitsModified &&
<i className="fa fa-circle modified-icon" aria-hidden="true" />
}
</button>
<button onClick={(_) => this.props.callbacks.openLayoutView()}>
<i className="fa fa-layer-group" aria-hidden="true" /> Layout
{
this.props.layoutModified &&
<i className="fa fa-circle modified-icon" aria-hidden="true" />
}
</button>
<hr />
<h2>Compare Against</h2>