mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-11 16:26:20 +00:00
@@ -6,51 +6,127 @@ import {
|
||||
createRef,
|
||||
ComponentChildren,
|
||||
ComponentProps,
|
||||
Fragment,
|
||||
render,
|
||||
} from 'preact';
|
||||
import { ClickOutsideDetector } from '../ClickOutsideDetector';
|
||||
import * as style from './style.css';
|
||||
import 'add-css:./style.css';
|
||||
|
||||
type Anchor = 'left' | 'right' | 'top' | 'bottom';
|
||||
type Direction = 'left' | 'right' | 'up' | 'down';
|
||||
|
||||
interface Props extends ComponentProps<'aside'> {
|
||||
const has = (haystack: string | string[] | undefined, needle: string) =>
|
||||
Array.isArray(haystack) ? haystack.includes(needle) : haystack === needle;
|
||||
|
||||
interface Props extends Omit<ComponentProps<'aside'>, 'ref'> {
|
||||
showing?: boolean;
|
||||
direction?: Direction | Direction[];
|
||||
anchor?: Anchor | Anchor[];
|
||||
anchor?: Anchor;
|
||||
toggle?: VNode;
|
||||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
interface State {
|
||||
showing: boolean;
|
||||
hasShown: boolean;
|
||||
}
|
||||
|
||||
export default class Flyout extends Component<Props, State> {
|
||||
state = {
|
||||
showing: this.props.showing === true,
|
||||
hasShown: this.props.showing === true,
|
||||
};
|
||||
|
||||
private wrap = createRef<HTMLElement>();
|
||||
|
||||
private menu = createRef<HTMLElement>();
|
||||
|
||||
private hide = () => {
|
||||
this.setState({ showing: false });
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
private shown?: number;
|
||||
|
||||
private dismiss = (event: Event) => {
|
||||
if (this.menu.current && this.menu.current.contains(event.target as Node))
|
||||
return;
|
||||
// prevent toggle buttons from immediately dismissing:
|
||||
if (this.shown && Date.now() - this.shown < 10) return;
|
||||
this.setShowing(false);
|
||||
};
|
||||
|
||||
private toggle = () => {
|
||||
this.setState({ showing: !this.state.showing });
|
||||
hide = () => {
|
||||
this.setShowing(false);
|
||||
};
|
||||
|
||||
show = () => {
|
||||
this.setShowing(true);
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
this.setShowing(!this.state.showing);
|
||||
};
|
||||
|
||||
private setShowing = (showing?: boolean) => {
|
||||
this.shown = Date.now();
|
||||
if (showing) this.setState({ showing: true, hasShown: true });
|
||||
else this.setState({ showing: false });
|
||||
};
|
||||
|
||||
private reposition = () => {
|
||||
const menu = this.menu.current;
|
||||
const wrap = this.wrap.current;
|
||||
if (!menu || !wrap || !this.state.showing) return;
|
||||
const bbox = wrap.getBoundingClientRect();
|
||||
|
||||
const { direction = 'down', anchor = 'right' } = this.props;
|
||||
const { innerWidth, innerHeight } = window;
|
||||
|
||||
const anchorX = has(anchor, 'left') ? bbox.left : bbox.right;
|
||||
|
||||
menu.style.left = menu.style.right = menu.style.top = menu.style.bottom =
|
||||
'';
|
||||
|
||||
if (has(direction, 'left')) {
|
||||
menu.style.right = innerWidth - anchorX + 'px';
|
||||
} else {
|
||||
menu.style.left = anchorX + 'px';
|
||||
}
|
||||
if (has(direction, 'up')) {
|
||||
const anchorY = has(anchor, 'bottom') ? bbox.bottom : bbox.top;
|
||||
menu.style.bottom = innerHeight - anchorY + 'px';
|
||||
} else {
|
||||
const anchorY = has(anchor, 'top') ? bbox.top : bbox.bottom;
|
||||
menu.style.top = anchorY + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
componentWillReceiveProps({ showing }: Props) {
|
||||
if (showing !== this.props.showing) {
|
||||
this.setState({ showing });
|
||||
this.setShowing(showing);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
addEventListener('click', this.dismiss, { passive: true });
|
||||
addEventListener('resize', this.reposition, { passive: true });
|
||||
if (typeof ResizeObserver === 'function' && this.wrap.current) {
|
||||
this.resizeObserver = new ResizeObserver(this.reposition);
|
||||
this.resizeObserver.observe(this.wrap.current);
|
||||
}
|
||||
if (this.props.showing) this.setShowing(true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
removeEventListener('click', this.dismiss);
|
||||
removeEventListener('resize', this.reposition);
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.showing && !prevState.showing) {
|
||||
const menu = this.menu.current;
|
||||
if (menu) {
|
||||
this.reposition();
|
||||
|
||||
let toFocus = menu.firstElementChild;
|
||||
for (let child of menu.children) {
|
||||
if (child.hasAttribute('autofocus')) {
|
||||
@@ -79,21 +155,56 @@ export default class Flyout extends Component<Props, State> {
|
||||
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)}
|
||||
<span
|
||||
class={style.wrap}
|
||||
ref={this.wrap}
|
||||
data-flyout-open={showing ? '' : undefined}
|
||||
>
|
||||
{toggle && cloneElement(toggle, toggleProps)}
|
||||
|
||||
<aside
|
||||
{...props}
|
||||
ref={this.menu}
|
||||
hidden={!showing}
|
||||
data-anchor={anchorText}
|
||||
data-direction={directionText}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</ClickOutsideDetector>
|
||||
{showing &&
|
||||
createPortal(
|
||||
<aside
|
||||
{...props}
|
||||
class={`${style.flyout} ${props.class || props.className || ''}`}
|
||||
ref={this.menu}
|
||||
data-anchor={anchorText}
|
||||
data-direction={directionText}
|
||||
>
|
||||
{children}
|
||||
</aside>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// not worth pulling in compat
|
||||
function createPortal(children: ComponentChildren, parent: Element) {
|
||||
return <Portal parent={parent}>{children}</Portal>;
|
||||
}
|
||||
// this is probably overly careful, since it works directly rendering into parent
|
||||
function createPersistentFragment(parent: Element) {
|
||||
const frag = {
|
||||
nodeType: 11,
|
||||
childNodes: [],
|
||||
appendChild: parent.appendChild.bind(parent),
|
||||
insertBefore: parent.insertBefore.bind(parent),
|
||||
removeChild: parent.removeChild.bind(parent),
|
||||
};
|
||||
return (frag as unknown) as Element;
|
||||
}
|
||||
class Portal extends Component<{
|
||||
children: ComponentChildren;
|
||||
parent: Element;
|
||||
}> {
|
||||
root = createPersistentFragment(this.props.parent);
|
||||
componentWillUnmount() {
|
||||
render(null, this.root);
|
||||
}
|
||||
render() {
|
||||
render(<Fragment>{this.props.children}</Fragment>, this.root);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
.wrap {
|
||||
display: inline;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.wrap > aside:last-of-type {
|
||||
.flyout {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -23,44 +23,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* align to the right edge */
|
||||
&[data-anchor*='right'] {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* open to the left */
|
||||
&[data-direction*='left'] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
align-items: flex-end;
|
||||
&[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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, Component } from 'preact';
|
||||
import { h, Component, createRef } from 'preact';
|
||||
|
||||
import * as style from './style.css';
|
||||
import 'add-css:./style.css';
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
import Expander from './Expander';
|
||||
import Toggle from './Toggle';
|
||||
import Select from './Select';
|
||||
import Flyout from '../Flyout';
|
||||
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
|
||||
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
|
||||
import { CLIIcon, SwapIcon } from 'client/lazy-app/icons';
|
||||
import { CLIIcon, MoreIcon, SwapIcon } from 'client/lazy-app/icons';
|
||||
|
||||
interface Props {
|
||||
index: 0 | 1;
|
||||
@@ -64,6 +65,8 @@ export default class Options extends Component<Props, State> {
|
||||
supportedEncoderMap: undefined,
|
||||
};
|
||||
|
||||
menu = createRef<Flyout>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
supportedEncoderMapP.then((supportedEncoderMap) =>
|
||||
@@ -110,10 +113,12 @@ export default class Options extends Component<Props, State> {
|
||||
|
||||
private onCopyCliClick = () => {
|
||||
this.props.onCopyCliClick(this.props.index);
|
||||
if (this.menu.current) this.menu.current.hide();
|
||||
};
|
||||
|
||||
private onCopyToOtherSideClick = () => {
|
||||
this.props.onCopyToOtherSideClick(this.props.index);
|
||||
if (this.menu.current) this.menu.current.hide();
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -136,23 +141,33 @@ export default class Options extends Component<Props, State> {
|
||||
{!encoderState ? null : (
|
||||
<div>
|
||||
<h3 class={style.optionsTitle}>
|
||||
<div class={style.titleAndButtons}>
|
||||
Edit
|
||||
Edit
|
||||
<Flyout
|
||||
ref={this.menu}
|
||||
class={style.menu}
|
||||
direction={['up', 'left']}
|
||||
anchor="right"
|
||||
toggle={
|
||||
<button class={style.titleButton}>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class={style.cliButton}
|
||||
title="Copy npx command"
|
||||
class={style.menuButton}
|
||||
onClick={this.onCopyCliClick}
|
||||
>
|
||||
<CLIIcon />
|
||||
Copy npx command
|
||||
</button>
|
||||
<button
|
||||
class={style.copyOverButton}
|
||||
title="Copy settings to other side"
|
||||
class={style.menuButton}
|
||||
onClick={this.onCopyToOtherSideClick}
|
||||
>
|
||||
<SwapIcon />
|
||||
Copy settings to other side
|
||||
</button>
|
||||
</div>
|
||||
</Flyout>
|
||||
</h3>
|
||||
<label class={style.sectionEnabler}>
|
||||
Resize
|
||||
|
||||
@@ -14,13 +14,21 @@
|
||||
background-color: var(--main-theme-color);
|
||||
color: var(--header-text-color);
|
||||
margin: 0;
|
||||
padding: 10px var(--horizontal-padding);
|
||||
height: 38px;
|
||||
padding: 0 var(--horizontal-padding);
|
||||
font-weight: bold;
|
||||
font-size: 1.4rem;
|
||||
border-bottom: 1px solid var(--off-black);
|
||||
transition: all 300ms ease-in-out;
|
||||
transition-property: background-color, color;
|
||||
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-columns: max-content;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.8rem 0;
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -82,36 +90,63 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.title-and-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-columns: max-content;
|
||||
grid-auto-flow: column;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
.menu {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.title-button {
|
||||
position: relative;
|
||||
left: 10px;
|
||||
composes: unbutton from global;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
svg {
|
||||
--size: 20px;
|
||||
--size: 24px;
|
||||
fill: var(--header-text-color);
|
||||
display: block;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.cli-button {
|
||||
composes: title-button;
|
||||
.menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
margin: 8px 0;
|
||||
background-color: rgba(29, 29, 29, 0.92);
|
||||
border: 1px solid rgba(0, 0, 0, 0.67);
|
||||
border-radius: 2rem;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
height: 39px;
|
||||
padding: 0 16px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
|
||||
svg {
|
||||
stroke: var(--header-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-over-button {
|
||||
composes: title-button;
|
||||
|
||||
svg {
|
||||
fill: var(--header-text-color);
|
||||
&:hover {
|
||||
background: rgba(50, 50, 50, 0.92);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
outline: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 12px;
|
||||
color: var(--main-theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface State {
|
||||
altBackground: boolean;
|
||||
transform: boolean;
|
||||
menuOpen: boolean;
|
||||
smallControls: boolean;
|
||||
}
|
||||
|
||||
const scaleToOpts: ScaleToOpts = {
|
||||
@@ -56,6 +57,9 @@ export default class Output extends Component<Props, State> {
|
||||
altBackground: false,
|
||||
transform: false,
|
||||
menuOpen: false,
|
||||
smallControls:
|
||||
typeof matchMedia === 'function' &&
|
||||
matchMedia('(max-width: 859px)').matches,
|
||||
};
|
||||
canvasLeft?: HTMLCanvasElement;
|
||||
canvasRight?: HTMLCanvasElement;
|
||||
@@ -84,6 +88,12 @@ export default class Output extends Component<Props, State> {
|
||||
if (this.canvasRight && rightDraw) {
|
||||
drawDataToCanvas(this.canvasRight, rightDraw);
|
||||
}
|
||||
|
||||
if (typeof matchMedia === 'function') {
|
||||
matchMedia('(max-width: 859px)').addEventListener('change', (e) =>
|
||||
this.setState({ smallControls: e.matches }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
@@ -278,7 +288,7 @@ export default class Output extends Component<Props, State> {
|
||||
onShowPreprocessorTransforms,
|
||||
onToggleBackground,
|
||||
}: Props,
|
||||
{ scale, editingScale }: State,
|
||||
{ scale, editingScale, smallControls }: State,
|
||||
) {
|
||||
const leftDraw = this.leftDrawable();
|
||||
const rightDraw = this.rightDrawable();
|
||||
@@ -370,7 +380,7 @@ export default class Output extends Component<Props, State> {
|
||||
class={style.menu}
|
||||
showing={hidden ? false : undefined}
|
||||
anchor="right"
|
||||
direction={mobileView ? ['down', 'left'] : 'up'}
|
||||
direction={smallControls ? ['down', 'left'] : 'up'}
|
||||
toggle={
|
||||
<button class={`${style.button} ${style.moreButton}`}>
|
||||
<MoreIcon />
|
||||
|
||||
@@ -187,7 +187,7 @@ input.zoom {
|
||||
}
|
||||
}
|
||||
|
||||
[data-flyout-open] {
|
||||
.controls [data-flyout-open] {
|
||||
.moreButton {
|
||||
background: rgba(82, 82, 82, 0.92);
|
||||
|
||||
|
||||
@@ -858,7 +858,7 @@ export default class Compress extends Component<Props, State> {
|
||||
}
|
||||
|
||||
render(
|
||||
{ onBack }: Props,
|
||||
{ onBack, showSnack }: Props,
|
||||
{
|
||||
loading,
|
||||
sides,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
const Icon = (props: preact.JSX.HTMLAttributes) => (
|
||||
const Icon = (props: preact.JSX.HTMLAttributes | preact.JSX.SVGAttributes) => (
|
||||
// @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019
|
||||
<svg
|
||||
width="24"
|
||||
@@ -11,9 +11,15 @@ const Icon = (props: preact.JSX.HTMLAttributes) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const CLIIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||
<Icon {...props}>
|
||||
<path d="M1 2.7H23v18.5H1zm5.5 13l3.7-3.7-3.7-3.7m5.5 7.4h5.6" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const SwapIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||
<Icon {...props}>
|
||||
<path d="M9.01 14H2v2h7.01v3L13 15l-3.99-4zm5.98-1v-3H22V8h-7.01V5L11 9z" />
|
||||
<path d="M8.5 8.6v6.8L5.1 12l3.4-3.4M10 5l-7 7 7 7V5zm4 0v14l7-7-7-7z" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
@@ -77,9 +83,9 @@ export const RotateIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||
|
||||
export const MoreIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||
<Icon {...props}>
|
||||
<circle cx="12" cy="6" r="2" fill="#fff" />
|
||||
<circle cx="12" cy="12" r="2" fill="#fff" />
|
||||
<circle cx="12" cy="18" r="2" fill="#fff" />
|
||||
<circle cx="12" cy="6" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="18" r="2" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
@@ -124,14 +130,3 @@ export const DownloadIcon = () => (
|
||||
<path d="M6.6 2.7h-4v13.2h2.7A2.7 2.7 0 018 18.6a2.7 2.7 0 002.6 2.6h2.7a2.7 2.7 0 002.6-2.6 2.7 2.7 0 012.7-2.7h2.6V2.7h-4a1.3 1.3 0 110-2.7h4A2.7 2.7 0 0124 2.7v18.5a2.7 2.7 0 01-2.7 2.7H2.7A2.7 2.7 0 010 21.2V2.7A2.7 2.7 0 012.7 0h4a1.3 1.3 0 010 2.7zm4 7.4V1.3a1.3 1.3 0 112.7 0v8.8L15 8.4a1.3 1.3 0 011.9 1.8l-4 4a1.3 1.3 0 01-1.9 0l-4-4A1.3 1.3 0 019 8.4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CLIIcon = () => (
|
||||
<svg viewBox="0 0 81.3 68.8">
|
||||
<path
|
||||
fill="none"
|
||||
stroke-miterlimit="15.6"
|
||||
stroke-width="6.3"
|
||||
d="M3.1 3.1h75v62.5h-75zm18.8 43.8l12.5-12.5-12.5-12.5m18.7 25h18.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user