Add Flyout, hoist altBackground to Compress

This commit is contained in:
Jason Miller
2020-12-09 02:09:24 -05:00
parent 8356838a01
commit 0802a74602
7 changed files with 482 additions and 132 deletions

View File

@@ -0,0 +1,54 @@
import {
Component,
cloneElement,
createRef,
toChildArray,
ComponentChildren,
RefObject,
} from 'preact';
interface Props {
children: ComponentChildren;
onClick?(e: MouseEvent | KeyboardEvent): void;
}
export class ClickOutsideDetector extends Component<Props> {
private _roots: RefObject<Element>[] = [];
private handleClick = (e: MouseEvent) => {
let target = e.target as Node;
// check if the click came from within any of our child elements:
for (const { current: root } of this._roots) {
if (root && (root === target || root.contains(target))) return;
}
const { onClick } = this.props;
if (onClick) onClick(e);
};
private handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
const { onClick } = this.props;
if (onClick) onClick(e);
}
};
componentDidMount() {
addEventListener('click', this.handleClick, { passive: true });
addEventListener('keydown', this.handleKey, { passive: true });
}
componentWillUnmount() {
removeEventListener('click', this.handleClick);
removeEventListener('keydown', this.handleKey);
}
render({ children }: Props) {
this._roots = [];
return toChildArray(children).map((child) => {
if (typeof child !== 'object') return child;
const ref = createRef();
this._roots.push(ref);
return cloneElement(child, { ref });
});
}
}

View File

@@ -0,0 +1,84 @@
import { h, cloneElement, Component, VNode, createRef, ComponentChildren, ComponentProps } from "preact";
import { ClickOutsideDetector } from "../ClickOutsideDetector";
import * as style from './style.css';
import 'add-css:./style.css';
type Anchor = 'left' | 'right' | 'top' | 'bottom';
interface Props extends ComponentProps<'aside'> {
showing?: boolean;
direction?: 'up' | 'down';
anchor?: Anchor | Anchor[];
toggle?: VNode;
children?: ComponentChildren;
}
interface State {
showing: boolean;
}
export default class Flyout extends Component<Props, State> {
state = {
showing: this.props.showing === true
};
private menu = createRef<HTMLElement>();
private hide = () => {
this.setState({ showing: false });
};
private toggle = () => {
this.setState({ showing: !this.state.showing });
};
componentWillReceiveProps({ showing }: Props) {
if (showing !== this.props.showing) {
this.setState({ showing });
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.showing && !prevState.showing) {
const menu = this.menu.current;
if (menu) {
let toFocus = menu.firstElementChild;
for (let child of menu.children) {
if (child.hasAttribute('autofocus')) {
toFocus = child;
break;
}
}
// @ts-ignore-next
if (toFocus) toFocus.focus();
}
}
}
render({ direction, anchor, toggle, children, ...props }: Props, { showing }: State) {
const toggleProps = {
flyoutOpen: showing,
onClick: this.toggle
};
const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor;
return (
<span class={style.wrap} data-flyout-open={showing ? '' : undefined}>
<ClickOutsideDetector onClick={this.hide}>
{toggle && cloneElement(toggle, toggleProps)}
<aside
{...props}
ref={this.menu}
hidden={!showing}
data-anchor={anchorText}
data-direction={direction}
>
{children}
</aside>
</ClickOutsideDetector>
</span>
);
}
}

View File

