diff --git a/src/components/Options/index.tsx b/src/components/Options/index.tsx index d0daba2d..2f16c4a4 100644 --- a/src/components/Options/index.tsx +++ b/src/components/Options/index.tsx @@ -1,7 +1,7 @@ import { h, Component } from 'preact'; import * as style from './style.scss'; -import { bind, Fileish } from '../../lib/initial-util'; +import { bind } from '../../lib/initial-util'; import { cleanSet, cleanMerge } from '../../lib/clean-modify'; import OptiPNGEncoderOptions from '../../codecs/optipng/options'; import MozJpegEncoderOptions from '../../codecs/mozjpeg/options'; @@ -35,13 +35,10 @@ import { import { QuantizeOptions } from '../../codecs/imagequant/processor-meta'; import { ResizeOptions } from '../../codecs/resize/processor-meta'; import { PreprocessorState } from '../../codecs/preprocessors'; -import FileSize from './FileSize'; -import { DownloadIcon } from '../../lib/icons'; import { SourceImage } from '../App'; import Checkbox from '../checkbox'; import Expander from '../expander'; import Select from '../select'; -import '../custom-els/LoadingSpinner'; const encoderOptionsComponentMap = { [identity.type]: undefined, @@ -60,12 +57,9 @@ const encoderOptionsComponentMap = { }; interface Props { - orientation: 'horizontal' | 'vertical'; - loading: boolean; + mobileView: boolean; source?: SourceImage; imageIndex: number; - imageFile?: Fileish; - downloadUrl?: string; encoderState: EncoderState; preprocessorState: PreprocessorState; onEncoderTypeChange(newType: EncoderType): void; @@ -76,39 +70,18 @@ interface Props { interface State { encoderSupportMap?: EncoderSupportMap; - showLoadingState: boolean; } -const loadingReactionDelay = 500; - export default class Options extends Component { state: State = { encoderSupportMap: undefined, - showLoadingState: false, }; - /** The timeout ID between entering the loading state, and changing UI */ - private loadingTimeoutId: number = 0; - constructor() { super(); encodersSupported.then(encoderSupportMap => this.setState({ encoderSupportMap })); } - componentDidUpdate(prevProps: Props, prevState: State) { - if (prevProps.loading && !this.props.loading) { - // Just stopped loading - clearTimeout(this.loadingTimeoutId); - this.setState({ showLoadingState: false }); - } else if (!prevProps.loading && this.props.loading) { - // Just started loading - this.loadingTimeoutId = self.setTimeout( - () => this.setState({ showLoadingState: true }), - loadingReactionDelay, - ); - } - } - @bind onEncoderTypeChange(event: Event) { const el = event.currentTarget as HTMLSelectElement; @@ -153,20 +126,17 @@ export default class Options extends Component { { source, imageIndex, - imageFile, - downloadUrl, - orientation, encoderState, preprocessorState, onEncoderOptionsChange, }: Props, - { encoderSupportMap, showLoadingState }: State, + { encoderSupportMap }: State, ) { // tslint:disable variable-name const EncoderOptionComponent = encoderOptionsComponentMap[encoderState.type]; return ( -
+
{encoderState.type === identity.type ? null :
@@ -211,32 +181,30 @@ export default class Options extends Component {

Compress

-
-
- {encoderSupportMap ? - - : - - } -
+
+ {encoderSupportMap ? + + : + + } +
- - {EncoderOptionComponent ? - - : null} - -
+ + {EncoderOptionComponent ? + + : null} +
- -
-
- {!imageFile || showLoadingState ? 'Working…' : - - } -
- -
- {(downloadUrl && imageFile) && ( - - - - )} - {showLoadingState && } -
- -
-
); } diff --git a/src/components/Options/style.scss b/src/components/Options/style.scss index a8c3a64f..d7f7180a 100644 --- a/src/components/Options/style.scss +++ b/src/components/Options/style.scss @@ -1,15 +1,5 @@ $horizontalPadding: 15px; -.options { - color: #fff; - width: 300px; - opacity: 0.9; - font-size: 1.2rem; - max-height: 100%; - display: flex; - flex-flow: column; -} - .options-title { background: rgba(0, 0, 0, 0.9); margin: 0; @@ -66,35 +56,6 @@ $horizontalPadding: 15px; overflow-y: auto; } -.results { - display: grid; - grid-template-columns: 1fr auto; - background: rgba(0, 0, 0, 0.9); - font-size: 1.4rem; -} - -.result-data { - display: flex; - align-items: center; - padding: 0 $horizontalPadding; -} - -.size-delta { - font-size: 1.1rem; - font-style: italic; - position: relative; - top: -1px; - margin-left: 0.3em; -} - -.size-increase { - color: #e35050; -} - -.size-decrease { - color: #50e3c2; -} - .options-copy { display: grid; background: rgba(0, 0, 0, 0.9); @@ -109,58 +70,3 @@ $horizontalPadding: 15px; text-align: left; padding: 5px 10px; } - -@keyframes action-enter { - from { - transform: rotate(-90deg); - opacity: 0; - animation-timing-function: ease-out; - } -} - -@keyframes action-leave { - from { - transform: rotate(0deg); - opacity: 1; - animation-timing-function: ease-out; - } -} - -.download { - background: #34B9EB; - --size: 38px; - width: var(--size); - height: var(--size); - display: grid; - align-items: center; - justify-items: center; -} - -.download-link { - animation: action-enter 0.2s; - grid-area: 1/1; -} - -.download-link-disable { - pointer-events: none; - opacity: 0; - transform: rotate(90deg); - animation: action-leave 0.2s; -} - -.download-icon { - color: #fff; - display: block; - --size: 24px; - width: var(--size); - height: var(--size); - padding: 7px; - filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.7)); -} - -.spinner { - --color: #fff; - --delay: 0; - --size: 22px; - grid-area: 1/1; -} diff --git a/src/components/Output/custom-els/TwoUp/styles.css b/src/components/Output/custom-els/TwoUp/styles.css index d40b8735..73c555da 100644 --- a/src/components/Output/custom-els/TwoUp/styles.css +++ b/src/components/Output/custom-els/TwoUp/styles.css @@ -56,7 +56,7 @@ two-up[legacy-clip-compat] > :not(.two-up-handle) { box-shadow: 0 1px 4px rgba(0,0,0,0.1); color: var(--thumb-color); box-sizing: border-box; - padding: 0 48%; + padding: 0 calc(var(--thumb-size) * 0.24); } .scrubber svg { diff --git a/src/components/Output/index.tsx b/src/components/Output/index.tsx index 2aec617a..5a80939b 100644 --- a/src/components/Output/index.tsx +++ b/src/components/Output/index.tsx @@ -10,7 +10,7 @@ import { twoUpHandle } from './custom-els/TwoUp/styles.css'; interface Props { originalImage?: ImageData; - orientation: 'horizontal' | 'vertical'; + mobileView: boolean; leftCompressed?: ImageData; rightCompressed?: ImageData; leftImgContain: boolean; @@ -180,10 +180,7 @@ export default class Output extends Component { } render( - { - orientation, leftCompressed, rightCompressed, leftImgContain, rightImgContain, - originalImage, - }: Props, + { mobileView, leftImgContain, rightImgContain, originalImage }: Props, { scale, editingScale, altBackground }: State, ) { const leftDraw = this.leftDrawable(); @@ -194,7 +191,7 @@ export default class Output extends Component { * { + pointer-events: auto; + } + @media (min-width: 860px) { top: auto; left: 320px; diff --git a/src/components/output/custom-els/MultiPanel/index.ts b/src/components/compress/custom-els/MultiPanel/index.ts similarity index 60% rename from src/components/output/custom-els/MultiPanel/index.ts rename to src/components/compress/custom-els/MultiPanel/index.ts index 95a06dcd..39bdecc1 100644 --- a/src/components/output/custom-els/MultiPanel/index.ts +++ b/src/components/compress/custom-els/MultiPanel/index.ts @@ -1,19 +1,75 @@ -import './styles.css'; +import * as style from './styles.css'; +import { transitionHeight } from '../../../../lib/util'; -function getClosestHeading(el: Element) { - const closestEl = el.closest('multi-panel > *'); - if (closestEl && closestEl.classList.contains('panel-heading')) { - return closestEl; +interface CloseAllOptions { + exceptFirst?: boolean; +} + +const openOneOnlyAttr = 'open-one-only'; + +function getClosestHeading(el: Element): HTMLElement | undefined { + // Look for the child of multi-panel, but stop at interactive elements like links & buttons + const closestEl = el.closest('multi-panel > *, a, button'); + if (closestEl && closestEl.classList.contains(style.panelHeading)) { + return closestEl as HTMLElement; } return undefined; } +async function close(heading: HTMLElement) { + const content = heading.nextElementSibling as HTMLElement; + + // if there is no content, nothing to expand + if (!content) return; + + const from = content.getBoundingClientRect().height; + + heading.removeAttribute('content-expanded'); + content.setAttribute('aria-expanded', 'false'); + + // Wait a microtask so other calls to open/close can get the final sizes. + await null; + + await transitionHeight(content, { + from, + to: 0, + duration: 300, + }); + + content.style.height = ''; +} + +async function open(heading: HTMLElement) { + const content = heading.nextElementSibling as HTMLElement; + + // if there is no content, nothing to expand + if (!content) return; + + const from = content.getBoundingClientRect().height; + + heading.setAttribute('content-expanded', ''); + content.setAttribute('aria-expanded', 'true'); + + const to = content.getBoundingClientRect().height; + + // Wait a microtask so other calls to open/close can get the final sizes. + await null; + + await transitionHeight(content, { + from, to, + duration: 300, + }); + + content.style.height = ''; +} + /** * A multi-panel view that the user can add any number of 'panels'. * 'a panel' consists of two elements. Even index element becomes heading, * and odd index element becomes the expandable content. */ export default class MultiPanel extends HTMLElement { + static get observedAttributes() { return [openOneOnlyAttr]; } constructor() { super(); @@ -31,12 +87,18 @@ export default class MultiPanel extends HTMLElement { this._childrenChange(); } + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (name === openOneOnlyAttr && newValue === null) { + this._closeAll({ exceptFirst: true }); + } + } + // Click event handler private _onClick(event: MouseEvent) { - const el = event.target as Element; + const el = event.target as HTMLElement; const heading = getClosestHeading(el); if (!heading) return; - this._expand(heading); + this._toggle(heading); } // KeyDown event handler @@ -53,7 +115,8 @@ export default class MultiPanel extends HTMLElement { // don’t handle modifier shortcuts used by assistive technology. if (event.altKey) return; - let newHeading:HTMLElement | undefined; + let newHeading: HTMLElement | undefined; + switch (event.key) { case 'ArrowLeft': case 'ArrowUp': @@ -77,7 +140,7 @@ export default class MultiPanel extends HTMLElement { case 'Enter': case ' ': case 'Spacebar': - this._expand(heading); + this._toggle(heading); break; // Any other key press is ignored and passed back to the browser. @@ -93,26 +156,32 @@ export default class MultiPanel extends HTMLElement { } } - private _expand(heading: Element) { + private _toggle(heading: HTMLElement) { if (!heading) return; - const content = heading.nextElementSibling; - - // if there is no content, nothing to expand - if (!content) return; // toggle expanded and aria-expanded attributes - if (content.hasAttribute('expanded')) { - content.removeAttribute('expanded'); - content.setAttribute('aria-expanded', 'false'); + if (heading.hasAttribute('content-expanded')) { + close(heading); } else { - content.setAttribute('expanded', ''); - content.setAttribute('aria-expanded', 'true'); + if (this.openOneOnly) this._closeAll(); + open(heading); } } + private _closeAll(options: CloseAllOptions = {}): void { + const { exceptFirst = false } = options; + let els = [...this.children].filter(el => el.matches('[content-expanded]')) as HTMLElement[]; + + if (exceptFirst) { + els = els.slice(1); + } + + for (const el of els) close(el); + } + // children of multi-panel should always be even number (heading/content pair) private _childrenChange() { - let preserveTabIndex : boolean = false; + let preserveTabIndex = false; let heading = this.firstElementChild; while (heading) { @@ -123,31 +192,23 @@ export default class MultiPanel extends HTMLElement { // it means it has odd number of elements. log error and set heading to end the loop. if (!content) { console.error(' requires an even number of element children.'); - heading = null; - continue; + break; } // When odd number of elements were inserted in the middle, // what was heading before may become content after the insertion. // Remove classes and attributes to prepare for this change. - heading.classList.remove('panel-content'); - - if (content.classList.contains('panel-heading')) { - content.classList.remove('panel-heading'); - } - if (heading.hasAttribute('expanded') && heading.hasAttribute('aria-expanded')) { - heading.removeAttribute('expanded'); - heading.removeAttribute('aria-expanded'); - } + heading.classList.remove(style.panelContent); + content.classList.remove(style.panelHeading); + heading.removeAttribute('aria-expanded'); + heading.removeAttribute('content-expanded'); // If appreciable, remove tabindex from content which used to be header. - if (content.hasAttribute('tabindex')) { - content.removeAttribute('tabindex'); - } + content.removeAttribute('tabindex'); // Assign heading and content classes - heading.classList.add('panel-heading'); - content.classList.add('panel-content'); + heading.classList.add(style.panelHeading); + content.classList.add(style.panelContent); // Assign ids and aria-X for heading/content pair. heading.id = `panel-heading-${randomId}`; @@ -163,6 +224,13 @@ export default class MultiPanel extends HTMLElement { heading.setAttribute('tabindex', '-1'); } + // It's possible that the heading & content expanded attributes are now out of sync. Resync + // them using the heading as the source of truth. + content.setAttribute( + 'aria-expanded', + heading.hasAttribute('content-expanded') ? 'true' : 'false', + ); + // next sibling of content = next heading heading = content.nextElementSibling; } @@ -171,6 +239,9 @@ export default class MultiPanel extends HTMLElement { if (!preserveTabIndex && this.firstElementChild) { this.firstElementChild.setAttribute('tabindex', '0'); } + + // In case we're openOneOnly, and an additional open item has been added: + if (this.openOneOnly) this._closeAll({ exceptFirst: true }); } // returns heading that is before currently selected one. @@ -208,7 +279,7 @@ export default class MultiPanel extends HTMLElement { private _lastHeading() { // if the last element is heading, return last element const lastEl = this.lastElementChild as HTMLElement; - if (lastEl && lastEl.classList.contains('panel-heading')) { + if (lastEl && lastEl.classList.contains(style.panelHeading)) { return lastEl; } // otherwise return 2nd from the last @@ -217,6 +288,21 @@ export default class MultiPanel extends HTMLElement { return lastContent.previousElementSibling as HTMLElement; } } + + /** + * If true, only one panel can be open at once. When one opens, others close. + */ + get openOneOnly() { + return this.hasAttribute(openOneOnlyAttr); + } + + set openOneOnly(val: boolean) { + if (val) { + this.setAttribute(openOneOnlyAttr, ''); + } else { + this.removeAttribute(openOneOnlyAttr); + } + } } customElements.define('multi-panel', MultiPanel); diff --git a/src/components/compress/custom-els/MultiPanel/missing-types.d.ts b/src/components/compress/custom-els/MultiPanel/missing-types.d.ts new file mode 100644 index 00000000..b775fa8a --- /dev/null +++ b/src/components/compress/custom-els/MultiPanel/missing-types.d.ts @@ -0,0 +1,9 @@ +interface MultiPanelAttributes extends JSX.HTMLAttributes { + 'open-one-only'?: boolean; +} + +declare namespace JSX { + interface IntrinsicElements { + 'multi-panel': MultiPanelAttributes; + } +} diff --git a/src/components/compress/custom-els/MultiPanel/styles.css b/src/components/compress/custom-els/MultiPanel/styles.css new file mode 100644 index 00000000..07ce0f6f --- /dev/null +++ b/src/components/compress/custom-els/MultiPanel/styles.css @@ -0,0 +1,10 @@ +.panel-heading { + background: gray; +} +.panel-content { + height: 0px; + overflow: auto; +} +.panel-content[aria-expanded=true] { + height: auto; +} diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx index 700c244e..2fc9c553 100644 --- a/src/components/compress/index.tsx +++ b/src/components/compress/index.tsx @@ -24,18 +24,17 @@ import { EncoderOptions, encoderMap, } from '../../codecs/encoders'; - import { PreprocessorState, defaultPreprocessorState, } from '../../codecs/preprocessors'; - import { decodeImage } from '../../codecs/decoders'; import { cleanMerge, cleanSet } from '../../lib/clean-modify'; import Processor from '../../codecs/processor'; import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta'; - -type Orientation = 'horizontal' | 'vertical'; +import './custom-els/MultiPanel'; +import Results from '../results'; +import { ExpandIcon } from '../../lib/icons'; export interface SourceImage { file: File | Fileish; @@ -69,7 +68,7 @@ interface State { loading: boolean; loadingCounter: number; error?: string; - orientation: Orientation; + mobileView: boolean; } interface UpdateImageOptions { @@ -155,8 +154,11 @@ async function processSvg(blob: Blob): Promise { return blobToImg(new Blob([newSource], { type: 'image/svg+xml' })); } +// These are only used in the mobile view +const resultTitles = ['Top', 'Bottom']; + export default class Compress extends Component { - widthQuery = window.matchMedia('(min-width: 500px)'); + widthQuery = window.matchMedia('(max-width: 599px)'); state: State = { source: undefined, @@ -178,7 +180,7 @@ export default class Compress extends Component { loading: false, }, ], - orientation: this.widthQuery.matches ? 'horizontal' : 'vertical', + mobileView: this.widthQuery.matches, }; private readonly encodeCache = new ResultCache(); @@ -193,7 +195,7 @@ export default class Compress extends Component { @bind private onMobileWidthChange() { - this.setState({ orientation: this.widthQuery.matches ? 'horizontal' : 'vertical' }); + this.setState({ mobileView: this.widthQuery.matches }); } private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { @@ -395,36 +397,69 @@ export default class Compress extends Component { this.setState({ images }); } - render({ }: Props, { loading, images, source, orientation }: State) { + render({ }: Props, { loading, images, source, mobileView }: State) { const [leftImage, rightImage] = images; const [leftImageData, rightImageData] = images.map(i => i.data); + const options = images.map((image, index) => ( + + )); + + const results = images.map((image, i) => ( + + {!mobileView ? null : [ + , + `${resultTitles[i]} (${encoderMap[image.encoderState.type].label})`, + ]} + + )); + return ( -
+
- {images.map((image, index) => ( - - ))} + {mobileView + ? ( +
+ + {results[0]} + {options[0]} + {results[1]} + {options[1]} + +
+ ) : ([ +
+ {options[0]} + {results[0]} +
, +
+ {options[1]} + {results[1]} +
, + ]) + }
); } diff --git a/src/components/compress/style.scss b/src/components/compress/style.scss index efb7e628..6956af3c 100644 --- a/src/components/compress/style.scss +++ b/src/components/compress/style.scss @@ -3,20 +3,72 @@ height: 100%; contain: strict; display: grid; + align-items: end; + align-content: end; + grid-template-rows: 1fr auto; - &.horizontal { + @media (min-width: 600px) { grid-template-columns: 1fr auto; - grid-template-rows: calc(100% - 75px); - - @media (min-width: 860px) { - grid-template-rows: 100%; - } - align-items: end; - align-content: end; - } - - &.vertical { - // TODO: make the mobile view work - background: red; + grid-template-rows: 100%; } } + +.options { + color: #fff; + opacity: 0.9; + font-size: 1.2rem; + display: flex; + flex-flow: column; + max-width: 400px; + margin: 0 auto; + width: calc(100% - 60px); + max-height: calc(100% - 143px); + overflow: hidden; + + @media (min-width: 600px) { + max-height: calc(100% - 75px); + width: 300px; + margin: 0; + } + + @media (min-width: 860px) { + max-height: 100%; + } +} + +.multi-panel { + position: relative; + display: flex; + flex-flow: column; + + // Reorder so headings appear after content: + & > :nth-child(1) { + order: 2; + margin-bottom: 10px; + } + + & > :nth-child(2) { + order: 1; + } + + & > :nth-child(3) { + order: 4; + } + + & > :nth-child(4) { + order: 3; + } +} + +.expand-icon { + transform: rotate(180deg); + margin-left: -12px; +} + +[content-expanded] .expand-icon { + transform: none; +} + +:focus .expand-icon { + fill: #34B9EB; +} diff --git a/src/components/expander/index.tsx b/src/components/expander/index.tsx index 49099cc2..8099937a 100644 --- a/src/components/expander/index.tsx +++ b/src/components/expander/index.tsx @@ -1,6 +1,6 @@ import { h, Component, ComponentChild, ComponentChildren } from 'preact'; import * as style from './style.scss'; -import { linkRef } from '../../lib/initial-util'; +import { transitionHeight } from '../../lib/util'; interface Props { children: ComponentChildren; @@ -13,7 +13,6 @@ export default class Expander extends Component { state: State = { outgoingChildren: [], }; - private el?: HTMLDivElement; private lastElHeight: number = 0; componentWillReceiveProps(nextProps: Props) { @@ -32,10 +31,10 @@ export default class Expander extends Component { // Only interested if going from empty to not-empty, or not-empty to empty. if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0])) return; - this.lastElHeight = this.el!.getBoundingClientRect().height; + this.lastElHeight = this.base!.getBoundingClientRect().height; } - componentDidUpdate(previousProps: Props) { + async componentDidUpdate(previousProps: Props) { const children = this.props.children as ComponentChild[]; const previousChildren = previousProps.children as ComponentChild[]; @@ -43,37 +42,20 @@ export default class Expander extends Component { if ((children[0] && previousChildren[0]) || (!children[0] && !previousChildren[0])) return; // What height do we need to transition to? - this.el!.style.transition = 'none'; - this.el!.style.height = ''; - const newHeight = children[0] ? this.el!.getBoundingClientRect().height : 0; + this.base!.style.height = ''; + this.base!.style.overflow = 'hidden'; + const newHeight = children[0] ? this.base!.getBoundingClientRect().height : 0; - if (this.lastElHeight === newHeight) { - this.el!.style.transition = ''; - return; - } + await transitionHeight(this.base!, { + duration: 300, + from: this.lastElHeight, + to: newHeight, + }); - // Set the currently rendered height absolutely. - this.el!.style.height = this.lastElHeight + 'px'; - this.el!.style.transition = ''; - this.el!.style.overflow = 'hidden'; - // Force a style calc so the browser picks up the start value. - getComputedStyle(this.el!).height; - // Animate to the new height. - this.el!.style.height = newHeight + 'px'; - - const listener = () => { - // Unset the height & overflow, so element changes do the right thing. - this.el!.style.height = ''; - this.el!.style.overflow = ''; - this.el!.removeEventListener('transitionend', listener); - this.el!.removeEventListener('transitioncancel', listener); - if (this.state.outgoingChildren[0]) { - this.setState({ outgoingChildren: [] }); - } - }; - - this.el!.addEventListener('transitionend', listener); - this.el!.addEventListener('transitioncancel', listener); + // Unset the height & overflow, so element changes do the right thing. + this.base!.style.height = ''; + this.base!.style.overflow = ''; + if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] }); } render(props: Props, { outgoingChildren }: State) { @@ -81,10 +63,7 @@ export default class Expander extends Component { const childrenExiting = !children[0] && outgoingChildren[0]; return ( -
+
{children[0] ? children : outgoingChildren}
); diff --git a/src/components/expander/style.scss b/src/components/expander/style.scss index f72985b7..5eeaeeb4 100644 --- a/src/components/expander/style.scss +++ b/src/components/expander/style.scss @@ -1,7 +1,3 @@ -.expander { - transition: height 200ms ease-in-out; -} - .children-exiting { & > * { pointer-events: none; diff --git a/src/components/output/custom-els/MultiPanel/styles.css b/src/components/output/custom-els/MultiPanel/styles.css deleted file mode 100644 index 97047454..00000000 --- a/src/components/output/custom-els/MultiPanel/styles.css +++ /dev/null @@ -1,11 +0,0 @@ -multi-panel > .panel-heading { - background:gray; -} -multi-panel > .panel-content { - height:0px; - overflow:scroll; - transition: height 1s; -} -multi-panel > .panel-content[expanded] { - height:auto; -} diff --git a/src/components/Options/FileSize.tsx b/src/components/results/FileSize.tsx similarity index 100% rename from src/components/Options/FileSize.tsx rename to src/components/results/FileSize.tsx diff --git a/src/components/results/index.tsx b/src/components/results/index.tsx new file mode 100644 index 00000000..f3f865cb --- /dev/null +++ b/src/components/results/index.tsx @@ -0,0 +1,78 @@ +import { h, Component, ComponentChildren, ComponentChild } from 'preact'; + +import * as style from './style.scss'; +import FileSize from './FileSize'; +import { DownloadIcon } from '../../lib/icons'; +import '../custom-els/LoadingSpinner'; +import { SourceImage } from '../compress'; +import { Fileish } from '../../lib/initial-util'; + +interface Props { + loading: boolean; + source?: SourceImage; + imageFile?: Fileish; + downloadUrl?: string; + children: ComponentChildren; +} + +interface State { + showLoadingState: boolean; +} + +const loadingReactionDelay = 500; + +export default class Results extends Component { + state: State = { + showLoadingState: false, + }; + + /** The timeout ID between entering the loading state, and changing UI */ + private loadingTimeoutId: number = 0; + + componentDidUpdate(prevProps: Props, prevState: State) { + if (prevProps.loading && !this.props.loading) { + // Just stopped loading + clearTimeout(this.loadingTimeoutId); + this.setState({ showLoadingState: false }); + } else if (!prevProps.loading && this.props.loading) { + // Just started loading + this.loadingTimeoutId = self.setTimeout( + () => this.setState({ showLoadingState: true }), + loadingReactionDelay, + ); + } + } + + render({ source, imageFile, downloadUrl, children }: Props, { showLoadingState }: State) { + return ( +
+
+ {(children as ComponentChild[])[0] + ?
{children}
+ : null + } + {!imageFile || showLoadingState ? 'Working…' : + + } +
+ +
+ {(downloadUrl && imageFile) && ( + + + + )} + {showLoadingState && } +
+
+ ); + } +} diff --git a/src/components/results/style.scss b/src/components/results/style.scss new file mode 100644 index 00000000..420a8da4 --- /dev/null +++ b/src/components/results/style.scss @@ -0,0 +1,95 @@ +@keyframes action-enter { + from { + transform: rotate(-90deg); + opacity: 0; + animation-timing-function: ease-out; + } +} + +@keyframes action-leave { + from { + transform: rotate(0deg); + opacity: 1; + animation-timing-function: ease-out; + } +} + +.results { + display: grid; + grid-template-columns: 1fr auto; + background: rgba(0, 0, 0, 0.9); + font-size: 1.4rem; + + &:focus { + outline: none; + } +} + +.result-data { + display: flex; + align-items: center; + padding: 0 15px; + white-space: nowrap; + overflow: hidden; +} + +.result-title { + display: flex; + align-items: center; + margin-right: 0.4em; +} + +.size-delta { + font-size: 1.1rem; + font-style: italic; + position: relative; + top: -1px; + margin-left: 0.3em; +} + +.size-increase { + color: #e35050; +} + +.size-decrease { + color: #50e3c2; +} + +.download { + background: #34B9EB; + --size: 38px; + width: var(--size); + height: var(--size); + display: grid; + align-items: center; + justify-items: center; +} + +.download-link { + animation: action-enter 0.2s; + grid-area: 1/1; +} + +.download-link-disable { + pointer-events: none; + opacity: 0; + transform: rotate(90deg); + animation: action-leave 0.2s; +} + +.download-icon { + color: #fff; + display: block; + --size: 24px; + width: var(--size); + height: var(--size); + padding: 7px; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.7)); +} + +.spinner { + --color: #fff; + --delay: 0; + --size: 22px; + grid-area: 1/1; +} diff --git a/src/custom-els/RangeInput/index.ts b/src/custom-els/RangeInput/index.ts index 1195fa79..4cefcb03 100644 --- a/src/custom-els/RangeInput/index.ts +++ b/src/custom-els/RangeInput/index.ts @@ -1,5 +1,6 @@ import { bind } from '../../lib/initial-util'; import * as style from './styles.css'; +import { PointerTracker } from '../../lib/PointerTracker'; const RETARGETED_EVENTS = ['focus', 'blur']; const UPDATE_EVENTS = ['input', 'change']; @@ -26,6 +27,17 @@ class RangeInputElement extends HTMLElement { this._input.type = 'range'; this._input.className = style.input; + const tracker = new PointerTracker(this._input, { + start: (): boolean => { + if (tracker.currentPointers.length !== 0) return false; + this._input.classList.add(style.touchActive); + return true; + }, + end: () => { + this._input.classList.remove(style.touchActive); + }, + }); + for (const event of RETARGETED_EVENTS) { this._input.addEventListener(event, this._retargetEvent, true); } diff --git a/src/custom-els/RangeInput/styles.css b/src/custom-els/RangeInput/styles.css index f48483b3..21ca4767 100644 --- a/src/custom-els/RangeInput/styles.css +++ b/src/custom-els/RangeInput/styles.css @@ -84,11 +84,11 @@ range-input::before { overflow: hidden; } -.input:active + .thumb-wrapper .value-display { +.touch-active + .thumb-wrapper .value-display { opacity: 1; transform: scale(1); } -.input:active + .thumb-wrapper .thumb { +.touch-active + .thumb-wrapper .thumb { box-shadow: 0 1px 3px rgba(0,0,0,0.5); } diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 06cb7dac..e114c472 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -41,3 +41,9 @@ export const CheckedIcon = (props: JSX.HTMLAttributes) => ( ); + +export const ExpandIcon = (props: JSX.HTMLAttributes) => ( + + + +); diff --git a/src/lib/util.ts b/src/lib/util.ts index cecf38fc..dc9417a8 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -258,3 +258,43 @@ export function konami(): Promise { window.addEventListener('keydown', listener); }); } + +interface TransitionOptions { + from?: number; + to?: number; + duration?: number; + easing?: string; +} + +export async function transitionHeight(el: HTMLElement, opts: TransitionOptions): Promise { + const { + from = el.getBoundingClientRect().height, + to = el.getBoundingClientRect().height, + duration = 1000, + easing = 'ease-in-out', + } = opts; + + if (from === to || duration === 0) { + el.style.height = to + 'px'; + return; + } + + el.style.height = from + 'px'; + // Force a style calc so the browser picks up the start value. + getComputedStyle(el).transform; + el.style.transition = `height ${duration}ms ${easing}`; + el.style.height = to + 'px'; + + return new Promise((resolve) => { + const listener = (event: Event) => { + if (event.target !== el) return; + el.style.transition = ''; + el.removeEventListener('transitionend', listener); + el.removeEventListener('transitioncancel', listener); + resolve(); + }; + + el.addEventListener('transitionend', listener); + el.addEventListener('transitioncancel', listener); + }); +}