Files
op-pedal/vite/src/pipedal/AutoZoom.tsx
T
2025-07-14 18:13:09 -04:00

374 lines
15 KiB
TypeScript

// Copyright (c) 2025 Robin E. R. Davies
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import { Theme } from '@mui/material/styles';
import WithStyles from './WithStyles';
import { withStyles } from "tss-react/mui";
import { createStyles } from './WithStyles';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import IconButtonEx from './IconButtonEx';
import { isDarkMode } from './DarkMode';
import Backdrop from '@mui/material/Backdrop';
import CloseIcon from '@mui/icons-material/Close';
import Rect from './Rect';
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
const styles = (theme: Theme) => createStyles({
pgraph: {
paddingBottom: 16
}
});
export interface AutoZoomProps extends WithStyles<typeof styles> {
leftPad: number,
rightPad: number,
children?: React.ReactNode,
contentReady: boolean,
showZoomed: boolean,
setShowZoomed: (value: boolean) => void;
}
export interface AutoZoomState {
screenWidth: number,
screenHeight: number,
}
const AutoZoom = withStyles(
class extends ResizeResponsiveComponent<AutoZoomProps, AutoZoomState> {
private model: PiPedalModel;
constructor(props: AutoZoomProps) {
super(props);
this.model = PiPedalModelFactory.getInstance();
void this.model; // suppress unused variable warning
this.state = {
screenWidth: this.windowSize.width,
screenHeight: this.windowSize.height,
};
}
mounted: boolean = false;
onWindowSizeChanged(width: number, height: number): void {
this.setState({ screenWidth: width, screenHeight: height });
}
componentDidMount() {
super.componentDidMount();
this.mounted = true;
if (this.normalRef) {
this.updateZoom(this.normalRef);
}
}
componentWillUnmount() {
this.mounted = false;
this.cancelZoomUpdate();
this.cancelMaximizedZoomUpdate();
super.componentWillUnmount();
}
componentDidUpdate(prevProps: AutoZoomProps) {
if (prevProps.contentReady !== this.props.contentReady) {
if (this.normalRef) {
this.updateZoom(this.normalRef);
}
if (this.maximizedRef) {
this.updateMaximizedZoom();
}
}
}
resizeObserver: ResizeObserver | null = null;
calculateZoomedClientRect(
clientRect: Rect,
contentWidth: number,
contentHeight: number,
buttonPadding: number
): Rect {
console.log("Zoom: " + clientRect.toString() + " content: " + contentWidth + "x" + contentHeight + " buttonPadding: " + buttonPadding);
let rightButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, clientRect.height / contentHeight);
let topButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, (clientRect.height - buttonPadding) / contentHeight);
let effectiveClientRect = clientRect.copy();
if (rightButtonZoom > topButtonZoom) {
effectiveClientRect.width -= buttonPadding;
} else {
effectiveClientRect.y += buttonPadding;
effectiveClientRect.height -= buttonPadding;
}
let zoom = Math.min(effectiveClientRect.width / contentWidth, effectiveClientRect.height / contentHeight);
if (zoom > 1) {
zoom = 1;
}
let leftPad = (effectiveClientRect.width - contentWidth * zoom) / 2;
if (leftPad < 0) {
leftPad = 0;
}
let topPad = (effectiveClientRect.height - contentHeight * zoom) / 4;
if (topPad < 0) {
topPad = 0;
}
return new Rect(effectiveClientRect.x + leftPad, effectiveClientRect.y + topPad, contentWidth * zoom, contentHeight * zoom);
}
updateZoom(ref: HTMLDivElement) {
if (!this.mounted || !ref) {
return;
}
let content = ref.firstElementChild as HTMLElement | null;
if (content) {
const { leftPad, rightPad } = this.props;
let bounds = new Rect(leftPad, 0, ref.clientWidth - leftPad - rightPad, ref.clientHeight - 8);
let zoomedClientRect = this.calculateZoomedClientRect(
bounds, content.clientWidth, content.clientHeight, 48);
content.style.left = "0px";
content.style.top = "0px";
content.style.transformOrigin = "0 0"; // top-left corner
content.style.transform =
`translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`;
if (this.zoomButton) {
this.zoomButton.style.display = zoomedClientRect.width < content.clientWidth ? "block" : "none";
let rightSizeSpace = ref.clientWidth - zoomedClientRect.right;
if (rightSizeSpace - rightPad >= 48) {
this.zoomButton.style.top = `${zoomedClientRect.y + 24}px`;
this.zoomButton.style.left = `${zoomedClientRect.right + 4}px`;
} else {
this.zoomButton.style.top = `${zoomedClientRect.y - 48}px`;
this.zoomButton.style.left = `${zoomedClientRect.right - 64 - 12}px`;
}
}
// save the position of the Mod UI in page coordinates for later zooming.
this.zoomStartRect = zoomedClientRect.copy();
let refBounds = ref.getBoundingClientRect();
this.zoomStartRect.x += refBounds.left;
this.zoomStartRect.y += refBounds.top;
}
}
updateMaximizedZoom() {
let ref = this.maximizedRef;
if (!this.mounted || !ref) {
return;
}
let content = ref.firstElementChild as HTMLElement | null;
if (content) {
let leftPad = 16; let rightPad = 16;
let topPad = 16, bottomPad = 16;
let bounds: Rect;
bounds = new Rect(leftPad, topPad, ref.clientWidth - leftPad - rightPad, ref.clientHeight - topPad - bottomPad);
let zoomedClientRect = this.calculateZoomedClientRect(
bounds, content.clientWidth, content.clientHeight, 48);
content.style.left = "0px";
content.style.top = "0px";
content.style.transformOrigin = "0 0"; // top-left corner
content.style.transform = `translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`;
//console.log("Zoomed to: ", zoomedClientRect.toString());
}
}
private animateFrameHandle: number | null = null;
cancelZoomUpdate() {
if (this.animateFrameHandle) {
window.cancelAnimationFrame(this.animateFrameHandle);
this.animateFrameHandle = null;
}
}
requestZoomUpdate() {
if (this.animateFrameHandle) {
return; // already scheduled
}
this.animateFrameHandle = window.requestAnimationFrame(() => {
this.animateFrameHandle = null;
if (this.normalRef) {
this.updateZoom(this.normalRef);
}
});
}
private maximizedAnimateFrameHandle: number | null = null;
cancelMaximizedZoomUpdate() {
if (this.maximizedAnimateFrameHandle) {
window.cancelAnimationFrame(this.maximizedAnimateFrameHandle);
this.maximizedAnimateFrameHandle = null;
}
}
requestMaximizedZoomUpdate() {
if (this.maximizedAnimateFrameHandle) {
return; // already scheduled
}
this.maximizedAnimateFrameHandle = window.requestAnimationFrame(() => {
this.maximizedAnimateFrameHandle = null;
if (this.maximizedRef) {
this.updateMaximizedZoom();
}
});
}
private zoomButton: HTMLElement | null = null;
private normalRef: HTMLDivElement | null = null;
setNormalRef(ref: HTMLDivElement | null) {
this.normalRef = ref;
if (ref) {
this.zoomButton = ref.querySelector("#maximize-button") as HTMLDivElement | null;
this.resizeObserver = new ResizeObserver(() => {
this.requestZoomUpdate();
});
this.resizeObserver.observe(ref);
let child = ref.firstElementChild as HTMLElement | null;
if (child) {
this.resizeObserver.observe(child);
}
this.requestZoomUpdate();
} else {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
this.zoomButton = null;
}
this.zoomStartRect = null;
}
}
private maximizedRef: HTMLDivElement | null = null;
private maximedResizeObserver: ResizeObserver | null = null;
setMaximizedRef(ref: HTMLDivElement | null) {
this.maximizedRef = ref;
if (ref) {
this.maximedResizeObserver = new ResizeObserver(() => {
this.requestMaximizedZoomUpdate();
});
this.maximedResizeObserver.observe(ref);
let child = ref.firstElementChild as HTMLElement | null;
if (child) {
this.maximedResizeObserver.observe(child);
}
this.requestMaximizedZoomUpdate();
} else {
if (this.maximedResizeObserver) {
this.maximedResizeObserver.disconnect();
this.maximedResizeObserver = null;
}
}
}
private zoomStartRect: Rect | null = null;
startZoom() {
if (!this.zoomStartRect) {
return;
}
this.props.setShowZoomed(true);
}
render() {
const classes = withStyles.getClasses(this.props);
void classes; // suppress unused variable warning
const landscape = this.state.screenWidth > this.state.screenHeight;
return (<div style={{ overflow: "hidden", height: "100%", width: "100%" }} ref={(ref) => { this.setNormalRef(ref); }}>
<div style={{
display: "inline-block",
position: "absolute",
visibility: this.props.contentReady ? "visible" : "hidden",
}}>
{!this.props.showZoomed && this.props.children}
</div>
<div id="maximize-button" style={{
display: "none", position: "absolute", left: 0, top: 0, width: 52, height: 64, zIndex: 1101,
borderRadius: "0px 26px 26px 0px",
}}
>
<IconButtonEx tooltip="Fullscreen view"
style={{ margin: 2, background: isDarkMode() ? "#444" : "#eee" }}
onClick={() => {
this.startZoom();
}}
>
<FullscreenIcon style={{ color: "white" }} />
</IconButtonEx>
</div>
{this.props.showZoomed && (
<Backdrop id="modGuiZoomBackdrop"
sx={(theme) => ({
zIndex: this.props.showZoomed ? 1101 : -1,
overflow: "clip",
background: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)'
})}
open={this.props.showZoomed}
>
<div ref={(ref) => { this.setMaximizedRef(ref); }}
style={{
position: "absolute", left: 0, top: 0, right: 0, bottom: 0, overflow: "hidden",
visibility: this.props.contentReady ? "visible" : "hidden"
}}>
<div style={{ display: "inline-block", position: "relative" }}>
{
this.props.showZoomed && this.props.children
}
</div>
<IconButtonEx tooltip="Exit fullscreen"
style={{
position: "absolute",
right: landscape? 80: 16, // avoid potential cutout in landscape mode
top: 16, zIndex: 1104,
}}
onClick={(e) => {
e.stopPropagation();
this.props.setShowZoomed(false);
}}
>
<CloseIcon />
</IconButtonEx>
</div>
</Backdrop>
)}
</div>);
}
},
styles);
export default AutoZoom;