@@ -0,0 +1,70 @@
.wrap {
display: inline;
position: relative;
}
.wrap > aside:last-of-type {
display: inline-block;
position: absolute;
left: 0;
top: 100%;
z-index: 100;
display: flex;
flex-wrap: nowrap;
flex-direction: column;
align-items: flex-start;
overflow: visible;
outline: none;
will-change: transform, opacity;
animation: menuOpen 350ms ease forwards 1;
--flyout-offset-y: -20px;
&[hidden] {
display: none;
}
/* align to the right edge */
&[data-anchor*='right'] {
left: 100%;
}
/* open to the left */
&[data-direction*='left'] {
right: 0;
left: auto;
&[anchor*='right'] {
right: 100%;
}
}
/* align to the top edge */
&[data-anchor*='top'] {
top: 0;
}
/* open to the left */
&[data-direction*='up'] {
bottom: 100%;
top: auto;
flex-direction: column-reverse;
--flyout-offset-y: 20px;
&[data-anchor*='bottom'] {
bottom: 0;
}
}
/*
@media (min-width: 860px) {
flex-direction: column-reverse;
top: auto;
bottom: 100%;
}
*/
}
@keyframes menuOpen {
0% {
transform: translateY(var(--flyout-offset-y, 0));
opacity: 0;
}
}

View File

