diff --git a/buildCore.js b/buildCore.js index ae86616..28a4b70 100644 --- a/buildCore.js +++ b/buildCore.js @@ -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", diff --git a/livesplit-core b/livesplit-core index f957c93..a7c9df2 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit f957c93b03aafcca925fcf35a2f4949f7b921f58 +Subproject commit a7c9df2ef1da927e743bd4a0783a5266d70ac402 diff --git a/src/assets/icon.svg b/src/assets/icon.svg index 1cdb312..8c93ee3 100644 --- a/src/assets/icon.svg +++ b/src/assets/icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/css/Abbreviated.scss b/src/css/Abbreviated.scss deleted file mode 100644 index 1864e35..0000000 --- a/src/css/Abbreviated.scss +++ /dev/null @@ -1,6 +0,0 @@ -.abbreviated { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - width: 100%; -} diff --git a/src/css/DetailedTimer.scss b/src/css/DetailedTimer.scss deleted file mode 100644 index 19f433e..0000000 --- a/src/css/DetailedTimer.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/css/Graph.scss b/src/css/Graph.scss deleted file mode 100644 index fb499f1..0000000 --- a/src/css/Graph.scss +++ /dev/null @@ -1,3 +0,0 @@ -.graph { - display: block; -} diff --git a/src/css/KeyValue.scss b/src/css/KeyValue.scss deleted file mode 100644 index 61c3d81..0000000 --- a/src/css/KeyValue.scss +++ /dev/null @@ -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; - } - } -} diff --git a/src/css/Layout.scss b/src/css/Layout.scss index 7e63595..3604991 100644 --- a/src/css/Layout.scss +++ b/src/css/Layout.scss @@ -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; } } } diff --git a/src/css/Separator.scss b/src/css/Separator.scss deleted file mode 100644 index 1b5dbdf..0000000 --- a/src/css/Separator.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import 'variables'; - -.separator { - background-color: hsla(0, 0%, 43%, 1); - height: $thick-separator-width; -} diff --git a/src/css/Splits.scss b/src/css/Splits.scss deleted file mode 100644 index f223a4a..0000000 --- a/src/css/Splits.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/css/Time.scss b/src/css/Time.scss deleted file mode 100644 index ae56428..0000000 --- a/src/css/Time.scss +++ /dev/null @@ -1,5 +0,0 @@ -@mixin time { - .time { - font-variant-numeric: tabular-nums; - } -} diff --git a/src/css/Timer.scss b/src/css/Timer.scss deleted file mode 100644 index 52203ea..0000000 --- a/src/css/Timer.scss +++ /dev/null @@ -1,4 +0,0 @@ -.timer { - will-change: transform; - transform: translateZ(0); -} diff --git a/src/css/Title.scss b/src/css/Title.scss deleted file mode 100644 index 40eefd2..0000000 --- a/src/css/Title.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/index.tsx b/src/index.tsx index 747a4d6..f0d6a52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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(
{ - public sorted: string[]; - public oldAbbreviations: string[]; - public element: React.RefObject; - - 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
; - } - - 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]; - } -} diff --git a/src/layout/AutoRefreshLayout.tsx b/src/layout/AutoRefreshLayout.tsx deleted file mode 100644 index 0a6574a..0000000 --- a/src/layout/AutoRefreshLayout.tsx +++ /dev/null @@ -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 { - constructor(props: Props) { - super(props); - - this.state = { - layoutState: this.props.getState(), - }; - } - - public refreshLayout() { - this.setState({ - layoutState: this.props.getState(), - }); - } - - public render() { - return ( - this.refreshLayout()} > - - - ); - } -} diff --git a/src/layout/BlankSpace.tsx b/src/layout/BlankSpace.tsx deleted file mode 100644 index d47c9e5..0000000 --- a/src/layout/BlankSpace.tsx +++ /dev/null @@ -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 { - public render() { - return ( -
- ); - } -} diff --git a/src/layout/Component.tsx b/src/layout/Component.tsx deleted file mode 100644 index 0345153..0000000 --- a/src/layout/Component.tsx +++ /dev/null @@ -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 { - public render() { - const { state } = this.props; - if ("KeyValue" in state) { - return ; - } else if ("DetailedTimer" in state) { - return ; - } else if ("Graph" in state) { - return ; - } else if ("Splits" in state) { - return ; - } else if ("Text" in state) { - return ; - } else if ("Timer" in state) { - return ; - } else if ("Title" in state) { - return ; - } 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; - } -} diff --git a/src/layout/DetailedTimer.tsx b/src/layout/DetailedTimer.tsx deleted file mode 100644 index f45302e..0000000 --- a/src/layout/DetailedTimer.tsx +++ /dev/null @@ -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> - ); -} diff --git a/src/layout/DragAutoRefreshLayout.tsx b/src/layout/DragAutoRefreshLayout.tsx deleted file mode 100644 index 5ed6179..0000000 --- a/src/layout/DragAutoRefreshLayout.tsx +++ /dev/null @@ -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} />; -} diff --git a/src/layout/Graph.tsx b/src/layout/Graph.tsx deleted file mode 100644 index d3d8d51..0000000 --- a/src/layout/Graph.tsx +++ /dev/null @@ -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>); - } -} diff --git a/src/layout/KeyValue.tsx b/src/layout/KeyValue.tsx deleted file mode 100644 index 1733823..0000000 --- a/src/layout/KeyValue.tsx +++ /dev/null @@ -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} - />; - } -} diff --git a/src/layout/KeyValueGeneric.tsx b/src/layout/KeyValueGeneric.tsx deleted file mode 100644 index 0af8adb..0000000 --- a/src/layout/KeyValueGeneric.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/src/layout/Layout.tsx b/src/layout/Layout.tsx index 28e7f92..3f4faa2 100644 --- a/src/layout/Layout.tsx +++ b/src/layout/Layout.tsx @@ -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; - } -} diff --git a/src/layout/Separator.tsx b/src/layout/Separator.tsx deleted file mode 100644 index beb0ff5..0000000 --- a/src/layout/Separator.tsx +++ /dev/null @@ -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)", - }} - /> - ); - } -} diff --git a/src/layout/Split.tsx b/src/layout/Split.tsx deleted file mode 100644 index dd3a68b..0000000 --- a/src/layout/Split.tsx +++ /dev/null @@ -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; - } -} diff --git a/src/layout/SplitLabels.tsx b/src/layout/SplitLabels.tsx deleted file mode 100644 index a2232eb..0000000 --- a/src/layout/SplitLabels.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/src/layout/Splits.tsx b/src/layout/Splits.tsx deleted file mode 100644 index 51b9a7a..0000000 --- a/src/layout/Splits.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/src/layout/Text.tsx b/src/layout/Text.tsx deleted file mode 100644 index 5b7f715..0000000 --- a/src/layout/Text.tsx +++ /dev/null @@ -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} - />; - } -} diff --git a/src/layout/Timer.tsx b/src/layout/Timer.tsx deleted file mode 100644 index 25b8c41..0000000 --- a/src/layout/Timer.tsx +++ /dev/null @@ -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, - ); - } -} diff --git a/src/layout/Title.tsx b/src/layout/Title.tsx deleted file mode 100644 index 8820f78..0000000 --- a/src/layout/Title.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/src/storage/index.tsx b/src/storage/index.tsx index a135f3d..eeec0a7 100644 --- a/src/storage/index.tsx +++ b/src/storage/index.tsx @@ -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) { diff --git a/src/ui/LayoutEditor.tsx b/src/ui/LayoutEditor.tsx index c124fb4..6533aa3 100644 --- a/src/ui/LayoutEditor.tsx +++ b/src/ui/LayoutEditor.tsx @@ -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> diff --git a/src/ui/LayoutView.tsx b/src/ui/LayoutView.tsx index 19ecfd8..59fc7b2 100644 --- a/src/ui/LayoutView.tsx +++ b/src/ui/LayoutView.tsx @@ -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(); diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 632ccb2..10de1cb 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -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() { diff --git a/src/ui/TimerView.tsx b/src/ui/TimerView.tsx index 93958ff..0ab0564 100644 --- a/src/ui/TimerView.tsx +++ b/src/ui/TimerView.tsx @@ -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>; diff --git a/test/rendering-test.js b/test/rendering-test.js index 783f5ae..cbb136c 100644 --- a/test/rendering-test.js +++ b/test/rendering-test.js @@ -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();