diff --git a/src/client/lazy-app/Compress/ClickOutsideDetector.tsx b/src/client/lazy-app/Compress/ClickOutsideDetector.tsx new file mode 100644 index 00000000..e65766cd --- /dev/null +++ b/src/client/lazy-app/Compress/ClickOutsideDetector.tsx @@ -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 { + private _roots: RefObject[] = []; + + 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 }); + }); + } +} diff --git a/src/client/lazy-app/Compress/Flyout/index.tsx b/src/client/lazy-app/Compress/Flyout/index.tsx new file mode 100644 index 00000000..37950d3b --- /dev/null +++ b/src/client/lazy-app/Compress/Flyout/index.tsx @@ -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 { + state = { + showing: this.props.showing === true + }; + + private menu = createRef(); + + 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 ( + + + {toggle && cloneElement(toggle, toggleProps)} + + + + + ); + } +} diff --git a/src/client/lazy-app/Compress/Flyout/style.css b/src/client/lazy-app/Compress/Flyout/style.css new file mode 100644 index 00000000..f933f913 --- /dev/null +++ b/src/client/lazy-app/Compress/Flyout/style.css @@ -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; + } +} diff --git a/src/client/lazy-app/Compress/Output/index.tsx b/src/client/lazy-app/Compress/Output/index.tsx index 562445c3..1201edfd 100644 --- a/src/client/lazy-app/Compress/Output/index.tsx +++ b/src/client/lazy-app/Compress/Output/index.tsx @@ -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 { ScaleToOpts } from './custom-els/PinchZoom'; import './custom-els/PinchZoom'; @@ -10,30 +10,36 @@ import { ToggleBackgroundIcon, AddIcon, RemoveIcon, - ToggleBackgroundActiveIcon, RotateIcon, + MoreIcon, } from '../../icons'; import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import type { PreprocessorState } from '../../feature-meta'; import { cleanSet } from '../../util/clean-modify'; import type { SourceImage } from '../../Compress'; import { linkRef } from 'shared/prerendered-app/util'; +import Flyout from '../Flyout'; interface Props { source?: SourceImage; preprocessorState?: PreprocessorState; + hidden?: boolean; mobileView: boolean; leftCompressed?: ImageData; rightCompressed?: ImageData; leftImgContain: boolean; rightImgContain: boolean; - onPreprocessorChange: (newState: PreprocessorState) => void; + onPreprocessorChange?: (newState: PreprocessorState) => void; + onShowPreprocessorTransforms?: () => void; + onToggleBackground?: () => void; } interface State { scale: number; editingScale: boolean; altBackground: boolean; + transform: boolean; + menuOpen: boolean; } const scaleToOpts: ScaleToOpts = { @@ -48,12 +54,15 @@ export default class Output extends Component { scale: 1, editingScale: false, altBackground: false, + transform: false, + menuOpen: false, }; canvasLeft?: HTMLCanvasElement; canvasRight?: HTMLCanvasElement; pinchZoomLeft?: PinchZoom; pinchZoomRight?: PinchZoom; scaleInput?: HTMLInputElement; + flyout = createRef(); retargetedEvents = new WeakSet(); componentDidMount() { @@ -144,12 +153,6 @@ export default class Output extends Component { return props.rightCompressed || (props.source && props.source.preprocessed); } - private toggleBackground = () => { - this.setState({ - altBackground: !this.state.altBackground, - }); - }; - private zoomIn = () => { if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts); @@ -160,17 +163,36 @@ export default class Output extends Component { this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); }; - private onRotateClick = () => { - const { preprocessorState: inputProcessorState } = this.props; - if (!inputProcessorState) return; - - const newState = cleanSet( - inputProcessorState, - 'rotate.rotate', - (inputProcessorState.rotate.rotate + 90) % 360, + private fitToViewport = () => { + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + const img = this.props.source?.preprocessed; + if (!img) return; + const scale = Number( + Math.min( + (window.innerWidth - 20) / img.width, + (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 = () => { @@ -253,8 +275,16 @@ export default class Output extends Component { }; 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 rightDraw = this.rightDrawable(); @@ -262,57 +292,60 @@ export default class Output extends Component { const originalImage = source && source.preprocessed; return ( -
- - + -
+ -
+ ); } } diff --git a/src/client/lazy-app/Compress/Output/style.css b/src/client/lazy-app/Compress/Output/style.css index 88f6650d..b95a3a27 100644 --- a/src/client/lazy-app/Compress/Output/style.css +++ b/src/client/lazy-app/Compress/Output/style.css @@ -1,20 +1,8 @@ .output { display: contents; - &::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; + &[hidden] { + display: none; } } @@ -43,15 +31,20 @@ .controls { display: flex; justify-content: center; - overflow: hidden; flex-wrap: wrap; - contain: content; grid-area: header; align-self: center; 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 */ pointer-events: none; + & > * { pointer-events: auto; } @@ -62,17 +55,29 @@ grid-area: viewportOpts; align-self: end; } + + &[hidden] { + visibility: visible; + transform: translateY(-200%); + + @media (min-width: 860px) { + transform: translateY(200%); + } + } } .zoom-controls { display: flex; + position: relative; + z-index: 100; - & :not(:first-child) { + & > :not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; } - & :not(:last-child) { + + & > :not(:nth-last-child(2)) { margin-right: 0; border-right-width: 0; border-top-right-radius: 0; @@ -86,62 +91,69 @@ align-items: center; box-sizing: border-box; margin: 4px; - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 5px; - line-height: 1; + background-color: rgba(29, 29, 29, 0.92); + border: 1px solid rgba(0, 0, 0, 0.67); + border-radius: 6px; + line-height: 1.1; white-space: nowrap; - height: 36px; + height: 39px; padding: 0 8px; + font-size: 1.2rem; cursor: pointer; - @media (min-width: 600px) { - height: 48px; - padding: 0 16px; - } - &: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; z-index: 1; } } .button { - color: var(--button-fg); + color: #fff; &:hover { - background-color: #eee; + background: rgba(50, 50, 50, 0.92); } &.active { - background: #34b9eb; + background: rgba(72, 72, 72, 0.92); color: #fff; - - &:hover { - background: #32a3ce; - } } } .zoom { - color: #625e80; cursor: text; - width: 6em; + width: 7rem; font: inherit; text-align: center; justify-content: center; &: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 { - position: relative; - top: 1px; 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; } @@ -153,3 +165,64 @@ 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; + } +} diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx index cc39731e..62ccd851 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -70,6 +70,7 @@ interface State { loading: boolean; error?: string; mobileView: boolean; + altBackground: boolean; preprocessorState: PreprocessorState; encodedPreprocessorState?: PreprocessorState; } @@ -294,6 +295,7 @@ export default class Compress extends Component { }, ], mobileView: this.widthQuery.matches, + altBackground: false, }; private readonly encodeCache = new ResultCache(); @@ -319,6 +321,12 @@ export default class Compress extends Component { this.setState({ mobileView: this.widthQuery.matches }); }; + private toggleBackground = () => { + this.setState({ + altBackground: !this.state.altBackground, + }); + }; + private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => { this.setState({ sides: cleanSet( @@ -790,7 +798,7 @@ export default class Compress extends Component { render( { onBack }: Props, - { loading, sides, source, mobileView, preprocessorState }: State, + { loading, sides, source, mobileView, altBackground, preprocessorState }: State, ) { const [leftSide, rightSide] = sides; const [leftImageData, rightImageData] = sides.map((i) => i.data); @@ -849,7 +857,7 @@ export default class Compress extends Component { rightDisplaySettings.processorState.resize.fitMethod === 'contain'; return ( -
+
{ rightImgContain={rightImgContain} preprocessorState={preprocessorState} onPreprocessorChange={this.onPreprocessorChange} + onToggleBackground={this.toggleBackground} />