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:
Christopher Serr
2024-04-27 21:45:41 +02:00
parent 78a668f9e5
commit 3e249de680
37 changed files with 193 additions and 1880 deletions
+3 -2
View File
@@ -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
View File
@@ -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

-6
View File
@@ -1,6 +0,0 @@
.abbreviated {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
}
-59
View File
@@ -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;
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
.graph {
display: block;
}
-59
View File
@@ -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
View File
@@ -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;
}
}
}
-6
View File
@@ -1,6 +0,0 @@
@import 'variables';
.separator {
background-color: hsla(0, 0%, 43%, 1);
height: $thick-separator-width;
}
-160
View File
@@ -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;
}
}
}
}
-5
View File
@@ -1,5 +0,0 @@
@mixin time {
.time {
font-variant-numeric: tabular-nums;
}
}
-4
View File
@@ -1,4 +0,0 @@
.timer {
will-change: transform;
transform: translateZ(0);
}
-99
View File
@@ -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;
}
}
}
}
+6
View File
@@ -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}
-76
View File
@@ -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];
}
}
-47
View File
@@ -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>
);
}
}
-19
View File
@@ -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),
}}
/>
);
}
}
-75
View File
@@ -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;
}
}
-132
View File
@@ -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>
);
}
-176
View File
@@ -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} />;
}
-143
View File
@@ -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>);
}
}
-26
View File
@@ -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}
/>;
}
}
-75
View File
@@ -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
View File
@@ -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;
}
}
-16
View File
@@ -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)",
}}
/>
);
}
}
-133
View File
@@ -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;
}
}
-35
View File
@@ -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>
);
}
}
-71
View File
@@ -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>
);
}
}
-44
View File
@@ -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}
/>;
}
}
-116
View File
@@ -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,
);
}
}
-89
View File
@@ -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>
);
}
}
+8 -3
View File
@@ -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
View File
@@ -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>
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();