Skip to content

Commit

Permalink
Add indicators for unsaved changes (#912)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
CryZe authored Jun 4, 2024
1 parent af383c7 commit 0902aa3
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 16 deletions.
6 changes: 6 additions & 0 deletions src/css/Sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ $h2-font-size: 22px;
}
}
}

.modified-icon {
position: absolute;
font-size: 10px;
padding-left: 5px;
}
}

.choose-comparison {
Expand Down
5 changes: 5 additions & 0 deletions src/ui/LSOEventSink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -30,6 +31,7 @@ export class LSOEventSink {

this.currentPhaseChanged();
this.currentSplitChanged();
this.splitsModifiedChanged();
}

public split(): void {
Expand All @@ -44,6 +46,7 @@ export class LSOEventSink {

this.currentPhaseChanged();
this.currentSplitChanged();
this.splitsModifiedChanged();
}

public reset(): void {
Expand Down Expand Up @@ -167,6 +170,7 @@ export class LSOEventSink {
this.currentPhaseChanged();
this.currentSplitChanged();
this.comparisonListChanged();
this.splitsModifiedChanged();

return result;
}
Expand All @@ -177,6 +181,7 @@ export class LSOEventSink {

public markAsUnmodified(): void {
this.timer.markAsUnmodified();
this.splitsModifiedChanged();
}

public getRun(): RunRef {
Expand Down
8 changes: 8 additions & 0 deletions src/ui/LayoutView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface Props {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}

interface Callbacks {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
67 changes: 51 additions & 16 deletions src/ui/LiveSplit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export interface State {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}

export let hotkeySystem: Option<HotkeySystem> = null;
Expand Down Expand Up @@ -131,6 +133,7 @@ export class LiveSplit extends React.Component<Props, State> {
() => this.currentPhaseChanged(),
() => this.currentSplitChanged(),
() => this.comparisonsListChanged(),
() => this.splitsModifiedChanged(),
() => this.onReset(),
);

Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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 changes: 5 additions & 0 deletions src/ui/SplitsSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Props {
openedSplitsKey?: number,
callbacks: Callbacks,
generalSettings: GeneralSettings,
splitsModified: boolean,
}

interface State {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/ui/TimerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface Props {
currentPhase: TimerPhase,
currentSplitIndex: number,
allComparisons: string[],
splitsModified: boolean,
layoutModified: boolean,
}
export interface State {
manualGameTime: string,
Expand Down Expand Up @@ -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>
Expand Down

0 comments on commit 0902aa3

Please sign in to comment.