diff --git a/package-lock.json b/package-lock.json index f4ed52c56..a5cf3d9e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@surma/rollup-plugin-off-main-thread": "^2.2.2", "@types/dedent": "^0.7.0", "@types/mime-types": "^2.1.1", - "@types/node": "^16.11.1", + "@types/node": "^16.18.38", "@web/rollup-plugin-import-meta-assets": "^1.0.6", "comlink": "^4.3.0", "cssnano": "^4.1.10", @@ -42,7 +42,7 @@ "rollup": "^2.38.0", "rollup-plugin-terser": "^7.0.2", "serve": "^11.3.2", - "typescript": "^4.4.4", + "typescript": "^4.8.3", "wasm-feature-detect": "^1.2.11", "which": "^2.0.2" } @@ -326,9 +326,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.11.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.1.tgz", - "integrity": "sha512-PYGcJHL9mwl1Ek3PLiYgyEKtwTMmkMw4vbiyz/ps3pfdRYLVv+SN7qHVAImrjdAXxgluDEw6Ph4lyv+m9UpRmA==", + "version": "16.18.38", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.38.tgz", + "integrity": "sha512-6sfo1qTulpVbkxECP+AVrHV9OoJqhzCsfTNp5NIG+enM4HyM3HvZCO798WShIXBN0+QtDIcutJCjsVYnQP5rIQ==", "dev": true }, "node_modules/@types/parse-json": { @@ -8470,9 +8470,9 @@ } }, "node_modules/typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", - "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -8915,9 +8915,9 @@ "dev": true }, "@types/node": { - "version": "16.11.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.1.tgz", - "integrity": "sha512-PYGcJHL9mwl1Ek3PLiYgyEKtwTMmkMw4vbiyz/ps3pfdRYLVv+SN7qHVAImrjdAXxgluDEw6Ph4lyv+m9UpRmA==", + "version": "16.18.38", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.38.tgz", + "integrity": "sha512-6sfo1qTulpVbkxECP+AVrHV9OoJqhzCsfTNp5NIG+enM4HyM3HvZCO798WShIXBN0+QtDIcutJCjsVYnQP5rIQ==", "dev": true }, "@types/parse-json": { @@ -15677,9 +15677,9 @@ "dev": true }, "typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", - "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true }, "uniq": { diff --git a/package.json b/package.json index c8eb30787..39ae6987b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@surma/rollup-plugin-off-main-thread": "^2.2.2", "@types/dedent": "^0.7.0", "@types/mime-types": "^2.1.1", - "@types/node": "^16.11.1", + "@types/node": "^16.18.38", "@web/rollup-plugin-import-meta-assets": "^1.0.6", "comlink": "^4.3.0", "cssnano": "^4.1.10", @@ -45,9 +45,9 @@ "rollup": "^2.38.0", "rollup-plugin-terser": "^7.0.2", "serve": "^11.3.2", - "typescript": "^4.4.4", - "which": "^2.0.2", - "wasm-feature-detect": "^1.2.11" + "typescript": "^4.8.3", + "wasm-feature-detect": "^1.2.11", + "which": "^2.0.2" }, "lint-staged": { "*.{js,css,json,md,ts,tsx}": "prettier --write", diff --git a/src/client/lazy-app/Modal/ModalHint/index.tsx b/src/client/lazy-app/Modal/ModalHint/index.tsx new file mode 100644 index 000000000..718f6821b --- /dev/null +++ b/src/client/lazy-app/Modal/ModalHint/index.tsx @@ -0,0 +1,57 @@ +import { h, Component, VNode } from 'preact'; +import Modal from '..'; +import { InfoIcon } from 'client/lazy-app/icons'; +import { linkRef } from 'shared/prerendered-app/util'; + +import * as style from './style.css'; +import 'add-css:./style.css'; + +interface Props { + modalTitle: string; + text?: string; +} + +interface State {} + +export default class ModalHint extends Component { + private modalComponent?: Modal; + + private onclick = (event: Event) => { + if (!this.modalComponent) + throw new Error('ModalHint is missing a modalComponent'); + + // Stop bubbled events from triggering the modal + if (!(event.currentTarget as Element).matches('button')) return; + + this.modalComponent.showModal(); + }; + + render({ modalTitle, text }: Props) { + return ( + { + // When the button is clicked, the event starts bubbling up + // which might cause unexpected behaviour + event.preventDefault(); + event.stopImmediatePropagation(); + }} + > + + } + title={modalTitle} + content={this.props.children as any} + > + + ); + } +} diff --git a/src/client/lazy-app/Modal/ModalHint/style.css b/src/client/lazy-app/Modal/ModalHint/style.css new file mode 100644 index 000000000..be5a09b96 --- /dev/null +++ b/src/client/lazy-app/Modal/ModalHint/style.css @@ -0,0 +1,31 @@ +.modal-hint { + display: inline-block; + vertical-align: bottom; +} + +.modal-button { + composes: unbutton from global; + + color: inherit; + + display: flex; + align-items: center; + gap: 0.5rem; + + border-radius: 2px; +} + +.modal-button:hover { + text-decoration: underline solid 1px currentColor; + text-underline-offset: 0.1em; +} + +.modal-button:focus { + outline: white solid 1px; + outline-offset: 0.25em; +} + +.modal-button > svg { + width: 1em; + height: 1em; +} diff --git a/src/client/lazy-app/Modal/index.tsx b/src/client/lazy-app/Modal/index.tsx new file mode 100644 index 000000000..5429c31a6 --- /dev/null +++ b/src/client/lazy-app/Modal/index.tsx @@ -0,0 +1,120 @@ +import { h, Component, VNode, Fragment } from 'preact'; +import * as style from './style.css'; +import 'add-css:./style.css'; +import { linkRef } from 'shared/prerendered-app/util'; +import { cleanSet } from '../util/clean-modify'; +import { animateFrom, animateTo } from '../util'; +import { RoundedCrossIcon } from '../icons'; + +interface Props { + icon: VNode; + title: string; + content: VNode; +} + +interface State {} + +export default class Modal extends Component { + private dialogElement?: HTMLDialogElement; + + componentDidMount() { + if (!this.dialogElement) throw new Error('Modal missing'); + } + + private _closeOnTransitionEnd = () => { + // If modal does not exist + if (!this.dialogElement) return; + + this.dialogElement.close(); + }; + + showModal() { + if (!this.dialogElement) throw Error('Modal missing'); + if (this.dialogElement.open) return; + + this.dialogElement.showModal(); + // animate modal opening + animateTo( + this.dialogElement, + [ + { opacity: 0, transform: 'translateY(50px)' }, + { opacity: 1, transform: 'translateY(0px)' }, + ], + { duration: 250, easing: 'ease' }, + ); + // animate modal::backdrop + // some browsers don't support ::backdrop, catch those errors + try { + animateFrom( + this.dialogElement, + { opacity: 0 }, + { + duration: 250, + easing: 'ease', + pseudoElement: '::backdrop', + }, + ); + } catch (e) {} + } + + private _hideModal() { + if (!this.dialogElement) throw Error('Modal missing / hidden'); + if (!this.dialogElement.open) return; + + // animate modal closing + const anim = animateTo( + this.dialogElement, + { opacity: 0, transform: 'translateY(50px)' }, + { duration: 250, easing: 'ease' }, + ); + // animate modal::backdrop + // some browsers don't support ::backdrop, catch those errors + try { + animateTo(this.dialogElement, [{ opacity: 0 }], { + duration: 250, + easing: 'ease', + pseudoElement: '::backdrop', + }); + } catch (e) {} + anim.onfinish = this._closeOnTransitionEnd; + } + + private _onKeyDown = (event: KeyboardEvent) => { + // Default behaviour of closes it instantly when you press Esc + // So we hijack it to smoothly hide the modal + if (event.key === 'Escape') { + this._hideModal(); + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + + render({ title, icon, content }: Props, {}: State) { + return ( + { + // Prevent clicks from leaking out of the dialog + event.preventDefault(); + event.stopImmediatePropagation(); + }} + > +
+
+ {icon} + {title} + +
+
+
{content}
+
+
+
+
+ ); + } +} diff --git a/src/client/lazy-app/Modal/style.css b/src/client/lazy-app/Modal/style.css new file mode 100644 index 000000000..f8adf7dd4 --- /dev/null +++ b/src/client/lazy-app/Modal/style.css @@ -0,0 +1,171 @@ +.modal-dialog { + padding: 0; + width: 70vw; + max-width: 60ch; + max-height: 70vh; + cursor: auto; + + box-shadow: 0 0 20px 0 rgba(0, 0, 0, 20%); + + font-size: 1.5rem; + color: var(--light-gray); + + border: none; + border-radius: 10px; + overflow: hidden; + + opacity: 0; + background-color: var(--off-black); + + /* Note: dialog and ::backdrop are animated using the Animations API in the component file */ + + &::backdrop { + background-color: rgba(0, 0, 0, 30%); + } +} + +@media (max-width: 720px) { + .modal-dialog { + width: 90%; + max-width: none; + font-size: 1.25rem; + } +} + +@media (max-width: 480px) { + .modal-dialog { + font-size: 1rem; + } +} + +.article { + display: grid; + grid-template-rows: auto 1fr auto; +} + +.header, +.footer { + max-height: 100%; + padding: 1em; + + background-color: var(--dark-gray); +} + +.header { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.5em; + border-bottom: 1px solid rgba(0, 0, 0, 8%); + align-content: stretch; + + /* Apply font size only to children so that padding is unaffected */ + & > * { + font-size: 1.5em; + } +} + +.footer { + border-top: 1px solid rgba(0, 0, 0, 8%); +} + +.modal-title { + text-align: center; + + /* Overflowing text gets ellipse-d */ + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + white-space: nowrap; +} + +.modal-icon { + display: flex; + justify-content: center; + align-items: center; + + opacity: 0.5; + + svg { + font-size: 1em; + height: 1em; + width: 1em; + } +} + +.close-button { + composes: unbutton from global; + flex: 0 0 auto; + height: 1em; + width: 1em; + background-color: var(--pink); + border-radius: 5px; + display: grid; + outline-color: var(--medium-light-gray); + + color: black; /* Influences currentColor of the icon */ + + &:focus { + outline: var(--medium-light-gray); /* Overwrite default black outline */ + } + + /* Don't show focus ring on mobile */ + &:focus-visible { + outline-width: 2px; + outline-style: solid; + outline-offset: 2px; + } +} + +.content-container { + max-height: 60vh; + overflow-y: auto; +} + +.content { + font-size: 1em; + line-height: 1.5; + + padding: 0 1em; + + h1 { + color: var(--white); + } + + img { + max-width: 100%; + max-height: 500px; + } + + figcaption { + font-size: 0.8em; + text-align: center; + font-style: italic; + } + + a { + &:link { + color: var(--blue); + } + &:visited { + color: var(--pink); + } + } + + pre, + code { + background-color: var(--black); + padding: 2px 4px; + border-radius: 3px; + } + + pre { + overflow-x: auto; + padding: 1rem 2rem; + } + + hr { + border: none; + border-bottom: 1px solid var(--dim-text); + } +} diff --git a/src/client/lazy-app/icons/index.tsx b/src/client/lazy-app/icons/index.tsx index c3a640d9e..989f46842 100644 --- a/src/client/lazy-app/icons/index.tsx +++ b/src/client/lazy-app/icons/index.tsx @@ -1,4 +1,4 @@ -import { h } from 'preact'; +import preact, { h } from 'preact'; const Icon = (props: preact.JSX.HTMLAttributes) => ( // @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 @@ -11,6 +11,30 @@ const Icon = (props: preact.JSX.HTMLAttributes) => ( /> ); +/* Cross with rounded linecaps */ +export const RoundedCrossIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const InfoIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const DiamondStarIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + export const ToggleAliasingIcon = (props: preact.JSX.HTMLAttributes) => ( { + // pseudo-elements don't support inline styles at the moment, catch those errors + try { + anim.commitStyles(); + } catch (e) {} + anim.cancel(); + }); + return anim; +} + +export function animateFrom( + element: HTMLElement, + from: PropertyIndexedKeyframes, + options: KeyframeAnimationOptions, +) { + return element.animate( + { ...from, offset: 0 }, + { ...options, fill: 'backwards' }, + ); +} + /** If render engine is Safari */ export const isSafari = /Safari\//.test(navigator.userAgent) && diff --git a/src/features/encoders/mozJPEG/client/index.tsx b/src/features/encoders/mozJPEG/client/index.tsx index 780e5cb3c..db37f38a0 100644 --- a/src/features/encoders/mozJPEG/client/index.tsx +++ b/src/features/encoders/mozJPEG/client/index.tsx @@ -1,6 +1,6 @@ import { EncodeOptions, MozJpegColorSpace } from '../shared/meta'; import type WorkerBridge from 'client/lazy-app/worker-bridge'; -import { h, Component } from 'preact'; +import { h, Component, Fragment } from 'preact'; import { inputFieldChecked, inputFieldValueAsNumber, @@ -13,6 +13,8 @@ import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; import Expander from 'client/lazy-app/Compress/Options/Expander'; import Select from 'client/lazy-app/Compress/Options/Select'; import Revealer from 'client/lazy-app/Compress/Options/Revealer'; +import ModalHint from 'client/lazy-app/Modal/ModalHint'; +import * as linkImage from 'img-url:static-build/assets/link.jpg'; export function encode( signal: AbortSignal, @@ -23,6 +25,53 @@ export function encode( return workerBridge.mozjpegEncode(signal, imageData, options); } +function sampleContent() { + return ( + +

Test Suite (h1)

+

+ Let's say, hypothetically, this paragraph links to{' '} + +

WASSUP

+ + . Lorem ipsum dolor sit amet consectetur adipisicing elit. Quaerat ad + maiores iure suscipit numquam voluptate. +

+

+ This is another paragraph. Notice how another uses + italics. Oh wait, I just used the <b> tag. Oh, and escaped HTML + unicode thingies. Now I'm just rambling so that this is long enough to + be multi-line, and you can see the line-height. +

+
+
+        # Which is the best programming language?
+        
+ print("Python is!") +
+ Oh and this is all in <pre> +
+

+ This text is strong. And this is in + <code>. +

+
+ Alt Text +
+ Source: Nintendo EDP / Nintendo (and this is a figcaption) +
+
+

+ That's a link.{' '} + + This is also a link + + . +

+
+ ); +} + interface Props { options: EncodeOptions; onChange(newOptions: EncodeOptions): void; @@ -114,7 +163,9 @@ export class Options extends Component { value={options.quality} onInput={this.onChange} > - Quality: + + {sampleContent()} +