@@ -1,4 +1,4 @@
import { h, Component } from 'preact'; import { h, createRef, Component, Fragment } from 'preact';
import type PinchZoom from './custom-els/PinchZoom'; import type PinchZoom from './custom-els/PinchZoom';
import type { ScaleToOpts } from './custom-els/PinchZoom'; import type { ScaleToOpts } from './custom-els/PinchZoom';
import './custom-els/PinchZoom'; import './custom-els/PinchZoom';
@@ -10,30 +10,36 @@ import {
ToggleBackgroundIcon, ToggleBackgroundIcon,
AddIcon, AddIcon,
RemoveIcon, RemoveIcon,
ToggleBackgroundActiveIcon,
RotateIcon, RotateIcon,
MoreIcon,
} from '../../icons'; } from '../../icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import type { PreprocessorState } from '../../feature-meta'; import type { PreprocessorState } from '../../feature-meta';
import { cleanSet } from '../../util/clean-modify'; import { cleanSet } from '../../util/clean-modify';
import type { SourceImage } from '../../Compress'; import type { SourceImage } from '../../Compress';
import { linkRef } from 'shared/prerendered-app/util'; import { linkRef } from 'shared/prerendered-app/util';
import Flyout from '../Flyout';
interface Props { interface Props {
source?: SourceImage; source?: SourceImage;
preprocessorState?: PreprocessorState; preprocessorState?: PreprocessorState;
hidden?: boolean;
mobileView: boolean; mobileView: boolean;
leftCompressed?: ImageData; leftCompressed?: ImageData;
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onPreprocessorChange: (newState: PreprocessorState) => void; onPreprocessorChange?: (newState: PreprocessorState) => void;
onShowPreprocessorTransforms?: () => void;
onToggleBackground?: () => void;
} }
interface State { interface State {
scale: number; scale: number;
editingScale: boolean; editingScale: boolean;
altBackground: boolean; altBackground: boolean;
transform: boolean;
menuOpen: boolean;
} }
const scaleToOpts: ScaleToOpts = { const scaleToOpts: ScaleToOpts = {
@@ -48,12 +54,15 @@ export default class Output extends Component<Props, State> {
scale: 1, scale: 1,
editingScale: false, editingScale: false,
altBackground: false, altBackground: false,
transform: false,
menuOpen: false,
}; };
canvasLeft?: HTMLCanvasElement; canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement; canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom; pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom; pinchZoomRight?: PinchZoom;
scaleInput?: HTMLInputElement; scaleInput?: HTMLInputElement;
flyout = createRef<Flyout>();
retargetedEvents = new WeakSet<Event>(); retargetedEvents = new WeakSet<Event>();
componentDidMount() { componentDidMount() {
@@ -144,12 +153,6 @@ export default class Output extends Component<Props, State> {
return props.rightCompressed || (props.source && props.source.preprocessed); return props.rightCompressed || (props.source && props.source.preprocessed);
} }
private toggleBackground = () => {
this.setState({
altBackground: !this.state.altBackground,
});
};
private zoomIn = () => { private zoomIn = () => {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts); this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
@@ -160,17 +163,36 @@ export default class Output extends Component<Props, State> {
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
}; };
private onRotateClick = () => { private fitToViewport = () => {
const { preprocessorState: inputProcessorState } = this.props; if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
if (!inputProcessorState) return; const img = this.props.source?.preprocessed;
if (!img) return;
const newState = cleanSet( const scale = Number(
inputProcessorState, Math.min(
'rotate.rotate', (window.innerWidth - 20) / img.width,
(inputProcessorState.rotate.rotate + 90) % 360, (window.innerHeight - 20) / img.height,
).toFixed(2),
); );
this.pinchZoomLeft.scaleTo(Number(scale.toFixed(2)), scaleToOpts);
this.recenter();
// this.hideMenu();
};
this.props.onPreprocessorChange(newState); private zoomTo2x = () => {
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomLeft.scaleTo(0.5, scaleToOpts);
this.recenter();
};
private recenter = () => {
const img = this.props.source?.preprocessed;
if (!img || !this.pinchZoomLeft) return;
let scale = this.pinchZoomLeft.scale;
this.pinchZoomLeft.setTransform({
x: (img.width - img.width * scale) / 2,
y: (img.height - img.height * scale) / 2,
allowChangeEvent: true,
});
}; };
private onScaleValueFocus = () => { private onScaleValueFocus = () => {
@@ -253,8 +275,16 @@ export default class Output extends Component<Props, State> {
}; };
render( render(
{ mobileView, leftImgContain, rightImgContain, source }: Props, {
{ scale, editingScale, altBackground }: State, source,
mobileView,
hidden,
leftImgContain,
rightImgContain,
onShowPreprocessorTransforms,
onToggleBackground,
}: Props,
{ scale, editingScale }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
@@ -262,9 +292,8 @@ export default class Output extends Component<Props, State> {
const originalImage = source && source.preprocessed; const originalImage = source && source.preprocessed;
return ( return (
<div <Fragment>
class={`${style.output} ${altBackground ? style.altBackground : ''}`} <div class={style.output} hidden={hidden}>
>
<two-up <two-up
legacy-clip-compat legacy-clip-compat
class={style.twoUp} class={style.twoUp}
@@ -290,7 +319,7 @@ export default class Output extends Component<Props, State> {
style={{ style={{
width: originalImage ? originalImage.width : '', width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '', height: originalImage ? originalImage.height : '',
objectFit: leftImgContain ? 'contain' : '', objectFit: leftImgContain ? 'contain' : undefined,
}} }}
/> />
</pinch-zoom> </pinch-zoom>
@@ -306,13 +335,17 @@ export default class Output extends Component<Props, State> {
style={{ style={{
width: originalImage ? originalImage.width : '', width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '', height: originalImage ? originalImage.height : '',
objectFit: rightImgContain ? 'contain' : '', objectFit: rightImgContain ? 'contain' : undefined,
}} }}
/> />
</pinch-zoom> </pinch-zoom>
</two-up> </two-up>
</div>
<div class={style.controls}> <div
class={style.controls}
hidden={hidden}
>
<div class={style.zoomControls}> <div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}> <button class={style.button} onClick={this.zoomOut}>
<RemoveIcon /> <RemoveIcon />
@@ -341,29 +374,39 @@ export default class Output extends Component<Props, State> {
<button class={style.button} onClick={this.zoomIn}> <button class={style.button} onClick={this.zoomIn}>
<AddIcon /> <AddIcon />
</button> </button>
</div>
<div class={style.buttonsNoWrap}> <Flyout
<button class={style.menu}
class={style.button} showing={hidden ? false : undefined}
onClick={this.onRotateClick} anchor="right"
title="Rotate image" direction={mobileView ? 'down' : 'up'}
> toggle={
<RotateIcon /> <button class={`${style.button} ${style.moreButton}`}>
<MoreIcon />
</button> </button>
<button }
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
> >
{altBackground ? ( <button class={style.button} onClick={onShowPreprocessorTransforms}>
<ToggleBackgroundActiveIcon /> <RotateIcon /> Rotate & Transform
) : ( </button>
<button class={style.button} onClick={this.fitToViewport}>
Fit to viewport
</button>
<button class={style.button} onClick={this.zoomTo2x}>
Simulate retina
</button>
<button class={style.button} onClick={this.recenter}>
Re-center
</button>
<button class={style.button} onClick={onToggleBackground}>
<ToggleBackgroundIcon /> <ToggleBackgroundIcon />
)} {' '}
Change canvas color
</button> </button>
</Flyout>
</div> </div>
</div> </div>
</div> </Fragment>
); );
} }
} }

View File

@@ -1,20 +1,8 @@
.output { .output {
display: contents; display: contents;
&::before { &[hidden] {
content: ''; display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
opacity: 0.8;
transition: opacity 500ms ease;
}
&.alt-background::before {
opacity: 0;
} }
} }
@@ -43,15 +31,20 @@
.controls { .controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
contain: content;
grid-area: header; grid-area: header;
align-self: center; align-self: center;
padding: 9px 66px; padding: 9px 66px;
/* Had to disable containment because of the overflow menu. */
/*
contain: content;
overflow: hidden;
*/
transition: transform 500ms ease;
/* Allow clicks to fall through to the pinch zoom area */ /* Allow clicks to fall through to the pinch zoom area */
pointer-events: none; pointer-events: none;
& > * { & > * {
pointer-events: auto; pointer-events: auto;
} }
@@ -62,17 +55,29 @@
grid-area: viewportOpts; grid-area: viewportOpts;
align-self: end; align-self: end;
} }
&[hidden] {
visibility: visible;
transform: translateY(-200%);
@media (min-width: 860px) {
transform: translateY(200%);
}
}
} }
.zoom-controls { .zoom-controls {
display: flex; display: flex;
position: relative;
z-index: 100;
& :not(:first-child) { & > :not(:first-child) {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
margin-left: 0; margin-left: 0;
} }
& :not(:last-child) {
& > :not(:nth-last-child(2)) {
margin-right: 0; margin-right: 0;
border-right-width: 0; border-right-width: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
@@ -86,62 +91,69 @@
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
margin: 4px; margin: 4px;
background-color: #fff; background-color: rgba(29, 29, 29, 0.92);
border: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.67);
border-radius: 5px; border-radius: 6px;
line-height: 1; line-height: 1.1;
white-space: nowrap; white-space: nowrap;
height: 36px; height: 39px;
padding: 0 8px; padding: 0 8px;
font-size: 1.2rem;
cursor: pointer; cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus { &:focus {
box-shadow: 0 0 0 2px var(--button-fg); /* box-shadow: 0 0 0 2px var(--hot-pink); */
box-shadow: 0 0 0 2px #fff;
outline: none; outline: none;
z-index: 1; z-index: 1;
} }
} }
.button { .button {
color: var(--button-fg);
&:hover {
background-color: #eee;
}
&.active {
background: #34b9eb;
color: #fff; color: #fff;
&:hover { &:hover {
background: #32a3ce; background: rgba(50, 50, 50, 0.92);
} }
&.active {
background: rgba(72, 72, 72, 0.92);
color: #fff;
} }
} }
.zoom { .zoom {
color: #625e80;
cursor: text; cursor: text;
width: 6em; width: 7rem;
font: inherit; font: inherit;
text-align: center; text-align: center;
justify-content: center; justify-content: center;
&:focus { &:focus {
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 2px var(--button-fg); box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 2px #fff;
} }
} }
span.zoom {
color: #939393;
font-size: 0.8rem;
line-height: 1.2;
font-weight: 100;
}
input.zoom {
font-size: 1.2rem;
letter-spacing: 0.05rem;
font-weight: 700;
text-indent: 3px;
color: #fff;
}
.zoom-value { .zoom-value {
position: relative;
top: 1px;
margin: 0 3px 0 0; margin: 0 3px 0 0;
color: #888; padding: 0 2px;
font-size: 1.2rem;
letter-spacing: 0.05rem;
font-weight: 700;
color: #fff;
border-bottom: 1px dashed #999; border-bottom: 1px dashed #999;
} }
@@ -153,3 +165,64 @@
pointer-events: auto; pointer-events: auto;
} }
} }
/** Three-dot menu */
.moreButton {
padding: 0 4px;
& > svg {
transform-origin: center;
transition: transform 200ms ease;
}
}
[data-flyout-open] {
.moreButton {
background: rgba(82, 82, 82, 0.92);
& > svg {
transform: rotate(180deg);
}
}
&:before {
content: '';
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(50, 50, 50, 0.4);
backdrop-filter: blur(2px) contrast(70%);
animation: menuShimFadeIn 350ms ease forwards 1;
will-change: opacity;
z-index: -1;
}
}
@keyframes menuShimFadeIn {
0% {
opacity: 0;
}
}
.menu {
button {
margin: 8px 0;
border-radius: 2rem;
padding: 0 16px;
& > svg {
position: relative;
left: -6px;
}
}
h5 {
text-transform: uppercase;
font-size: 0.8rem;
color: #fff;
margin: 8px 4px;
padding: 10px 0 0;
}
}

