mirror of
https://github.com/ApfelTeeSaft/LiveSplitOne.git
synced 2026-08-26 19:23:40 +00:00
Switch to Canvas Rendering
This switches the rendering of the layout from using HTML and CSS to using a canvas (technically it's two). This not only should improve the performance but also brings the rendering in line with our native renderers, enabling new features such as vertical resizing and horizontal layouts. This also removes a lot of code specific to the HTML rendering and thus greatly simplifies the codebase. There is one slight regression in that the layout editor doesn't allow clicking and dragging the individual components directly on the layout anymore. This is however something we can explore directly in `livesplit-core` in the future, so other frontends can benefit from it as well.
This commit is contained in:
+3
-2
@@ -42,7 +42,8 @@ if (process.argv.some((v) => v === "--nightly")) {
|
||||
// but it forgets to pass the LTO flag that we specified in the
|
||||
// Cargo.toml, so the virtual-function-elimination complains that it's
|
||||
// only compatible with LTO, so we have to specify lto here too.
|
||||
rustFlags += " -Z virtual-function-elimination -C lto";
|
||||
// FIXME: Seems to be broken at the moment.
|
||||
// rustFlags += " -Z virtual-function-elimination -C lto";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ execSync(
|
||||
);
|
||||
|
||||
execSync(
|
||||
`cargo ${toolchain} rustc -p livesplit-core-capi --crate-type cdylib --features wasm-web --target wasm32-unknown-unknown ${cargoFlags}`,
|
||||
`cargo ${toolchain} rustc -p livesplit-core-capi --crate-type cdylib --features wasm-web,web-rendering --target wasm32-unknown-unknown ${cargoFlags}`,
|
||||
{
|
||||
cwd: "livesplit-core",
|
||||
stdio: "inherit",
|
||||
|
||||
+1
-1
Submodule livesplit-core updated: f957c93b03...a7c9df2ef1
+1
-1
@@ -1 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1000 950" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="0" gradientTransform="rotate(90)"><stop stop-color="#D73F4A"/><stop offset="1" stop-color="#C22B36"/></linearGradient><linearGradient id="1" gradientTransform="rotate(90)"><stop stop-color="#3FCD46"/><stop offset="1" stop-color="#27BB2E"/></linearGradient><linearGradient id="2" gradientTransform="rotate(90)"><stop stop-color="#3F5CD7"/><stop offset="1" stop-color="#2B48C2"/></linearGradient><filter id="3"><feOffset dy="10"/><feGaussianBlur result="b" stdDeviation="20"/><feFlood flood-opacity=".8"/><feComposite in2="b" operator="in"/><feComposite in="SourceGraphic"/></filter><path id="4" d="M320.733,150.5 C395.427,21.125 604.573,21.125 679.267,150.5 L918.29,564.5 C992.985,693.875 888.412,875 739.023,875 L260.977,875 C111.588,875 7.015,693.875 81.71,564.5 Z"/></defs><use href="#4" fill="#404040"/><path d="M800.52,350.104 L620.208,169.792 L379.792,410.208 L620.208,650.624 L592.674,710.104 L412.362,529.792 L171.946,770.208 L280.133,878.395 L739.023,875 C888.412,875 992.985,693.875 918.29,564.5 Z" fill-opacity=".125"/><circle cx="500" cy="290" r="170" fill="url(#0)"/><circle cx="707.846" cy="650" r="170" fill="url(#1)"/><circle cx="292.154" cy="650" r="170" fill="url(#2)"/><use href="#4" fill="none" stroke="#fff" stroke-width="40" filter="url(#3)"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1000 950" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="r" gradientTransform="rotate(90)"><stop stop-color="#D73F4A"/><stop offset="1" stop-color="#C22B36"/></linearGradient><linearGradient id="g" gradientTransform="rotate(90)"><stop stop-color="#3FCD46"/><stop offset="1" stop-color="#27BB2E"/></linearGradient><linearGradient id="b" gradientTransform="rotate(90)"><stop stop-color="#3F5CD7"/><stop offset="1" stop-color="#2B48C2"/></linearGradient><filter id="s"><feOffset dy="10"/><feGaussianBlur result="b" stdDeviation="20"/><feFlood flood-opacity=".8"/><feComposite in2="b" operator="in"/><feComposite in="SourceGraphic"/></filter><path id="t" d="M320.733,150.5C395.427,21.125 604.573,21.125 679.267,150.5L918.29,564.5C992.985,693.875 888.412,875 739.023,875L260.977,875C111.588,875 7.015,693.875 81.71,564.5Z"/></defs><use href="#t" fill="#404040"/><path d="M786.297,335.88L620.208,169.792L379.792,410.208L620.208,650.624L592.674,710.104L412.362,529.792L171.946,770.208L276.738,875L739.023,875C888.412,875 992.985,693.875 918.29,564.5Z" fill-opacity=".125"/><circle cx="500" cy="290" r="170" fill="url(#r)"/><circle cx="707.846" cy="650" r="170" fill="url(#g)"/><circle cx="292.154" cy="650" r="170" fill="url(#b)"/><use href="#t" fill="none" stroke="#FFF" stroke-width="40" filter="url(#s)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +0,0 @@
|
||||
.abbreviated {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@import 'Time';
|
||||
@import 'variables';
|
||||
|
||||
.detailed-timer {
|
||||
position: relative;
|
||||
|
||||
@include time;
|
||||
|
||||
.detailed-timer-left-side {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
top: 0;
|
||||
left: $side-padding;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
|
||||
.detailed-timer-icon-container {
|
||||
height: 100%;
|
||||
padding: $vertical-padding $side-padding;
|
||||
margin-right: $side-padding;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.detailed-timer-icon-inner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
|
||||
.detailed-timer-icon {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-timer-left-side-text {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.detailed-timer-segment-name {
|
||||
position: absolute;
|
||||
top: $vertical-padding;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailed-timer-comparisons {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: $vertical-padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.graph {
|
||||
display: block;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@import 'Time';
|
||||
@import 'variables';
|
||||
|
||||
.key-value {
|
||||
height: $default-component-height;
|
||||
padding: 0 $side-padding;
|
||||
display: flex;
|
||||
|
||||
@include time;
|
||||
|
||||
&.key-value-two-rows {
|
||||
height: $two-row-height;
|
||||
padding-top: $vertical-padding;
|
||||
padding-bottom: $vertical-padding;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
|
||||
.key-value-value {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.key-value-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.key-value-key {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.key-value-left-right {
|
||||
.key-value-key {
|
||||
padding-right: $side-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.key-value-key {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.key-value-value {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.key-value-value-inner {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-15
@@ -1,27 +1,42 @@
|
||||
@import 'variables';
|
||||
@import 'Font';
|
||||
|
||||
.layout {
|
||||
font-family: "fira", sans-serif;
|
||||
font-size: $default-font-size;
|
||||
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.resizable-layout {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
height: 100% !important;
|
||||
top: 0;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
|
||||
.resizable-handle {
|
||||
.react-resizable {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.resizable-handle-east {
|
||||
cursor: e-resize;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
right: -10px;
|
||||
bottom: 10px;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
@import 'variables';
|
||||
|
||||
.separator {
|
||||
background-color: hsla(0, 0%, 43%, 1);
|
||||
height: $thick-separator-width;
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@use 'sass:math';
|
||||
|
||||
@import "Time";
|
||||
@import 'variables';
|
||||
|
||||
$split-column-width: $default-line-height * 3;
|
||||
$split-label-height: $default-component-height - $thick-separator-width;
|
||||
$split-icon-size: $default-component-height - $vertical-padding * 2;
|
||||
$split-icon-two-row-size: $two-row-height - $vertical-padding * 2;
|
||||
|
||||
.splits {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.split {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $default-component-height;
|
||||
|
||||
@include time;
|
||||
|
||||
&.two-rows {
|
||||
height: $two-row-height;
|
||||
|
||||
.split-rows {
|
||||
flex-wrap: wrap;
|
||||
padding-top: $vertical-padding;
|
||||
padding-bottom: $vertical-padding;
|
||||
|
||||
.split-row {
|
||||
height: initial;
|
||||
|
||||
&.split-second-row {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.split-icon-container {
|
||||
width: $split-icon-two-row-size;
|
||||
}
|
||||
}
|
||||
|
||||
.split-icon-container {
|
||||
padding: $vertical-padding $side-padding;
|
||||
width: $split-icon-size;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.split-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(2px 2px 1px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
.split-icon-container-empty {
|
||||
padding-right: $side-padding;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-rows {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
.split-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
&.split-first-row,
|
||||
.split-name {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.split-second-row,
|
||||
.split-time {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.split-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
padding: 0 $side-padding;
|
||||
padding-left: 0;
|
||||
height: 100%;
|
||||
|
||||
.split-name-inner {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.split-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 $side-padding;
|
||||
padding-left: 0;
|
||||
height: 100%;
|
||||
|
||||
&.split-time-full {
|
||||
width: $split-column-width;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.split-time-inner {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.split-label {
|
||||
height: $split-label-height;
|
||||
}
|
||||
|
||||
.current-split-background,
|
||||
.split-borders {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.split-borders {
|
||||
border-bottom-width: $thin-separator-width;
|
||||
border-top-width: $thin-separator-width;
|
||||
border-bottom-style: solid;
|
||||
border-top-style: solid;
|
||||
top: math.div(-$thick-separator-width, 2);
|
||||
height: calc(100% + #{math.div($thick-separator-width, 2)});
|
||||
|
||||
&.separator-above {
|
||||
border-top-width: $thick-separator-width;
|
||||
border-top-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@mixin time {
|
||||
.time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.timer {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
@import "variables";
|
||||
|
||||
$game-icon-size: $two-row-height - $vertical-padding * 2;
|
||||
|
||||
.title {
|
||||
height: $two-row-height;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding-top: $vertical-padding;
|
||||
padding-bottom: $vertical-padding;
|
||||
|
||||
.game-icon-container {
|
||||
max-width: $game-icon-size;
|
||||
max-height: $game-icon-size;
|
||||
padding: $vertical-padding 0;
|
||||
padding-left: $side-padding;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-info {
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.title-game {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50%;
|
||||
padding: 0 $side-padding;
|
||||
|
||||
&.centered .abbreviated {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.lower-row {
|
||||
display: flex;
|
||||
position: relative;
|
||||
height: 50%;
|
||||
|
||||
&.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.one-line {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
padding: 0 $side-padding;
|
||||
|
||||
&.centered .abbreviated {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.title-attempts,
|
||||
.title-attempts-invisible {
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.one-line {
|
||||
height: 50%;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.title-attempts {
|
||||
justify-content: flex-end;
|
||||
padding-right: $side-padding;
|
||||
}
|
||||
|
||||
.title-attempts-invisible {
|
||||
justify-content: flex-start;
|
||||
padding-left: $side-padding;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ try {
|
||||
layout,
|
||||
hotkeys,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
} = await LiveSplit.loadStoredData();
|
||||
|
||||
function requestWakeLock() {
|
||||
@@ -43,6 +44,10 @@ try {
|
||||
}
|
||||
});
|
||||
|
||||
// The renderer requires the fonts to be loaded before it gets created
|
||||
// as otherwise information may be cached incorrectly.
|
||||
await document.fonts.ready;
|
||||
|
||||
ReactDOM.render(
|
||||
<div>
|
||||
<LiveSplit
|
||||
@@ -51,6 +56,7 @@ try {
|
||||
hotkeys={hotkeys}
|
||||
splitsKey={splitsKey}
|
||||
layoutWidth={layoutWidth}
|
||||
layoutHeight={layoutHeight}
|
||||
/>
|
||||
<ToastContainer
|
||||
position={toast.POSITION.BOTTOM_RIGHT}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { expect } from "../util/OptionUtil";
|
||||
import { install } from "resize-observer";
|
||||
|
||||
import "../css/Abbreviated.scss";
|
||||
|
||||
if (!(window as any).ResizeObserver) { install(); }
|
||||
|
||||
export interface Props {
|
||||
abbreviations: string[],
|
||||
}
|
||||
|
||||
export default class Abbreviated extends React.Component<Props> {
|
||||
public sorted: string[];
|
||||
public oldAbbreviations: string[];
|
||||
public element: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.element = React.createRef();
|
||||
this.sorted = [...this.props.abbreviations];
|
||||
this.sorted.sort((a, b) => b.length - a.length);
|
||||
this.oldAbbreviations = this.props.abbreviations;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const element = expect(this.element.current, "Element should exist by now.");
|
||||
new (window as any).ResizeObserver(() => {
|
||||
this.chooseAbbreviation();
|
||||
}).observe(element);
|
||||
|
||||
this.chooseAbbreviation();
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
const arrEq = (a: string[], b: string[]) => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (!arrEq(this.props.abbreviations, this.oldAbbreviations)) {
|
||||
this.oldAbbreviations = this.props.abbreviations;
|
||||
this.sorted = [...this.props.abbreviations];
|
||||
this.sorted.sort((a, b) => b.length - a.length);
|
||||
this.chooseAbbreviation();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div
|
||||
className="abbreviated"
|
||||
ref={this.element}
|
||||
/>;
|
||||
}
|
||||
|
||||
private chooseAbbreviation() {
|
||||
if (this.element.current === null) {
|
||||
// We can still get here for a short time when unmounting.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const abbrev of this.sorted) {
|
||||
this.element.current.innerText = abbrev;
|
||||
if (this.element.current.scrollWidth <= this.element.current.clientWidth) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.element.current.innerText = this.sorted[0];
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { LayoutStateJson } from "../livesplit-core";
|
||||
import AutoRefresh from "../util/AutoRefresh";
|
||||
import Layout from "./Layout";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
getState: () => LayoutStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
allowResize: boolean,
|
||||
width: number,
|
||||
onResize(width: number): void,
|
||||
}
|
||||
|
||||
export interface State {
|
||||
layoutState: LayoutStateJson,
|
||||
}
|
||||
|
||||
export default class AutoRefreshLayout extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
layoutState: this.props.getState(),
|
||||
};
|
||||
}
|
||||
|
||||
public refreshLayout() {
|
||||
this.setState({
|
||||
layoutState: this.props.getState(),
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<AutoRefresh update={() => this.refreshLayout()} >
|
||||
<Layout
|
||||
state={this.state.layoutState}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
allowResize={this.props.allowResize}
|
||||
width={this.props.width}
|
||||
onResize={this.props.onResize}
|
||||
/>
|
||||
</AutoRefresh>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { gradientToCss } from "../util/ColorUtil";
|
||||
|
||||
export interface Props { state: LiveSplit.BlankSpaceComponentStateJson }
|
||||
|
||||
export default class BlankSpace extends React.Component<Props> {
|
||||
public render() {
|
||||
return (
|
||||
<div
|
||||
className="blank-space"
|
||||
style={{
|
||||
height: this.props.state.size,
|
||||
background: gradientToCss(this.props.state.background),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as React from "react";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
import { ComponentStateJson } from "../livesplit-core";
|
||||
import { assertNever } from "../util/OptionUtil";
|
||||
import BlankSpace from "./BlankSpace";
|
||||
import KeyValue from "./KeyValue";
|
||||
import DetailedTimer from "./DetailedTimer";
|
||||
import Graph from "./Graph";
|
||||
import Separator from "./Separator";
|
||||
import Splits from "./Splits";
|
||||
import Text from "./Text";
|
||||
import Timer from "./Timer";
|
||||
import Title from "./Title";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
state: ComponentStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
componentId: string,
|
||||
}
|
||||
|
||||
export default class Component extends React.Component<Props> {
|
||||
public render() {
|
||||
const { state } = this.props;
|
||||
if ("KeyValue" in state) {
|
||||
return <KeyValue
|
||||
state={state.KeyValue}
|
||||
/>;
|
||||
} else if ("DetailedTimer" in state) {
|
||||
return <DetailedTimer
|
||||
state={state.DetailedTimer}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
/>;
|
||||
} else if ("Graph" in state) {
|
||||
return <Graph
|
||||
state={state.Graph}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
/>;
|
||||
} else if ("Splits" in state) {
|
||||
return <Splits
|
||||
state={state.Splits}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
/>;
|
||||
} else if ("Text" in state) {
|
||||
return <Text
|
||||
state={state.Text}
|
||||
/>;
|
||||
} else if ("Timer" in state) {
|
||||
return <Timer
|
||||
state={state.Timer}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
componentId={this.props.componentId}
|
||||
/>;
|
||||
} else if ("Title" in state) {
|
||||
return <Title
|
||||
state={state.Title}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
/>;
|
||||
} else if ("Separator" in state) {
|
||||
return <Separator />;
|
||||
} else if ("BlankSpace" in state) {
|
||||
return <BlankSpace state={state.BlankSpace} />;
|
||||
} else {
|
||||
return assertNever(state);
|
||||
}
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
|
||||
return !deepEqual(nextProps.state, this.props.state) ||
|
||||
nextProps.layoutWidth !== this.props.layoutWidth ||
|
||||
nextProps.componentId !== this.props.componentId;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { colorToCss, gradientToCss } from "../util/ColorUtil";
|
||||
import { renderToSVG } from "./Timer";
|
||||
|
||||
import "../css/DetailedTimer.scss";
|
||||
import variables from "../css/variables.scss";
|
||||
import { map } from "../util/OptionUtil";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
const fontSizeToLineHeightRatio = parseFloat(variables.fontSizeToLineHeightRatio);
|
||||
const lineHeightToComponentHeightRatio = parseFloat(variables.lineHeightToComponentHeightRatio);
|
||||
const sidePadding = parseFloat(variables.sidePadding);
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.DetailedTimerComponentStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
}
|
||||
|
||||
export default class DetailedTimer extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const icon = this.props.layoutUrlCache.cache(this.props.state.icon);
|
||||
|
||||
const totalHeight = this.props.state.timer.height + this.props.state.segment_timer.height;
|
||||
const topRowHeight = totalHeight * 0.55;
|
||||
const bottomRowHeight = totalHeight - topRowHeight;
|
||||
const topRowFontSize = topRowHeight * 0.5 * fontSizeToLineHeightRatio;
|
||||
const bottomRowFontSize = bottomRowHeight * 0.5 * lineHeightToComponentHeightRatio * fontSizeToLineHeightRatio;
|
||||
|
||||
const comparisonNamesColor = map(this.props.state.comparison_names_color, colorToCss);
|
||||
const comparisonTimesColor = map(this.props.state.comparison_times_color, colorToCss);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="detailed-timer"
|
||||
style={{
|
||||
background: gradientToCss(this.props.state.background),
|
||||
height: totalHeight,
|
||||
}}
|
||||
>
|
||||
{
|
||||
renderToSVG(
|
||||
this.props.state.timer,
|
||||
"timer",
|
||||
this.props.layoutWidth,
|
||||
)
|
||||
}
|
||||
{
|
||||
renderToSVG(
|
||||
this.props.state.segment_timer,
|
||||
"segment-timer",
|
||||
this.props.layoutWidth,
|
||||
)
|
||||
}
|
||||
<div className="detailed-timer-left-side">
|
||||
{
|
||||
icon !== undefined &&
|
||||
<div className="detailed-timer-icon-container" style={{ width: totalHeight }}>
|
||||
<div className="detailed-timer-icon-inner-container">
|
||||
<img className="detailed-timer-icon" src={icon} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="detailed-timer-left-side-text">
|
||||
{
|
||||
this.props.state.segment_name !== null &&
|
||||
<div
|
||||
className="detailed-timer-segment-name"
|
||||
style={{
|
||||
color: map(this.props.state.segment_name_color, colorToCss),
|
||||
height: topRowHeight,
|
||||
fontSize: topRowFontSize,
|
||||
}}
|
||||
>
|
||||
{this.props.state.segment_name}
|
||||
</div>
|
||||
}
|
||||
<table
|
||||
className="detailed-timer-comparisons"
|
||||
style={{ height: bottomRowHeight, fontSize: bottomRowFontSize }}
|
||||
>
|
||||
<tbody>
|
||||
{
|
||||
this.props.state.comparison1 !== null &&
|
||||
formatComparison(
|
||||
this.props.state.comparison1,
|
||||
comparisonNamesColor,
|
||||
comparisonTimesColor,
|
||||
)
|
||||
}
|
||||
{
|
||||
this.props.state.comparison2 !== null &&
|
||||
formatComparison(
|
||||
this.props.state.comparison2,
|
||||
comparisonNamesColor,
|
||||
comparisonTimesColor,
|
||||
)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatComparison(
|
||||
comparison: LiveSplit.DetailedTimerComponentComparisonStateJson,
|
||||
comparisonNamesColor?: string,
|
||||
comparisonTimesColor?: string,
|
||||
) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ color: comparisonNamesColor, padding: 0 }}>
|
||||
{comparison.name + ":"}
|
||||
</td>
|
||||
<td className="time" style={{
|
||||
color: comparisonTimesColor,
|
||||
padding: 0,
|
||||
paddingLeft: sidePadding,
|
||||
}}>
|
||||
{comparison.time}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { LayoutStateJson } from "../livesplit-core";
|
||||
import AutoRefresh from "../util/AutoRefresh";
|
||||
import { backgroundToCss, colorToCss } from "../util/ColorUtil";
|
||||
import { Option } from "../util/OptionUtil";
|
||||
import Component from "./Component";
|
||||
import { getLayoutStateStyle } from "./Layout";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
getState: () => LayoutStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
onClick: (componentIndex: number) => void,
|
||||
onDrag: (componentIndex: number) => void,
|
||||
onDragEnd: (componentIndex: number) => void,
|
||||
onDrop: (componentIndex: number) => void,
|
||||
isSelected: (componentIndex: number) => boolean,
|
||||
}
|
||||
|
||||
export interface State {
|
||||
layoutState: LayoutStateJson,
|
||||
startIndex: Option<number>,
|
||||
hoverIndex: Option<number>,
|
||||
}
|
||||
|
||||
export default class DragAutoRefreshLayout extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hoverIndex: null,
|
||||
layoutState: this.props.getState(),
|
||||
startIndex: null,
|
||||
};
|
||||
}
|
||||
|
||||
public refreshLayout() {
|
||||
this.setState({
|
||||
layoutState: this.props.getState(),
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
const layoutState = this.state.layoutState;
|
||||
const layoutStateStyle = getLayoutStateStyle(layoutState);
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
const background = backgroundToCss(
|
||||
layoutState.background,
|
||||
this.props.layoutUrlCache,
|
||||
this.props.layoutWidth,
|
||||
);
|
||||
|
||||
const dragLayout = (
|
||||
<div
|
||||
className="layout"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
color: colorToCss(layoutState.text_color),
|
||||
width: this.props.layoutWidth,
|
||||
...layoutStateStyle
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: -1,
|
||||
...background,
|
||||
}} />
|
||||
{
|
||||
layoutState.components.map((c, i) => {
|
||||
const componentType = Object.keys(c)[0];
|
||||
const id = counts.get(componentType) || 0;
|
||||
counts.set(componentType, id + 1);
|
||||
|
||||
const key = `${componentType}${id}`;
|
||||
|
||||
return <div
|
||||
key={key}
|
||||
onClick={(_) => this.props.onClick(i)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", "");
|
||||
this.setState({
|
||||
...this.state,
|
||||
hoverIndex: i,
|
||||
startIndex: i,
|
||||
});
|
||||
this.props.onDrag(i);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDragEnter={(_) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
hoverIndex: i,
|
||||
});
|
||||
}}
|
||||
onDragEnd={(_) => {
|
||||
this.props.onDragEnd(i);
|
||||
this.setState({
|
||||
...this.state,
|
||||
hoverIndex: null,
|
||||
startIndex: null,
|
||||
});
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
this.props.onDrop(i);
|
||||
return false;
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{
|
||||
getBorderDiv(i, this.state, this.props)
|
||||
}
|
||||
<Component
|
||||
state={c}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
componentId={key}
|
||||
/>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoRefresh update={() => this.refreshLayout()}>
|
||||
{dragLayout}
|
||||
</AutoRefresh>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getBorderDiv(index: number, state: State, props: Props): Option<JSX.Element> {
|
||||
const style: any = {
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
zIndex: 2,
|
||||
};
|
||||
|
||||
if (state.startIndex == null || state.startIndex === state.hoverIndex) {
|
||||
if (props.isSelected(index)) {
|
||||
style.border = "2px solid rgb(50, 114, 241)";
|
||||
style.width = "calc(100% - 4px)";
|
||||
style.height = "calc(100% - 4px)";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else if (index === state.hoverIndex) {
|
||||
if (index < state.startIndex) {
|
||||
style.borderTop = "2px solid rgb(50, 114, 241)";
|
||||
} else {
|
||||
style.borderBottom = "2px solid rgb(50, 114, 241)";
|
||||
style.height = "calc(100% - 2px)";
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div style={style} />;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { colorToCss } from "../util/ColorUtil";
|
||||
|
||||
import "../css/Graph.scss";
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.GraphComponentStateJson,
|
||||
layoutWidth: number,
|
||||
}
|
||||
|
||||
export default class Graph extends React.Component<Props> {
|
||||
public render() {
|
||||
const width = this.props.layoutWidth;
|
||||
const height = this.props.state.height;
|
||||
|
||||
const middle = height * this.props.state.middle;
|
||||
|
||||
const colorTop = colorToCss(this.props.state.top_background_color);
|
||||
const colorBottom = colorToCss(this.props.state.bottom_background_color);
|
||||
const colorGridLines = colorToCss(this.props.state.grid_lines_color);
|
||||
const colorGraphLines = colorToCss(this.props.state.graph_lines_color);
|
||||
const colorPartialFill = colorToCss(this.props.state.partial_fill_color);
|
||||
const colorCompleteFill = colorToCss(this.props.state.complete_fill_color);
|
||||
const colorBestSegment = colorToCss(this.props.state.best_segment_color);
|
||||
|
||||
const rect1 = <rect
|
||||
key="rect1"
|
||||
width="100%"
|
||||
height={middle}
|
||||
style={{ fill: colorTop }}
|
||||
/>;
|
||||
const rect2 = <rect
|
||||
key="rect2"
|
||||
y={middle}
|
||||
width="100%"
|
||||
height={height - middle}
|
||||
style={{ fill: colorBottom }}
|
||||
/>;
|
||||
const children = [rect1, rect2];
|
||||
|
||||
this.props.state.horizontal_grid_lines.forEach((y, i) => {
|
||||
const line = <line
|
||||
key={`hline${i}`}
|
||||
x1="0" y1={height * y}
|
||||
x2="100%" y2={height * y}
|
||||
style={{
|
||||
stroke: colorGridLines,
|
||||
strokeWidth: "2",
|
||||
}}
|
||||
/>;
|
||||
children.push(line);
|
||||
});
|
||||
|
||||
this.props.state.vertical_grid_lines.forEach((x, i) => {
|
||||
const line = <line
|
||||
key={`vline${i}`}
|
||||
x1={`${x * 100}%`} y1="0"
|
||||
x2={`${x * 100}%`} y2={height}
|
||||
style={{
|
||||
stroke: colorGridLines,
|
||||
strokeWidth: "2",
|
||||
}}
|
||||
/>;
|
||||
children.push(line);
|
||||
});
|
||||
|
||||
let points = "";
|
||||
|
||||
let length = this.props.state.points.length;
|
||||
if (this.props.state.is_live_delta_active) {
|
||||
length -= 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const point = this.props.state.points[i];
|
||||
points += `${width * point.x},${height * point.y} `;
|
||||
}
|
||||
|
||||
points += `${width * this.props.state.points[length - 1].x},${middle}`;
|
||||
|
||||
const fill = <polygon key="fill" points={points} style={{
|
||||
fill: colorCompleteFill,
|
||||
}} />;
|
||||
|
||||
children.push(fill);
|
||||
|
||||
if (this.props.state.is_live_delta_active) {
|
||||
const x1 = width * this.props.state.points[length - 1].x;
|
||||
const y1 = height * this.props.state.points[length - 1].y;
|
||||
const x2 = width * this.props.state.points[length].x;
|
||||
const y2 = height * this.props.state.points[length].y;
|
||||
|
||||
const fill = <polygon
|
||||
key="pfill"
|
||||
points={`${x1},${middle} ${x1},${y1} ${x2},${y2} ${x2},${middle}`}
|
||||
style={{
|
||||
fill: colorPartialFill,
|
||||
}}
|
||||
/>;
|
||||
|
||||
children.push(fill);
|
||||
}
|
||||
|
||||
const childrenPoints = [];
|
||||
|
||||
for (let i = 1; i < this.props.state.points.length; i++) {
|
||||
const px = `${100 * this.props.state.points[i - 1].x}%`;
|
||||
const py = height * this.props.state.points[i - 1].y;
|
||||
const x = `${100 * this.props.state.points[i].x}%`;
|
||||
const y = height * this.props.state.points[i].y;
|
||||
|
||||
const fill = this.props.state.points[i].is_best_segment
|
||||
? colorBestSegment
|
||||
: colorGraphLines;
|
||||
|
||||
const line = <line
|
||||
key={`s${i}`}
|
||||
x1={px} y1={py}
|
||||
x2={x} y2={y}
|
||||
style={{
|
||||
stroke: fill,
|
||||
strokeWidth: "2",
|
||||
}}
|
||||
/>;
|
||||
children.push(line);
|
||||
|
||||
if (i !== this.props.state.points.length - 1 || !this.props.state.is_live_delta_active) {
|
||||
const circle = <ellipse
|
||||
key={`p${i}`}
|
||||
cx={x} cy={y}
|
||||
rx="2.5" ry="2.5"
|
||||
style={{ fill }}
|
||||
/>;
|
||||
childrenPoints.push(circle);
|
||||
}
|
||||
}
|
||||
|
||||
children.push(...childrenPoints);
|
||||
|
||||
return (<svg className="graph" width={width} height={height}>{children}</svg>);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import KeyValueGeneric, { KeyValueDisplay } from "./KeyValueGeneric";
|
||||
|
||||
import "../css/KeyValue.scss";
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.KeyValueComponentStateJson,
|
||||
}
|
||||
|
||||
export default class KeyValue extends React.Component<Props> {
|
||||
public render() {
|
||||
return <KeyValueGeneric
|
||||
display={this.props.state.display_two_rows ?
|
||||
KeyValueDisplay.SplitTwoRows :
|
||||
KeyValueDisplay.SplitOneRow
|
||||
}
|
||||
keyText={this.props.state.key}
|
||||
keyAbbreviations={this.props.state.key_abbreviations}
|
||||
keyColor={this.props.state.key_color}
|
||||
valueText={this.props.state.value}
|
||||
valueColor={this.props.state.value_color}
|
||||
wrapperBackground={this.props.state.background}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
|
||||
import { colorToCss, gradientToCss } from "../util/ColorUtil";
|
||||
import { map } from "../util/OptionUtil";
|
||||
import Abbreviated from "./Abbreviated";
|
||||
|
||||
import "../css/KeyValue.scss";
|
||||
|
||||
export enum KeyValueDisplay {
|
||||
Center,
|
||||
SplitOneRow,
|
||||
SplitTwoRows,
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
display: KeyValueDisplay,
|
||||
keyColor: LiveSplit.Color | null,
|
||||
keyText: string,
|
||||
keyAbbreviations: string[],
|
||||
valueColor: LiveSplit.Color | null,
|
||||
valueText: string | null,
|
||||
wrapperBackground: LiveSplit.Gradient,
|
||||
}
|
||||
|
||||
export default class KeyValueGeneric extends React.Component<Props> {
|
||||
public render() {
|
||||
const keyCell = <div
|
||||
className="key-value-key text-font"
|
||||
style={{
|
||||
color: map(this.props.keyColor, colorToCss),
|
||||
}}
|
||||
>
|
||||
<Abbreviated abbreviations={[...this.props.keyAbbreviations, this.props.keyText]} />
|
||||
</div>;
|
||||
|
||||
const valueCell = <div
|
||||
className="key-value-value time times-font"
|
||||
style={{
|
||||
color: map(this.props.valueColor, colorToCss),
|
||||
}}
|
||||
>
|
||||
<div className="key-value-value-inner">{this.props.valueText}</div>
|
||||
</div>;
|
||||
|
||||
let keyValueRows: JSX.Element;
|
||||
let wrapperClassName: string;
|
||||
if (this.props.display === KeyValueDisplay.Center) {
|
||||
keyValueRows = keyCell;
|
||||
wrapperClassName = "key-value-center";
|
||||
} else {
|
||||
keyValueRows = <>
|
||||
{keyCell}
|
||||
{valueCell}
|
||||
</>;
|
||||
|
||||
if (this.props.display === KeyValueDisplay.SplitTwoRows) {
|
||||
wrapperClassName = "key-value-two-rows";
|
||||
} else {
|
||||
wrapperClassName = "key-value-left-right";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`key-value ${wrapperClassName}`}
|
||||
style={{
|
||||
background: gradientToCss(this.props.wrapperBackground),
|
||||
}}
|
||||
>
|
||||
{keyValueRows}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+53
-130
@@ -1,145 +1,68 @@
|
||||
import * as React from "react";
|
||||
import { Font, FontStretch, FontStyle, FontWeight, LayoutStateJson } from "../livesplit-core";
|
||||
import { backgroundToCss, colorToCss } from "../util/ColorUtil";
|
||||
import Component from "./Component";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
|
||||
import "../css/Layout.scss";
|
||||
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";
|
||||
|
||||
interface LayoutStateStyle {
|
||||
"--thin-separators-color": string,
|
||||
"--separators-color": string,
|
||||
|
||||
"--timer-font-family"?: string,
|
||||
"--timer-font-style"?: FontStyle,
|
||||
"--timer-font-weight"?: string | number,
|
||||
"--timer-font-stretch"?: FontStretch,
|
||||
|
||||
"--times-font-family"?: string,
|
||||
"--times-font-style"?: FontStyle,
|
||||
"--times-font-weight"?: string | number,
|
||||
"--times-font-stretch"?: FontStretch,
|
||||
|
||||
"--text-font-family"?: string,
|
||||
"--text-font-style"?: FontStyle,
|
||||
"--text-font-weight"?: string | number,
|
||||
"--text-font-stretch"?: FontStretch,
|
||||
}
|
||||
import "../css/Layout.scss";
|
||||
|
||||
export interface Props {
|
||||
state: LayoutStateJson,
|
||||
getState: () => LayoutStateRef,
|
||||
layoutUrlCache: UrlCache,
|
||||
allowResize: boolean,
|
||||
width: number,
|
||||
onResize(width: number): void,
|
||||
height: number,
|
||||
renderer: WebRenderer,
|
||||
onResize(width: number, height: number): void,
|
||||
}
|
||||
|
||||
export default class Layout extends React.Component<Props> {
|
||||
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() {
|
||||
const layoutState = getLayoutStateStyle(this.props.state);
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
const background = backgroundToCss(
|
||||
this.props.state.background,
|
||||
this.props.layoutUrlCache,
|
||||
this.props.width,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="layout"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
color: colorToCss(this.props.state.text_color),
|
||||
width: this.props.width,
|
||||
...layoutState
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: -1,
|
||||
...background,
|
||||
}} />
|
||||
{this.props.allowResize &&
|
||||
<ResizableBox
|
||||
className="resizable-layout"
|
||||
width={this.props.width}
|
||||
minConstraints={[160, 0]}
|
||||
height={0}
|
||||
handle={<span onClick={(event: React.SyntheticEvent) => { event.stopPropagation(); }} className="resizable-handle" />}
|
||||
onResize={
|
||||
(
|
||||
_event: React.SyntheticEvent,
|
||||
data: ResizeCallbackData,
|
||||
) => this.props.onResize(data.size.width)
|
||||
}
|
||||
axis="x"
|
||||
<AutoRefresh 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.state.components.map((c) => {
|
||||
const componentType = Object.keys(c)[0];
|
||||
const id = counts.get(componentType) || 0;
|
||||
counts.set(componentType, id + 1);
|
||||
|
||||
const key = `${componentType}${id}`;
|
||||
|
||||
return <Component
|
||||
key={key}
|
||||
state={c}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
componentId={key}
|
||||
layoutWidth={this.props.width}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLayoutStateStyle(state: LayoutStateJson): LayoutStateStyle {
|
||||
const style = {
|
||||
"--thin-separators-color": colorToCss(state.thin_separators_color),
|
||||
"--separators-color": colorToCss(state.separators_color),
|
||||
"--times-font-weight": "bold",
|
||||
};
|
||||
|
||||
fillFont(style, "timer", state.timer_font, "timer, sans-serif");
|
||||
fillFont(style, "times", state.times_font, "fira, sans-serif");
|
||||
fillFont(style, "text", state.text_font, "fira, sans-serif");
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
function fillFont(style: any, prefix: string, font: Font | null, defaultName: string) {
|
||||
if (font == null) {
|
||||
style[`--${prefix}-font-family`] = defaultName;
|
||||
return;
|
||||
}
|
||||
style[`--${prefix}-font-family`] = `"${font.family}", ${defaultName}`;
|
||||
style[`--${prefix}-font-style`] = font.style;
|
||||
style[`--${prefix}-font-weight`] = translateWeight(font.weight);
|
||||
style[`--${prefix}-font-stretch`] = font.stretch;
|
||||
}
|
||||
|
||||
function translateWeight(weight: FontWeight): string | number {
|
||||
switch (weight) {
|
||||
case "bold":
|
||||
case "normal": return weight;
|
||||
case "thin": return 100;
|
||||
case "extra-light": return 200;
|
||||
case "light": return 300;
|
||||
case "semi-light": return 350;
|
||||
case "normal": return 400;
|
||||
case "medium": return 500;
|
||||
case "semi-bold": return 600;
|
||||
case "bold": return 700;
|
||||
case "extra-bold": return 800;
|
||||
case "black": return 900;
|
||||
case "extra-black": return 950;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import "../css/Separator.scss";
|
||||
|
||||
export default class Separator extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
<div
|
||||
className="separator"
|
||||
style={{
|
||||
backgroundColor: "var(--separators-color)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import * as React from "react";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { colorToCss, gradientToCss } from "../util/ColorUtil";
|
||||
import { Option } from "../util/OptionUtil";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
splitsState: {
|
||||
has_icons: boolean,
|
||||
show_thin_separators: boolean,
|
||||
display_two_rows: boolean,
|
||||
current_split_gradient: LiveSplit.Gradient,
|
||||
},
|
||||
evenOdd: [Option<string>, Option<string>],
|
||||
split: LiveSplit.SplitStateJson,
|
||||
separatorInFrontOfSplit: boolean,
|
||||
visualSplitIndex: number,
|
||||
layoutUrlCache: UrlCache,
|
||||
}
|
||||
|
||||
export default class Split extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const currentSplit = this.props.split.is_current_split ? "current-split" : "";
|
||||
const twoRows = this.props.splitsState.display_two_rows ? "two-rows" : "";
|
||||
|
||||
const splitsHaveIcons = this.props.splitsState.has_icons;
|
||||
const icon = splitsHaveIcons
|
||||
? this.props.layoutUrlCache.cache(this.props.split.icon)
|
||||
: undefined;
|
||||
|
||||
const innerStyle: any = {};
|
||||
const outerStyle: any = {};
|
||||
|
||||
if (this.props.split.index % 2 === 1) {
|
||||
if (this.props.splitsState.show_thin_separators) {
|
||||
innerStyle.borderBottomColor = "var(--thin-separators-color)";
|
||||
} else {
|
||||
innerStyle.borderBottomColor = "transparent";
|
||||
}
|
||||
outerStyle.backgroundColor = this.props.evenOdd[1];
|
||||
} else {
|
||||
innerStyle.borderBottomColor = "transparent";
|
||||
outerStyle.backgroundColor = this.props.evenOdd[0];
|
||||
}
|
||||
innerStyle.borderTopColor = innerStyle.borderBottomColor;
|
||||
|
||||
let separatorAbove = "";
|
||||
if (this.props.separatorInFrontOfSplit) {
|
||||
innerStyle.borderTopColor = "var(--separators-color)";
|
||||
separatorAbove = "separator-above";
|
||||
}
|
||||
|
||||
const currentSplitBackgroundStyle: any = {};
|
||||
if (this.props.split.is_current_split) {
|
||||
currentSplitBackgroundStyle.background = gradientToCss(this.props.splitsState.current_split_gradient);
|
||||
}
|
||||
|
||||
let lastEmptyColumnIndex = this.props.split.columns.length;
|
||||
for (let i = this.props.split.columns.length - 1; i >= 0; i--) {
|
||||
const column = this.props.split.columns[i];
|
||||
if (!column.value) {
|
||||
lastEmptyColumnIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = this.props.split.columns.slice(0, lastEmptyColumnIndex);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={["split", currentSplit, twoRows].filter((s) => s.length > 0).join(" ")}
|
||||
style={outerStyle}
|
||||
>
|
||||
<div className="current-split-background" style={currentSplitBackgroundStyle}></div>
|
||||
<div className={`split-borders ${separatorAbove}`} style={innerStyle}></div>
|
||||
<div
|
||||
key="split-icon"
|
||||
className={splitsHaveIcons ? "split-icon-container" : "split-icon-container-empty"}
|
||||
>
|
||||
{
|
||||
splitsHaveIcons && icon !== undefined &&
|
||||
<img className="split-icon" src={icon} />
|
||||
}
|
||||
</div>
|
||||
<div className="split-rows">
|
||||
<div className="split-row split-first-row">
|
||||
<div
|
||||
key="split-name"
|
||||
className="split-name text-font"
|
||||
>
|
||||
<div className="split-name-inner">
|
||||
{this.props.split.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="split-row split-second-row"
|
||||
>
|
||||
{
|
||||
columns.map((column, i) =>
|
||||
<div
|
||||
key={i}
|
||||
className={`split-time time times-font ${i < columns.length - 1 ? "split-time-full" : ""}`}
|
||||
style={{
|
||||
color: colorToCss(column.visual_color),
|
||||
}}
|
||||
>
|
||||
<div className="split-time-inner">
|
||||
{column.value}
|
||||
</div>
|
||||
</div>,
|
||||
).reverse()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
|
||||
return !deepEqual(nextProps.splitsState, this.props.splitsState) ||
|
||||
!deepEqual(nextProps.evenOdd, this.props.evenOdd) ||
|
||||
!deepEqual(nextProps.split, this.props.split) ||
|
||||
nextProps.separatorInFrontOfSplit !== this.props.separatorInFrontOfSplit ||
|
||||
nextProps.visualSplitIndex !== this.props.visualSplitIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface Props {
|
||||
labels: string[],
|
||||
}
|
||||
|
||||
export default class SplitLabels extends React.Component<Props> {
|
||||
public render() {
|
||||
return (
|
||||
<span
|
||||
className="split split-label text-font"
|
||||
>
|
||||
<div className="current-split-background" />
|
||||
<div className="split-icon-container-empty" />
|
||||
<div className="split-rows">
|
||||
<div className="split-row split-first-row">
|
||||
<div className="split-name" />
|
||||
</div>
|
||||
<div className="split-row split-second-row">
|
||||
{
|
||||
this.props.labels.map((label, i) =>
|
||||
<div
|
||||
key={i}
|
||||
className={`split-time split-label ${i < this.props.labels.length - 1 ? "split-time-full" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
).reverse()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import Split from "./Split";
|
||||
import { gradientToCss, colorToCss } from "../util/ColorUtil";
|
||||
import { Option, map } from "../util/OptionUtil";
|
||||
import SplitLabels from "./SplitLabels";
|
||||
|
||||
import "../css/Splits.scss";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.SplitsComponentStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
}
|
||||
|
||||
export default class Splits extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const background = this.props.state.background;
|
||||
const style: any = {};
|
||||
let evenOdd: [Option<string>, Option<string>] = [null, null];
|
||||
if ("Alternating" in background) {
|
||||
evenOdd = [
|
||||
colorToCss(background.Alternating[0]),
|
||||
colorToCss(background.Alternating[1]),
|
||||
];
|
||||
} else {
|
||||
style.background = gradientToCss(background.Same);
|
||||
}
|
||||
|
||||
const splitsState = {
|
||||
has_icons: this.props.state.has_icons,
|
||||
show_thin_separators: this.props.state.show_thin_separators,
|
||||
display_two_rows: this.props.state.display_two_rows,
|
||||
current_split_gradient: this.props.state.current_split_gradient
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="splits" style={style}>
|
||||
{
|
||||
map(
|
||||
this.props.state.column_labels,
|
||||
(labels) => <SplitLabels
|
||||
labels={labels}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
{
|
||||
this.props.state.splits.map((s, i) =>
|
||||
<Split
|
||||
evenOdd={evenOdd}
|
||||
split={s}
|
||||
splitsState={splitsState}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
key={s.index.toString()}
|
||||
separatorInFrontOfSplit={
|
||||
(this.props.state.show_final_separator &&
|
||||
i + 1 === this.props.state.splits.length)
|
||||
|| (i === 0 && this.props.state.column_labels !== null)
|
||||
}
|
||||
visualSplitIndex={i}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import KeyValueGeneric, { KeyValueDisplay } from "./KeyValueGeneric";
|
||||
|
||||
import "../css/KeyValue.scss";
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.TextComponentStateJson,
|
||||
}
|
||||
|
||||
export default class Text extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { text } = this.props.state;
|
||||
|
||||
if ("Center" in text) {
|
||||
return <KeyValueGeneric
|
||||
display={KeyValueDisplay.Center}
|
||||
keyColor={this.props.state.left_center_color}
|
||||
keyText={text.Center}
|
||||
keyAbbreviations={[]}
|
||||
valueColor={null}
|
||||
valueText={null}
|
||||
wrapperBackground={this.props.state.background}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <KeyValueGeneric
|
||||
display={this.props.state.display_two_rows ?
|
||||
KeyValueDisplay.SplitTwoRows :
|
||||
KeyValueDisplay.SplitOneRow
|
||||
}
|
||||
keyColor={this.props.state.left_center_color}
|
||||
keyText={text.Split[0]}
|
||||
keyAbbreviations={[]}
|
||||
valueColor={this.props.state.right_color}
|
||||
valueText={text.Split[1]}
|
||||
wrapperBackground={this.props.state.background}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { colorToCss, gradientToCss } from "../util/ColorUtil";
|
||||
|
||||
import variables from "../css/variables.scss";
|
||||
import "../css/Timer.scss";
|
||||
|
||||
const sidePadding = parseFloat(variables.sidePadding);
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.TimerComponentStateJson,
|
||||
componentId: string,
|
||||
layoutWidth: number,
|
||||
}
|
||||
|
||||
export function renderToSVG(
|
||||
state: LiveSplit.TimerComponentStateJson,
|
||||
className: string,
|
||||
componentWidth: number,
|
||||
): JSX.Element {
|
||||
const time = state.time;
|
||||
const fraction = state.fraction;
|
||||
const height = state.height;
|
||||
|
||||
const y = `${0.82 * height}px`;
|
||||
|
||||
const alignState: { x: string, element: SVGTextElement | null } = {
|
||||
x: "",
|
||||
element: null,
|
||||
};
|
||||
|
||||
const updateAlign = () => {
|
||||
alignState.element?.setAttribute("x", alignState.x);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="timer"
|
||||
style={{
|
||||
background: gradientToCss(state.background),
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
height={`${height}px`}
|
||||
style={{
|
||||
display: "block",
|
||||
filter: "drop-shadow(2px 2px 1px rgba(0, 0, 0, 0.5))",
|
||||
width: componentWidth,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${className}-text-gradient`} x1="0%" x2="0%" y1="0%" y2="100%">
|
||||
<stop
|
||||
offset="0%"
|
||||
style={{
|
||||
stopColor: colorToCss(state.top_color),
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
style={{
|
||||
stopColor: colorToCss(state.bottom_color),
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text
|
||||
ref={(instance) => {
|
||||
alignState.element = instance;
|
||||
updateAlign();
|
||||
}}
|
||||
className="timer-time timer-font"
|
||||
style={{
|
||||
fill: `url(#${className}-text-gradient)`,
|
||||
fontSize: `${0.9 * height}px`,
|
||||
fontVariant: "tabular-nums",
|
||||
}}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
>
|
||||
{time}
|
||||
</text>
|
||||
<text
|
||||
ref={(instance) => {
|
||||
if (instance != null) {
|
||||
alignState.x = `${componentWidth - sidePadding - instance.getComputedTextLength()}px`;
|
||||
}
|
||||
updateAlign();
|
||||
}}
|
||||
className="timer-time timer-font"
|
||||
style={{
|
||||
fill: `url(#${className}-text-gradient)`,
|
||||
fontSize: `${0.6 * height}px`,
|
||||
fontVariant: "tabular-nums",
|
||||
}}
|
||||
x={`${componentWidth - sidePadding}px`}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
>
|
||||
{fraction}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default class Timer extends React.Component<Props> {
|
||||
public render() {
|
||||
return renderToSVG(
|
||||
this.props.state,
|
||||
this.props.componentId,
|
||||
this.props.layoutWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { colorToCss, gradientToCss } from "../util/ColorUtil";
|
||||
import { map } from "../util/OptionUtil";
|
||||
|
||||
import "../css/Title.scss";
|
||||
import Abbreviated from "./Abbreviated";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
state: LiveSplit.TitleComponentStateJson,
|
||||
layoutUrlCache: UrlCache,
|
||||
}
|
||||
|
||||
export default class Title extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const iconUrl = this.props.layoutUrlCache.cache(this.props.state.icon);
|
||||
|
||||
const finishedRunsExist = this.props.state.finished_runs !== null;
|
||||
const attemptsExist = this.props.state.attempts !== null;
|
||||
const line2 = this.props.state.line2;
|
||||
const twoLines = line2.length !== 0;
|
||||
const showIcon = iconUrl !== undefined;
|
||||
const showAttempts = attemptsExist || finishedRunsExist;
|
||||
|
||||
let attemptsLabel = "";
|
||||
if (finishedRunsExist) {
|
||||
attemptsLabel += this.props.state.finished_runs;
|
||||
if (attemptsExist) {
|
||||
attemptsLabel += "/";
|
||||
}
|
||||
}
|
||||
if (attemptsExist) {
|
||||
attemptsLabel += this.props.state.attempts;
|
||||
}
|
||||
|
||||
const alignmentClass = this.props.state.is_centered ? "centered" : "left-align";
|
||||
const numLinesClass = twoLines ? "two-lines" : "one-line";
|
||||
const iconClass = showIcon ? "show-icon" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="title"
|
||||
style={{
|
||||
background: gradientToCss(this.props.state.background),
|
||||
color: map(this.props.state.text_color, colorToCss),
|
||||
}}
|
||||
>
|
||||
{
|
||||
showIcon && <div className="game-icon-container">
|
||||
<img className="title-icon" src={iconUrl} />
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
className={`run-info text-font ${alignmentClass} ${iconClass}`}
|
||||
>
|
||||
{
|
||||
twoLines && <span className={`title-game ${alignmentClass}`}>
|
||||
<Abbreviated abbreviations={this.props.state.line1} />
|
||||
</span>
|
||||
}
|
||||
<div className={`lower-row ${numLinesClass} ${alignmentClass}`} >
|
||||
{
|
||||
this.props.state.is_centered && showAttempts &&
|
||||
<div className={`title-attempts-invisible ${numLinesClass}`}>
|
||||
<div className="title-text">{attemptsLabel}</div>
|
||||
</div>
|
||||
}
|
||||
<div className={`title-category ${alignmentClass}`}>
|
||||
<Abbreviated abbreviations={
|
||||
twoLines ? line2 : this.props.state.line1
|
||||
} />
|
||||
</div>
|
||||
{
|
||||
showAttempts &&
|
||||
<div className={`title-attempts ${numLinesClass}`}>
|
||||
<div className="title-text">{attemptsLabel}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type HotkeyConfigSettings = unknown;
|
||||
export type LayoutSettings = unknown;
|
||||
|
||||
const DEFAULT_LAYOUT_WIDTH = 300;
|
||||
const DEFAULT_LAYOUT_HEIGHT = 500;
|
||||
|
||||
let db: Option<IDBPDatabase<unknown>> = null;
|
||||
|
||||
@@ -206,16 +207,20 @@ export async function loadHotkeys(): Promise<HotkeyConfigSettings | undefined> {
|
||||
return await db.get("settings", "hotkeys");
|
||||
}
|
||||
|
||||
export async function storeLayoutWidth(layoutWidth: number) {
|
||||
export async function storeLayoutDims(layoutWidth: number, layoutHeight: number) {
|
||||
const db = await getDb();
|
||||
|
||||
await db.put("settings", layoutWidth, "layoutWidth");
|
||||
await db.put("settings", layoutHeight, "layoutHeight");
|
||||
}
|
||||
|
||||
export async function loadLayoutWidth(): Promise<number> {
|
||||
export async function loadLayoutDims(): Promise<[number, number]> {
|
||||
const db = await getDb();
|
||||
|
||||
return await db.get("settings", "layoutWidth") ?? DEFAULT_LAYOUT_WIDTH;
|
||||
return [
|
||||
await db.get("settings", "layoutWidth") ?? DEFAULT_LAYOUT_WIDTH,
|
||||
await db.get("settings", "layoutHeight") ?? DEFAULT_LAYOUT_HEIGHT,
|
||||
];
|
||||
}
|
||||
|
||||
export async function storeSplitsKey(splitsKey?: number) {
|
||||
|
||||
+16
-15
@@ -1,18 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu";
|
||||
import DragAutoRefreshLayout from "../layout/DragAutoRefreshLayout";
|
||||
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 "../css/LayoutEditor.scss";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
editor: LiveSplit.LayoutEditor,
|
||||
layoutState: LiveSplit.LayoutStateRefMut,
|
||||
layoutEditorUrlCache: UrlCache,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
timer: LiveSplit.SharedTimerRef,
|
||||
renderer: WebRenderer,
|
||||
callbacks: Callbacks,
|
||||
}
|
||||
export interface State {
|
||||
@@ -21,6 +25,7 @@ export interface State {
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
onResize(width: number, height: number): Promise<void>,
|
||||
renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element): JSX.Element,
|
||||
closeLayoutEditor(save: boolean): void,
|
||||
}
|
||||
@@ -254,24 +259,20 @@ export class LayoutEditor extends React.Component<Props, State> {
|
||||
</div>
|
||||
</div>
|
||||
<div className="layout-container">
|
||||
<DragAutoRefreshLayout
|
||||
<Layout
|
||||
getState={() => this.props.timer.readWith(
|
||||
(t) => {
|
||||
const result = this.props.editor.layoutStateAsJson(this.props.layoutUrlCache.imageCache, t);
|
||||
this.props.editor.updateLayoutState(this.props.layoutState, this.props.layoutUrlCache.imageCache, t);
|
||||
this.props.layoutUrlCache.collect();
|
||||
return result;
|
||||
}
|
||||
return this.props.layoutState;
|
||||
},
|
||||
)}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
onClick={(i) => this.selectComponent(i)}
|
||||
onDrag={(i) => {
|
||||
this.props.editor.select(i);
|
||||
this.update();
|
||||
}}
|
||||
onDragEnd={(_) => this.update()}
|
||||
onDrop={(i) => this.props.editor.moveComponent(i)}
|
||||
isSelected={(i) => this.state.editor.selected_component === i}
|
||||
allowResize={true}
|
||||
width={this.props.layoutWidth}
|
||||
height={this.props.layoutHeight}
|
||||
renderer={this.props.renderer}
|
||||
onResize={(width, height) => this.props.callbacks.onResize(width, height)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { SharedTimer, Layout, LayoutStateRefMut } from "../livesplit-core";
|
||||
import { TimerView } from "./TimerView";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
import { WebRenderer } from "../livesplit-core/livesplit_core";
|
||||
|
||||
export interface Props {
|
||||
isDesktop: boolean,
|
||||
@@ -9,9 +10,11 @@ export interface Props {
|
||||
layoutState: LayoutStateRefMut,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
renderWithSidebar: boolean,
|
||||
sidebarOpen: boolean,
|
||||
timer: SharedTimer,
|
||||
renderer: WebRenderer,
|
||||
callbacks: Callbacks,
|
||||
}
|
||||
|
||||
@@ -21,7 +24,7 @@ interface Callbacks {
|
||||
importLayoutFromFile(file: File): Promise<void>,
|
||||
importSplitsFromFile(file: File): Promise<void>,
|
||||
loadDefaultLayout(): void,
|
||||
onResize(width: number): Promise<void>,
|
||||
onResize(width: number, height: number): Promise<void>,
|
||||
openAboutView(): void,
|
||||
openLayoutEditor(): void,
|
||||
openLayoutView(): void,
|
||||
@@ -39,10 +42,12 @@ export class LayoutView extends React.Component<Props> {
|
||||
layoutState={this.props.layoutState}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
layoutWidth={this.props.layoutWidth}
|
||||
layoutHeight={this.props.layoutHeight}
|
||||
isDesktop={this.props.isDesktop}
|
||||
renderWithSidebar={false}
|
||||
sidebarOpen={this.props.sidebarOpen}
|
||||
timer={this.props.timer}
|
||||
renderer={this.props.renderer}
|
||||
callbacks={this.props.callbacks}
|
||||
/>;
|
||||
const sidebarContent = this.renderSidebarContent();
|
||||
|
||||
+25
-4
@@ -17,10 +17,11 @@ import { SplitsSelection, EditingInfo } from "./SplitsSelection";
|
||||
import { LayoutView } from "./LayoutView";
|
||||
import { toast } from "react-toastify";
|
||||
import * as Storage from "../storage";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
import { WebRenderer } from "../livesplit-core/livesplit_core";
|
||||
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "../css/LiveSplit.scss";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export enum MenuKind {
|
||||
Timer,
|
||||
@@ -46,6 +47,7 @@ export interface Props {
|
||||
layout?: Storage.LayoutSettings,
|
||||
hotkeys?: Storage.HotkeyConfigSettings,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
splitsKey?: number,
|
||||
}
|
||||
|
||||
@@ -59,12 +61,15 @@ export interface State {
|
||||
runEditorUrlCache: UrlCache,
|
||||
layoutEditorUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
menu: Menu,
|
||||
openedSplitsKey?: number,
|
||||
sidebarOpen: boolean,
|
||||
sidebarTransitionsEnabled: boolean,
|
||||
storedLayoutWidth: number,
|
||||
storedLayoutHeight: number,
|
||||
timer: SharedTimer,
|
||||
renderer: WebRenderer,
|
||||
}
|
||||
|
||||
export let hotkeySystem: Option<HotkeySystem> = null;
|
||||
@@ -75,7 +80,7 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
const splits = splitsKey !== undefined ? await Storage.loadSplits(splitsKey) : undefined;
|
||||
const layout = await Storage.loadLayout();
|
||||
const hotkeys = await Storage.loadHotkeys();
|
||||
const layoutWidth = await Storage.loadLayoutWidth();
|
||||
const [layoutWidth, layoutHeight] = await Storage.loadLayoutDims();
|
||||
|
||||
return {
|
||||
splits,
|
||||
@@ -83,6 +88,7 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
layout,
|
||||
hotkeys,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +155,9 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
const isDesktop = this.isDesktopQuery.matches;
|
||||
const isBrowserSource = !!(window as any).obsstudio;
|
||||
|
||||
const renderer = WebRenderer.new();
|
||||
renderer.element().setAttribute("style", "width: inherit; height: inherit;");
|
||||
|
||||
this.state = {
|
||||
isDesktop: isDesktop && !isBrowserSource,
|
||||
isBrowserSource,
|
||||
@@ -158,13 +167,16 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
runEditorUrlCache: new UrlCache(),
|
||||
layoutEditorUrlCache: new UrlCache(),
|
||||
layoutWidth: props.layoutWidth,
|
||||
layoutHeight: props.layoutHeight,
|
||||
menu: { kind: MenuKind.Timer },
|
||||
sidebarOpen: false,
|
||||
sidebarTransitionsEnabled: false,
|
||||
storedLayoutWidth: props.layoutWidth,
|
||||
storedLayoutHeight: props.layoutHeight,
|
||||
timer,
|
||||
hotkeySystem,
|
||||
openedSplitsKey: props.splitsKey,
|
||||
renderer,
|
||||
};
|
||||
|
||||
this.mediaQueryChanged = this.mediaQueryChanged.bind(this);
|
||||
@@ -237,10 +249,13 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
} else if (this.state.menu.kind === MenuKind.LayoutEditor) {
|
||||
return <LayoutEditorComponent
|
||||
editor={this.state.menu.editor}
|
||||
layoutState={this.state.layoutState}
|
||||
layoutEditorUrlCache={this.state.layoutEditorUrlCache}
|
||||
layoutUrlCache={this.state.layoutUrlCache}
|
||||
layoutWidth={this.state.layoutWidth}
|
||||
layoutHeight={this.state.layoutHeight}
|
||||
timer={this.state.timer}
|
||||
renderer={this.state.renderer}
|
||||
callbacks={this}
|
||||
/>;
|
||||
} else if (this.state.menu.kind === MenuKind.SettingsEditor) {
|
||||
@@ -263,10 +278,12 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
layoutState={this.state.layoutState}
|
||||
layoutUrlCache={this.state.layoutUrlCache}
|
||||
layoutWidth={this.state.layoutWidth}
|
||||
layoutHeight={this.state.layoutHeight}
|
||||
isDesktop={this.state.isDesktop}
|
||||
renderWithSidebar={true}
|
||||
sidebarOpen={this.state.sidebarOpen}
|
||||
timer={this.state.timer}
|
||||
renderer={this.state.renderer}
|
||||
callbacks={this}
|
||||
/>;
|
||||
} else if (this.state.menu.kind === MenuKind.Layout) {
|
||||
@@ -275,10 +292,12 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
layoutState={this.state.layoutState}
|
||||
layoutUrlCache={this.state.layoutUrlCache}
|
||||
layoutWidth={this.state.layoutWidth}
|
||||
layoutHeight={this.state.layoutHeight}
|
||||
isDesktop={this.state.isDesktop}
|
||||
renderWithSidebar={true}
|
||||
sidebarOpen={this.state.sidebarOpen}
|
||||
timer={this.state.timer}
|
||||
renderer={this.state.renderer}
|
||||
callbacks={this}
|
||||
/>;
|
||||
}
|
||||
@@ -492,12 +511,14 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
this.openTimerView();
|
||||
}
|
||||
|
||||
public async onResize(width: number) {
|
||||
public async onResize(width: number, height: number) {
|
||||
this.setState({
|
||||
layoutWidth: width,
|
||||
layoutHeight: height,
|
||||
storedLayoutWidth: width,
|
||||
storedLayoutHeight: height,
|
||||
});
|
||||
await Storage.storeLayoutWidth(width);
|
||||
await Storage.storeLayoutDims(width, height);
|
||||
}
|
||||
|
||||
private getDefaultRun() {
|
||||
|
||||
+31
-25
@@ -1,28 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
SharedTimer, TimerRef, TimerRefMut, Layout,
|
||||
SharedTimer, TimerRef, TimerRefMut,
|
||||
TimingMethod, TimeSpan, LayoutStateRefMut,
|
||||
} from "../livesplit-core";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { Option } from "../util/OptionUtil";
|
||||
import DragUpload from "./DragUpload";
|
||||
import AutoRefresh from "../util/AutoRefresh";
|
||||
import AutoRefreshLayout from "../layout/AutoRefreshLayout";
|
||||
import Layout from "../layout/Layout";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
import { WebRenderer } from "../livesplit-core/livesplit_core";
|
||||
|
||||
import LiveSplitIcon from "../assets/icon.svg";
|
||||
|
||||
import "../css/TimerView.scss";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
|
||||
export interface Props {
|
||||
isDesktop: boolean,
|
||||
layout: Layout,
|
||||
layout: LiveSplit.Layout,
|
||||
layoutState: LayoutStateRefMut,
|
||||
layoutUrlCache: UrlCache,
|
||||
layoutWidth: number,
|
||||
layoutHeight: number,
|
||||
renderWithSidebar: boolean,
|
||||
sidebarOpen: boolean,
|
||||
timer: SharedTimer,
|
||||
renderer: WebRenderer,
|
||||
callbacks: Callbacks,
|
||||
}
|
||||
export interface State {
|
||||
@@ -33,7 +37,7 @@ export interface State {
|
||||
interface Callbacks {
|
||||
importLayoutFromFile(file: File): Promise<void>,
|
||||
importSplitsFromFile(file: File): Promise<void>,
|
||||
onResize(width: number): Promise<void>,
|
||||
onResize(width: number, height: number): Promise<void>,
|
||||
openAboutView(): void,
|
||||
openLayoutView(): void,
|
||||
openSplitsView(): void,
|
||||
@@ -80,36 +84,38 @@ export class TimerView extends React.Component<Props, State> {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<AutoRefreshLayout
|
||||
<Layout
|
||||
getState={() => this.readWith(
|
||||
(t) => {
|
||||
const result = this.props.layout.updateStateAsJson(this.props.layoutState, this.props.layoutUrlCache.imageCache, t);
|
||||
this.props.layout.updateState(this.props.layoutState, this.props.layoutUrlCache.imageCache, t);
|
||||
this.props.layoutUrlCache.collect();
|
||||
return result;
|
||||
return this.props.layoutState;
|
||||
},
|
||||
)}
|
||||
layoutUrlCache={this.props.layoutUrlCache}
|
||||
allowResize={this.props.isDesktop}
|
||||
width={this.props.layoutWidth}
|
||||
onResize={(width) => this.props.callbacks.onResize(width)}
|
||||
height={this.props.layoutHeight}
|
||||
renderer={this.props.renderer}
|
||||
onResize={(width, height) => this.props.callbacks.onResize(width, height)}
|
||||
/>
|
||||
</div>
|
||||
<div className="buttons" style={{ width: this.props.layoutWidth }}>
|
||||
<div className="small">
|
||||
<button aria-label="Undo Split" onClick={(_) => this.undoSplit()}>
|
||||
<i className="fa fa-arrow-up" aria-hidden="true" /></button>
|
||||
<button aria-label="Pause" onClick={(_) => this.togglePauseOrStart()}>
|
||||
<i className="fa fa-pause" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="small">
|
||||
<button aria-label="Skip Split" onClick={(_) => this.skipSplit()}>
|
||||
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="Reset" onClick={(_) => this.reset()}>
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="buttons" style={{ width: this.props.layoutWidth }}>
|
||||
<div className="small">
|
||||
<button aria-label="Undo Split" onClick={(_) => this.undoSplit()}>
|
||||
<i className="fa fa-arrow-up" aria-hidden="true" /></button>
|
||||
<button aria-label="Pause" onClick={(_) => this.togglePauseOrStart()}>
|
||||
<i className="fa fa-pause" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="small">
|
||||
<button aria-label="Skip Split" onClick={(_) => this.skipSplit()}>
|
||||
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="Reset" onClick={(_) => this.reset()}>
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DragUpload>;
|
||||
|
||||
+13
-9
@@ -101,6 +101,10 @@ describe("Layout Rendering Tests", function () {
|
||||
await clickElement(By.xpath(".//button[contains(text(), 'Back')]"));
|
||||
|
||||
const layoutElement = await findElement(By.className("layout"));
|
||||
|
||||
// The icons load asynchronously, so we need to wait for them to load.
|
||||
await driver.sleep(500);
|
||||
|
||||
const layoutScreenshot = await layoutElement.takeScreenshot();
|
||||
|
||||
const tempFilePath = `${SCREENSHOTS_FOLDER}/${layoutName}.png`;
|
||||
@@ -149,15 +153,15 @@ describe("Layout Rendering Tests", function () {
|
||||
});
|
||||
}
|
||||
|
||||
testRendering("all_components", "default", "f4G-QgAAAAAeAAAO____________________AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA__wCAAAAAABv______4PZ4AG");
|
||||
testRendering("all_components", "pmw3", "f4G-QgAAAAAeAAAO________fmAOf6AAX4AG3_gH3_AOX_APVkgOX-AOVvAPX_AeX_AeWrAeb_gfAAAAAABv__x-____JABO");
|
||||
testRendering("default", "default", "____________AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAD_________");
|
||||
testRendering("default", "pmw3", "__wA___GX-AG3-gGUNgGQkAOXnAOW9gOX-APVtwOH_APX8AOX_APRnAOXVAOXJAeHvAeX_wf__AfAAAAAABgAAD_Pz9_____");
|
||||
testRendering("splits_two_rows", "celeste", "f8AAcAAHf8AA____d4AAcAAHf_gAcAAPd_AAYAAHf4AA____f4AAYAAH__AA____Z_gAYAAHf_8A9VVfb_wAcAAPf_AAYAAP");
|
||||
testRendering("splits_with_labels", "celeste", "AABEAADPAADPAADPAADA____fAAH_wAP_wAP_wAPxgAA____wgAH34AP_4AH34APQYAA____0EAP_8AP38AP38APQAAAAAAA");
|
||||
testRendering("title_centered_no_game_icon", "celeste", "________AAAAADQAAHQAAH4A______3_____AAAAAAAAAAAA____AAkAAL8DAP8DAP8DAP8D_________-n9AAAAAAAAAAAA");
|
||||
testRendering("title_centered_with_game_icon", "celeste", "________AAAAQBoAQBoAYB8A________9V-U4AAA4AAA4AAA4AAA4ASA4H_D4H_D4H_D4H_D_________9T84AAAAAAAAAAA");
|
||||
testRendering("title_left_no_attempt_count", "celeste", "________AAAA-AAA-AAA-AAA____________AAAAAAAAAAAA____rVKV_wAA_4AA_4AA_4AA________qf__AAAAAAAAAAAA");
|
||||
testRendering("all_components", "default", "PwD-XAAAAAA2AAAO____________________AAAAAAAAAAAA____AAAA____AAAA____AAAAf_AAd_AAAAD-Nz___34HbwAG");
|
||||
testRendering("all_components", "pmw3", "PwD-XAAAAAA2AAAO_________sAPf8AAf4AC3_AH_-AOX-APXfAGX-AP18AOf-APXeAOX-AOD7AOQIAIAAD-b_j_____LwBs");
|
||||
testRendering("default", "default", "________8AADVVVVAAAAAAAAAAAA____________AAAAAAAAAAAA____VVVVAAAA____AAAAVVVVAAAAAADgAAD_________");
|
||||
testRendering("default", "pmw3", "b_gAb9-HV8AG__AHV_AG7kAGX-AGV-AOX8APX_gOX-AP28AO_-APX-APXeAOUQAOX-AOX_gP_-AfAAAAAADgAAD_q97_____");
|
||||
testRendering("splits_two_rows", "celeste", "b4AAYAAH________bwAA4AAHb-AA4AAHb-AAYAAHbwAA____bwAAYAAH_9VV____b_AAYAAH__wA____b_AAYAAPb8AAYAAP");
|
||||
testRendering("splits_with_labels", "celeste", "AAAAAABMAADnAADnAABH________WAAHfwAHfwAHTwAHVVVVAAAA0gAH_wAH_wAH3wAH_________IAP34AP34AP38APAAAA");
|
||||
testRendering("title_centered_no_game_icon", "celeste", "________MzMzADAAADwAADwA________VX1VADwAAAAAAAAA____ABkAAP8DAP8DAP8DAP8D________V_1XAGAAAAAAAAAA");
|
||||
testRendering("title_centered_with_game_icon", "celeste", "________AiIiQDAAYDwAYDwA________YDwAYDwAYAAAYAAAd393YBkAYL8DYP8DYP8DYP8D________d_1XYGAAAAAAAAAA");
|
||||
testRendering("title_left_no_attempt_count", "celeste", "________MzMz4AAA8AAA-AAA_________VVV-AAAAAAAAAAA____GQAA_wAA_wAA_wAA_wAA________f_1XIAAAAAAAAAAA");
|
||||
|
||||
after(async () => {
|
||||
await driver.quit();
|
||||
|
||||
Reference in New Issue
Block a user