View File

@@ -70,6 +70,7 @@ interface State {
loading: boolean; loading: boolean;
error?: string; error?: string;
mobileView: boolean; mobileView: boolean;
altBackground: boolean;
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
encodedPreprocessorState?: PreprocessorState; encodedPreprocessorState?: PreprocessorState;
} }
@@ -294,6 +295,7 @@ export default class Compress extends Component<Props, State> {
}, },
], ],
mobileView: this.widthQuery.matches, mobileView: this.widthQuery.matches,
altBackground: false,
}; };
private readonly encodeCache = new ResultCache(); private readonly encodeCache = new ResultCache();
@@ -319,6 +321,12 @@ export default class Compress extends Component<Props, State> {
this.setState({ mobileView: this.widthQuery.matches }); this.setState({ mobileView: this.widthQuery.matches });
}; };
private toggleBackground = () => {
this.setState({
altBackground: !this.state.altBackground,
});
};
private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => { private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => {
this.setState({ this.setState({
sides: cleanSet( sides: cleanSet(
@@ -790,7 +798,7 @@ export default class Compress extends Component<Props, State> {
render( render(
{ onBack }: Props, { onBack }: Props,
{ loading, sides, source, mobileView, preprocessorState }: State, { loading, sides, source, mobileView, altBackground, preprocessorState }: State,
) { ) {
const [leftSide, rightSide] = sides; const [leftSide, rightSide] = sides;
const [leftImageData, rightImageData] = sides.map((i) => i.data); const [leftImageData, rightImageData] = sides.map((i) => i.data);
@@ -849,7 +857,7 @@ export default class Compress extends Component<Props, State> {
rightDisplaySettings.processorState.resize.fitMethod === 'contain'; rightDisplaySettings.processorState.resize.fitMethod === 'contain';
return ( return (
<div class={style.compress}> <div class={`${style.compress} ${altBackground ? style.altBackground : ''}`}>
<Output <Output
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
@@ -859,6 +867,7 @@ export default class Compress extends Component<Props, State> {
rightImgContain={rightImgContain} rightImgContain={rightImgContain}
preprocessorState={preprocessorState} preprocessorState={preprocessorState}
onPreprocessorChange={this.onPreprocessorChange} onPreprocessorChange={this.onPreprocessorChange}
onToggleBackground={this.toggleBackground}
/> />
<button class={style.back} onClick={onBack}> <button class={style.back} onClick={onBack}>
<svg viewBox="0 0 61 53.3"> <svg viewBox="0 0 61 53.3">

View File

@@ -17,6 +17,23 @@
'header header header' 'header header header'
'optsLeft viewportOpts optsRight'; 'optsLeft viewportOpts optsRight';
} }
/* darker squares background */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
opacity: 0.8;
transition: opacity 500ms ease;
}
&.alt-background::before {
opacity: 0;
}
} }
.options { .options {