mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-17 19:19:47 +00:00
Merge branch 'master' into firebase
This commit is contained in:
@@ -1,41 +1,16 @@
|
||||
import { h, Component } from 'preact';
|
||||
import { When, bind } from '../../lib/util';
|
||||
import Fab from '../fab';
|
||||
import Header from '../header';
|
||||
// import Drawer from 'async!../drawer';
|
||||
const Drawer = require('async!../drawer').default;
|
||||
import Home from '../home';
|
||||
import { bind } from '../../lib/util';
|
||||
import * as style from './style.scss';
|
||||
import Output from '../output';
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
};
|
||||
|
||||
export type FileObj = {
|
||||
id: number,
|
||||
data?: string,
|
||||
uri?: string,
|
||||
error?: Error | DOMError | String,
|
||||
file: File,
|
||||
loading: boolean
|
||||
};
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
showDrawer: boolean,
|
||||
showFab: boolean,
|
||||
files: FileObj[]
|
||||
img?: ImageBitmap
|
||||
};
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export default class App extends Component<Props, State> {
|
||||
state: State = {
|
||||
showDrawer: false,
|
||||
showFab: true,
|
||||
files: []
|
||||
};
|
||||
|
||||
enableDrawer = false;
|
||||
state: State = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -49,87 +24,25 @@ export default class App extends Component<Props, State> {
|
||||
}
|
||||
|
||||
@bind
|
||||
openDrawer() {
|
||||
this.setState({ showDrawer: true });
|
||||
}
|
||||
@bind
|
||||
closeDrawer() {
|
||||
this.setState({ showDrawer: false });
|
||||
}
|
||||
@bind
|
||||
toggleDrawer() {
|
||||
this.setState({ showDrawer: !this.state.showDrawer });
|
||||
async onFileChange(event: Event) {
|
||||
const fileInput = event.target as HTMLInputElement;
|
||||
if (!fileInput.files || !fileInput.files[0]) return;
|
||||
// TODO: handle decode error
|
||||
const img = await createImageBitmap(fileInput.files[0]);
|
||||
this.setState({ img });
|
||||
}
|
||||
|
||||
@bind
|
||||
openFab() {
|
||||
this.setState({ showFab: true });
|
||||
}
|
||||
@bind
|
||||
closeFab() {
|
||||
this.setState({ showFab: false });
|
||||
}
|
||||
@bind
|
||||
toggleFab() {
|
||||
this.setState({ showFab: !this.state.showFab });
|
||||
}
|
||||
|
||||
@bind
|
||||
loadFile(file: File) {
|
||||
let fileObj: FileObj = {
|
||||
id: ++idCounter,
|
||||
file,
|
||||
error: undefined,
|
||||
loading: true,
|
||||
data: undefined
|
||||
};
|
||||
|
||||
this.setState({
|
||||
files: [fileObj]
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
new Response(file).text(),
|
||||
new Response(file).blob()
|
||||
])
|
||||
.then(([data, blob]) => ({
|
||||
data,
|
||||
uri: URL.createObjectURL(blob)
|
||||
}))
|
||||
.catch(error => ({ error }))
|
||||
.then(state => {
|
||||
let files = this.state.files.slice();
|
||||
files[files.indexOf(fileObj)] = Object.assign({}, fileObj, {
|
||||
loading: false,
|
||||
...state
|
||||
});
|
||||
this.setState({ files });
|
||||
});
|
||||
}
|
||||
|
||||
render({ url }: Props, { showDrawer, showFab, files }: State) {
|
||||
if (showDrawer) this.enableDrawer = true;
|
||||
|
||||
if (showFab) showFab = files.length > 0;
|
||||
|
||||
render({ }: Props, { img }: State) {
|
||||
return (
|
||||
<div id="app" class={style.app}>
|
||||
<Fab showing={showFab} />
|
||||
|
||||
<Header class={style.header} onToggleDrawer={this.toggleDrawer} loadFile={this.loadFile} />
|
||||
|
||||
{/* Avoid loading & rendering the drawer until the first time it is shown. */}
|
||||
<When value={showDrawer}>
|
||||
<Drawer showing={showDrawer} openDrawer={this.openDrawer} closeDrawer={this.closeDrawer} />
|
||||
</When>
|
||||
|
||||
{/*
|
||||
Note: this is normally where a <Router> with auto code-splitting goes.
|
||||
Since we don't seem to need one (yet?), it's omitted.
|
||||
*/}
|
||||
<div class={style.content}>
|
||||
<Home files={files} />
|
||||
</div>
|
||||
{img ?
|
||||
<Output img={img} />
|
||||
:
|
||||
<div>
|
||||
<h1>Select an image</h1>
|
||||
<input type="file" onChange={this.onFileChange} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
@import '~style/helpers.scss';
|
||||
|
||||
.app {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
contain: size layout style;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.app h1 {
|
||||
color: green;
|
||||
}
|
||||
|
||||
3
src/components/app/style.scss.d.ts
vendored
3
src/components/app/style.scss.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
export const app: string;
|
||||
export const header: string;
|
||||
export const content: string;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { h, Component } from 'preact';
|
||||
import MdlDrawer from 'preact-material-components-drawer';
|
||||
import 'preact-material-components/Drawer/style.css';
|
||||
import List from 'preact-material-components/List';
|
||||
// import 'preact-material-components/List/style.css';
|
||||
import { Text } from 'preact-i18n';
|
||||
import * as style from './style.scss';
|
||||
import { bind } from '../../lib/util';
|
||||
|
||||
type Props = {
|
||||
showing: boolean,
|
||||
openDrawer(): void,
|
||||
closeDrawer(): void
|
||||
};
|
||||
|
||||
type State = {
|
||||
rendered: boolean
|
||||
};
|
||||
|
||||
export default class Drawer extends Component<Props, State> {
|
||||
state: State = {
|
||||
rendered: false
|
||||
};
|
||||
|
||||
@bind
|
||||
setRendered() {
|
||||
this.setState({ rendered: true });
|
||||
}
|
||||
|
||||
render({ showing, openDrawer, closeDrawer }: Props, { rendered }: State) {
|
||||
if (showing && !rendered) {
|
||||
setTimeout(this.setRendered, 20);
|
||||
showing = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<MdlDrawer open={showing} onOpen={openDrawer} onClose={closeDrawer}>
|
||||
<MdlDrawer.Header class="mdc-theme--primary-bg">
|
||||
<img class={style.logo} alt="logo" src="/assets/icon.png" />
|
||||
</MdlDrawer.Header>
|
||||
<MdlDrawer.Content>
|
||||
<List>
|
||||
<List.LinkItem href="/">
|
||||
<List.ItemIcon>verified_user</List.ItemIcon>
|
||||
<Text id="SIGN_IN">Sign In</Text>
|
||||
</List.LinkItem>
|
||||
<List.LinkItem href="/register">
|
||||
<List.ItemIcon>account_circle</List.ItemIcon>
|
||||
<Text id="REGISTER">Register</Text>
|
||||
</List.LinkItem>
|
||||
</List>
|
||||
</MdlDrawer.Content>
|
||||
|
||||
<div class={style.bottom}>
|
||||
<List.LinkItem href="/preferences">
|
||||
<List.ItemIcon>settings</List.ItemIcon>
|
||||
<Text id="PREFERENCES">Preferences</Text>
|
||||
</List.LinkItem>
|
||||
</div>
|
||||
</MdlDrawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
@import '~style/helpers.scss';
|
||||
|
||||
:global {
|
||||
// @import '~preact-material-components/Drawer/style.css';
|
||||
@import '~preact-material-components/List/mdc-list.scss';
|
||||
}
|
||||
|
||||
.drawer {
|
||||
:global(.mdc-list-item__start-detail) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.category img {
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
bottom: constant(safe-area-inset-bottom);
|
||||
bottom: env(safe-area-inset-bottom);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
14
src/components/drawer/style.scss.d.ts
vendored
14
src/components/drawer/style.scss.d.ts
vendored
@@ -1,14 +0,0 @@
|
||||
export const mdcListItemSecondaryText: string;
|
||||
export const mdcListItemGraphic: string;
|
||||
export const mdcListItemMeta: string;
|
||||
export const mdcListItem: string;
|
||||
export const mdcListDivider: string;
|
||||
export const mdcListGroup: string;
|
||||
export const mdcListGroupSubheader: string;
|
||||
export const drawer: string;
|
||||
export const logo: string;
|
||||
export const category: string;
|
||||
export const bottom: string;
|
||||
export const mdcRippleFgRadiusIn: string;
|
||||
export const mdcRippleFgOpacityIn: string;
|
||||
export const mdcRippleFgOpacityOut: string;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { h, Component } from 'preact';
|
||||
import { bind } from '../../lib/util';
|
||||
import Icon from 'preact-material-components/Icon';
|
||||
import 'preact-material-components/Icon/style.css';
|
||||
import Fab from 'preact-material-components/Fab';
|
||||
import RadialProgress from 'material-radial-progress';
|
||||
import * as style from './style.scss';
|
||||
|
||||
type Props = {
|
||||
showing: boolean
|
||||
};
|
||||
|
||||
type State = {
|
||||
loading: boolean
|
||||
};
|
||||
|
||||
export default class AppFab extends Component<Props, State> {
|
||||
state: State = {
|
||||
loading: false
|
||||
};
|
||||
|
||||
@bind
|
||||
setLoading(loading: boolean) {
|
||||
this.setState({ loading });
|
||||
}
|
||||
|
||||
@bind
|
||||
handleClick() {
|
||||
console.log('TODO: Save the file to disk.');
|
||||
this.setState({ loading: true });
|
||||
setTimeout(() => {
|
||||
this.setState({ loading: false });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
render({ showing }: Props, { loading }: State) {
|
||||
return (
|
||||
<Fab ripple secondary exited={showing === false} class={style.fab} onClick={this.handleClick}>
|
||||
{ loading ? (
|
||||
<RadialProgress primary class={style.progress} />
|
||||
) : (
|
||||
<Icon>file_download</Icon>
|
||||
) }
|
||||
</Fab>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
@import '~style/helpers.scss';
|
||||
:global {
|
||||
@import '~preact-material-components/Fab/mdc-fab.scss';
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 4;
|
||||
|
||||
.progress {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: white;
|
||||
--mdc-theme-primary: #fff;
|
||||
}
|
||||
}
|
||||
5
src/components/fab/style.scss.d.ts
vendored
5
src/components/fab/style.scss.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
export const fab: string;
|
||||
export const progress: string;
|
||||
export const mdcRippleFgRadiusIn: string;
|
||||
export const mdcRippleFgOpacityIn: string;
|
||||
export const mdcRippleFgOpacityOut: string;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { h, Component } from 'preact';
|
||||
import Toolbar from 'preact-material-components/Toolbar';
|
||||
import cx from 'classnames';
|
||||
import * as style from './style.scss';
|
||||
import { bind } from '../../lib/util';
|
||||
|
||||
type Props = {
|
||||
'class'?: string,
|
||||
showHeader?: boolean,
|
||||
onToggleDrawer?(): void,
|
||||
showFab?(): void,
|
||||
loadFile(f: File): void
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
export default class Header extends Component<Props, State> {
|
||||
input?: HTMLInputElement;
|
||||
|
||||
@bind
|
||||
setInputRef(c?: Element) {
|
||||
this.input = c as HTMLInputElement;
|
||||
}
|
||||
|
||||
@bind
|
||||
upload() {
|
||||
this.input!.click();
|
||||
}
|
||||
|
||||
@bind
|
||||
handleFiles() {
|
||||
let files = this.input!.files;
|
||||
if (files && files.length) {
|
||||
this.props.loadFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
render({ class: c, onToggleDrawer, showHeader = true, showFab }: Props) {
|
||||
return (
|
||||
<Toolbar fixed class={cx(c, style.toolbar, 'inert', !showHeader && style.minimal)}>
|
||||
<Toolbar.Row>
|
||||
<Toolbar.Title class={style.title}>
|
||||
<Toolbar.Icon title="Upload" ripple onClick={this.upload} id="uploadIcon">file_upload</Toolbar.Icon>
|
||||
</Toolbar.Title>
|
||||
<Toolbar.Section align-end>
|
||||
<Toolbar.Icon ripple onClick={onToggleDrawer}>menu</Toolbar.Icon>
|
||||
</Toolbar.Section>
|
||||
</Toolbar.Row>
|
||||
<input class={style.fileInput} ref={this.setInputRef} type="file" onChange={this.handleFiles} aria-labelledby="uploadIcon" />
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
@import '~style/helpers.scss';
|
||||
:global {
|
||||
@import '~preact-material-components/Toolbar/mdc-toolbar.scss';
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
// height: $toolbar-height;
|
||||
|
||||
&.minimal {
|
||||
display: none;
|
||||
// height: $toolbar-height / 2;
|
||||
}
|
||||
|
||||
// > * {
|
||||
// min-height: 0;
|
||||
// }
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -999px;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
display: block;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
// z-index: 999;
|
||||
// transform: translateZ(0);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: $toolbar-height;
|
||||
right: 5px;
|
||||
|
||||
.menuItem {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 3px 0 0;
|
||||
font-weight: 300;
|
||||
font-size: 140%;
|
||||
}
|
||||
8
src/components/header/style.scss.d.ts
vendored
8
src/components/header/style.scss.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
export const toolbar: string;
|
||||
export const minimal: string;
|
||||
export const fileInput: string;
|
||||
export const fab: string;
|
||||
export const logo: string;
|
||||
export const menu: string;
|
||||
export const menuItem: string;
|
||||
export const title: string;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { h, Component } from 'preact';
|
||||
// import Button from 'preact-material-components/Button';
|
||||
// import Switch from 'preact-material-components/Switch';
|
||||
// import 'preact-material-components/Switch/style.css';
|
||||
import * as style from './style.scss';
|
||||
import { FileObj } from '../app';
|
||||
|
||||
type Props = {
|
||||
files: FileObj[]
|
||||
};
|
||||
|
||||
type State = {
|
||||
active: boolean
|
||||
};
|
||||
|
||||
export default class Home extends Component<Props, State> {
|
||||
state: State = {
|
||||
active: false
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.setState({ active: true });
|
||||
});
|
||||
}
|
||||
|
||||
render({ files }: Props, { active }: State) {
|
||||
return (
|
||||
<div class={style.home}>
|
||||
{ files && files[0] && (
|
||||
<img src={files[0].uri} class={style.image} />
|
||||
) || (
|
||||
<div class={style.content}>
|
||||
<h1>Squoosh</h1>
|
||||
<p>Test home content</p>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
@import '~style/helpers.scss';
|
||||
|
||||
// :global {
|
||||
// @import '~preact-material-components/Button/mdc-button.scss';
|
||||
// // @import '~preact-material-components/Switch/mdc-switch.scss';
|
||||
// }
|
||||
|
||||
.home {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 600px;
|
||||
margin: 50px auto 0;
|
||||
font-size: 120%;
|
||||
text-align: center;
|
||||
}
|
||||
3
src/components/home/style.scss.d.ts
vendored
3
src/components/home/style.scss.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
export const home: string;
|
||||
export const image: string;
|
||||
export const content: string;
|
||||
298
src/components/output/custom-els/PinchZoom/index.ts
Normal file
298
src/components/output/custom-els/PinchZoom/index.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import './styles.css';
|
||||
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
|
||||
|
||||
interface Point {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
interface ApplyChangeOpts {
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
scaleDiff?: number;
|
||||
originX?: number;
|
||||
originY?: number;
|
||||
}
|
||||
|
||||
interface SetTransformOpts {
|
||||
scale?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
/**
|
||||
* Fire a 'change' event if values are different to current values
|
||||
*/
|
||||
allowChangeEvent?: boolean;
|
||||
}
|
||||
|
||||
function getDistance (a: Point, b?: Point): number {
|
||||
if (!b) return 0;
|
||||
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
|
||||
}
|
||||
|
||||
function getMidpoint (a: Point, b?: Point): Point {
|
||||
if (!b) return a;
|
||||
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2
|
||||
};
|
||||
}
|
||||
|
||||
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
|
||||
// Given that, better to use something everything supports.
|
||||
let cachedSvg: SVGSVGElement;
|
||||
|
||||
function getSVG (): SVGSVGElement {
|
||||
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
|
||||
}
|
||||
|
||||
function createMatrix (): SVGMatrix {
|
||||
return getSVG().createSVGMatrix();
|
||||
}
|
||||
|
||||
function createPoint (): SVGPoint {
|
||||
return getSVG().createSVGPoint();
|
||||
}
|
||||
|
||||
export default class PinchZoom extends HTMLElement {
|
||||
// The element that we'll transform.
|
||||
// Ideally this would be shadow DOM, but we don't have the browser
|
||||
// support yet.
|
||||
private _positioningEl?: Element;
|
||||
// Current transform.
|
||||
private _transform: SVGMatrix = createMatrix();
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
// Watch for children changes.
|
||||
// Note this won't fire for initial contents,
|
||||
// so _stageElChange is also called in connectedCallback.
|
||||
new MutationObserver(() => this._stageElChange())
|
||||
.observe(this, { childList: true });
|
||||
|
||||
// Watch for pointers
|
||||
const pointerTracker: PointerTracker = new PointerTracker(this, {
|
||||
start: (pointer, event) => {
|
||||
// We only want to track 2 pointers at most
|
||||
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
move: previousPointers => {
|
||||
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
|
||||
}
|
||||
});
|
||||
|
||||
this.addEventListener('wheel', event => this._onWheel(event));
|
||||
}
|
||||
|
||||
connectedCallback () {
|
||||
this._stageElChange();
|
||||
}
|
||||
|
||||
get x () {
|
||||
return this._transform.e;
|
||||
}
|
||||
|
||||
get y () {
|
||||
return this._transform.f;
|
||||
}
|
||||
|
||||
get scale () {
|
||||
return this._transform.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stage with a given scale/x/y.
|
||||
*/
|
||||
setTransform (opts: SetTransformOpts = {}) {
|
||||
const {
|
||||
scale = this.scale,
|
||||
allowChangeEvent = false
|
||||
} = opts;
|
||||
|
||||
let {
|
||||
x = this.x,
|
||||
y = this.y
|
||||
} = opts;
|
||||
|
||||
// If we don't have an element to position, just set the value as given.
|
||||
// We'll check bounds later.
|
||||
if (!this._positioningEl) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current layout
|
||||
const thisBounds = this.getBoundingClientRect();
|
||||
const positioningElBounds = this._positioningEl.getBoundingClientRect();
|
||||
|
||||
// Not displayed. May be disconnected or display:none.
|
||||
// Just take the values, and we'll check bounds later.
|
||||
if (!thisBounds.width || !thisBounds.height) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create points for _positioningEl.
|
||||
let topLeft = createPoint();
|
||||
topLeft.x = positioningElBounds.left - thisBounds.left;
|
||||
topLeft.y = positioningElBounds.top - thisBounds.top;
|
||||
let bottomRight = createPoint();
|
||||
bottomRight.x = positioningElBounds.width + topLeft.x;
|
||||
bottomRight.y = positioningElBounds.height + topLeft.y;
|
||||
|
||||
// Calculate the intended position of _positioningEl.
|
||||
let matrix = createMatrix()
|
||||
.translate(x, y)
|
||||
.scale(scale)
|
||||
// Undo current transform
|
||||
.multiply(this._transform.inverse());
|
||||
|
||||
topLeft = topLeft.matrixTransform(matrix);
|
||||
bottomRight = bottomRight.matrixTransform(matrix);
|
||||
|
||||
// Ensure _positioningEl can't move beyond out-of-bounds.
|
||||
// Correct for x
|
||||
if (topLeft.x > thisBounds.width) {
|
||||
x += thisBounds.width - topLeft.x;
|
||||
} else if (bottomRight.x < 0) {
|
||||
x += -bottomRight.x;
|
||||
}
|
||||
|
||||
// Correct for y
|
||||
if (topLeft.y > thisBounds.height) {
|
||||
y += thisBounds.height - topLeft.y;
|
||||
} else if (bottomRight.y < 0) {
|
||||
y += -bottomRight.y;
|
||||
}
|
||||
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transform values without checking bounds. This is only called in setTransform.
|
||||
*/
|
||||
_updateTransform (scale: number, x: number, y: number, allowChangeEvent: boolean) {
|
||||
// Return if there's no change
|
||||
if (
|
||||
scale === this.scale &&
|
||||
x === this.x &&
|
||||
y === this.y
|
||||
) return;
|
||||
|
||||
this._transform.e = x;
|
||||
this._transform.f = y;
|
||||
this._transform.d = this._transform.a = scale;
|
||||
|
||||
this.style.setProperty('--x', this.x + 'px');
|
||||
this.style.setProperty('--y', this.y + 'px');
|
||||
this.style.setProperty('--scale', this.scale + '');
|
||||
|
||||
if (allowChangeEvent) {
|
||||
const event = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the direct children of this element change.
|
||||
* Until we have have shadow dom support across the board, we
|
||||
* require a single element to be the child of <pinch-zoom>, and
|
||||
* that's the element we pan/scale.
|
||||
*/
|
||||
private _stageElChange () {
|
||||
this._positioningEl = undefined;
|
||||
|
||||
if (this.children.length === 0) {
|
||||
console.warn('There should be at least one child in <pinch-zoom>.');
|
||||
return;
|
||||
}
|
||||
|
||||
this._positioningEl = this.children[0];
|
||||
|
||||
if (this.children.length > 1) {
|
||||
console.warn('<pinch-zoom> must not have more than one child.');
|
||||
}
|
||||
|
||||
// Do a bounds check
|
||||
this.setTransform();
|
||||
}
|
||||
|
||||
private _onWheel (event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const thisRect = this.getBoundingClientRect();
|
||||
let { deltaY } = event;
|
||||
const { ctrlKey, deltaMode } = event;
|
||||
|
||||
if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
|
||||
// Firefox uses "lines" for some types of mouse
|
||||
deltaY *= 15;
|
||||
}
|
||||
|
||||
// ctrlKey is true when pinch-zooming on a trackpad.
|
||||
const divisor = ctrlKey ? 100 : 300;
|
||||
const scaleDiff = 1 - deltaY / divisor;
|
||||
|
||||
this._applyChange({
|
||||
scaleDiff,
|
||||
originX: event.clientX - thisRect.left,
|
||||
originY: event.clientY - thisRect.top
|
||||
});
|
||||
}
|
||||
|
||||
private _onPointerMove (previousPointers: Pointer[], currentPointers: Pointer[]) {
|
||||
// Combine next points with previous points
|
||||
const thisRect = this.getBoundingClientRect();
|
||||
|
||||
// For calculating panning movement
|
||||
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
|
||||
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
|
||||
|
||||
// Midpoint within the element
|
||||
const originX = prevMidpoint.clientX - thisRect.left;
|
||||
const originY = prevMidpoint.clientY - thisRect.top;
|
||||
|
||||
// Calculate the desired change in scale
|
||||
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
|
||||
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
|
||||
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
|
||||
|
||||
this._applyChange({
|
||||
originX, originY, scaleDiff,
|
||||
panX: newMidpoint.clientX - prevMidpoint.clientX,
|
||||
panY: newMidpoint.clientY - prevMidpoint.clientY
|
||||
});
|
||||
}
|
||||
|
||||
/** Transform the view & fire a change event */
|
||||
private _applyChange (opts: ApplyChangeOpts = {}) {
|
||||
const {
|
||||
panX = 0, panY = 0,
|
||||
originX = 0, originY = 0,
|
||||
scaleDiff = 1
|
||||
} = opts;
|
||||
|
||||
const matrix = createMatrix()
|
||||
// Translate according to panning.
|
||||
.translate(panX, panY)
|
||||
// Scale about the origin.
|
||||
.translate(originX, originY)
|
||||
.scale(scaleDiff)
|
||||
.translate(-originX, -originY)
|
||||
// Apply current transform.
|
||||
.multiply(this._transform);
|
||||
|
||||
// Convert the transform into basic translate & scale.
|
||||
this.setTransform({
|
||||
scale: matrix.a,
|
||||
x: matrix.e,
|
||||
y: matrix.f,
|
||||
allowChangeEvent: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pinch-zoom', PinchZoom);
|
||||
16
src/components/output/custom-els/PinchZoom/missing-types.d.ts
vendored
Normal file
16
src/components/output/custom-els/PinchZoom/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
declare interface CSSStyleDeclaration {
|
||||
willChange: string | null;
|
||||
}
|
||||
|
||||
// TypeScript, you make me sad.
|
||||
// https://github.com/Microsoft/TypeScript/issues/18756
|
||||
interface Window {
|
||||
PointerEvent: typeof PointerEvent;
|
||||
Touch: typeof Touch;
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"pinch-zoom": any
|
||||
}
|
||||
}
|
||||
14
src/components/output/custom-els/PinchZoom/styles.css
Normal file
14
src/components/output/custom-els/PinchZoom/styles.css
Normal file
@@ -0,0 +1,14 @@
|
||||
pinch-zoom {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
--scale: 1;
|
||||
--x: 0;
|
||||
--y: 0;
|
||||
}
|
||||
|
||||
pinch-zoom > * {
|
||||
transform: translate(var(--x), var(--y)) scale(var(--scale));
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
45
src/components/output/index.tsx
Normal file
45
src/components/output/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { h, Component } from 'preact';
|
||||
import './custom-els/PinchZoom';
|
||||
import * as style from './style.scss';
|
||||
|
||||
type Props = {
|
||||
img: ImageBitmap
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
export default class App extends Component<Props, State> {
|
||||
state: State = {};
|
||||
canvas?: HTMLCanvasElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
updateCanvas(img: ImageBitmap) {
|
||||
if (!this.canvas) return;
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateCanvas(this.props.img);
|
||||
}
|
||||
|
||||
componentDidUpdate({ img }: Props) {
|
||||
if (img !== this.props.img) this.updateCanvas(this.props.img);
|
||||
}
|
||||
|
||||
render({ img }: Props, { }: State) {
|
||||
return (
|
||||
<div>
|
||||
<pinch-zoom>
|
||||
<canvas class={style.outputCanvas} ref={c => this.canvas = c as HTMLCanvasElement} width={img.width} height={img.height} />
|
||||
</pinch-zoom>
|
||||
<p>And that's all the app does so far!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/components/output/style.scss
Normal file
3
src/components/output/style.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.outputCanvas {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@@ -20,15 +20,4 @@ if (process.env.NODE_ENV === 'development') {
|
||||
root = render(<App />, document.body, root);
|
||||
});
|
||||
});
|
||||
} else if ('serviceWorker' in navigator) {
|
||||
addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
navigator.serviceWorker.register(__webpack_public_path__ + 'sw.js');
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/** @todo Async SSR if we need it */
|
||||
// export default async () => {
|
||||
// // render here, then resolve to a string of HTML (or null to serialize the document)
|
||||
// }
|
||||
|
||||
237
src/lib/PointerTracker/index.ts
Normal file
237
src/lib/PointerTracker/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { bind } from '../util';
|
||||
const enum Button { Left }
|
||||
|
||||
export class Pointer {
|
||||
/** x offset from the top of the document */
|
||||
pageX: number;
|
||||
/** y offset from the top of the document */
|
||||
pageY: number;
|
||||
/** x offset from the top of the viewport */
|
||||
clientX: number;
|
||||
/** y offset from the top of the viewport */
|
||||
clientY: number;
|
||||
/** ID for this pointer */
|
||||
id: number = -1;
|
||||
|
||||
constructor (nativePointer: Touch | PointerEvent | MouseEvent) {
|
||||
this.pageX = nativePointer.pageX;
|
||||
this.pageY = nativePointer.pageY;
|
||||
this.clientX = nativePointer.clientX;
|
||||
this.clientY = nativePointer.clientY;
|
||||
|
||||
if (self.Touch && nativePointer instanceof Touch) {
|
||||
this.id = nativePointer.identifier;
|
||||
} else if (isPointerEvent(nativePointer)) { // is PointerEvent
|
||||
this.id = nativePointer.pointerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPointerEvent = (event: any): event is PointerEvent =>
|
||||
self.PointerEvent && event instanceof PointerEvent;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type StartCallback = ((pointer: Pointer, event: TouchEvent | PointerEvent | MouseEvent) => boolean);
|
||||
type MoveCallback = ((previousPointers: Pointer[], event: TouchEvent | PointerEvent | MouseEvent) => void);
|
||||
type EndCallback = ((pointer: Pointer, event: TouchEvent | PointerEvent | MouseEvent) => void);
|
||||
|
||||
interface PointerTrackerCallbacks {
|
||||
/**
|
||||
* Called when a pointer is pressed/touched within the element.
|
||||
*
|
||||
* @param pointer The new pointer.
|
||||
* This pointer isn't included in this.currentPointers or this.startPointers yet.
|
||||
* @param event The event related to this pointer.
|
||||
*
|
||||
* @returns Whether you want to track this pointer as it moves.
|
||||
*/
|
||||
start?: StartCallback;
|
||||
/**
|
||||
* Called when pointers have moved.
|
||||
*
|
||||
* @param previousPointers The state of the pointers before this event.
|
||||
* This contains the same number of pointers, in the same order, as
|
||||
* this.currentPointers and this.startPointers.
|
||||
* @param event The event related to the pointer changes.
|
||||
*/
|
||||
move?: MoveCallback;
|
||||
/**
|
||||
* Called when a pointer is released.
|
||||
*
|
||||
* @param pointer The final state of the pointer that ended. This
|
||||
* pointer is now absent from this.currentPointers and
|
||||
* this.startPointers.
|
||||
* @param event The event related to this pointer.
|
||||
*/
|
||||
end?: EndCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track pointers across a particular element
|
||||
*/
|
||||
export class PointerTracker {
|
||||
/**
|
||||
* State of the tracked pointers when they were pressed/touched.
|
||||
*/
|
||||
readonly startPointers: Pointer[] = [];
|
||||
/**
|
||||
* Latest state of the tracked pointers. Contains the same number
|
||||
* of pointers, and in the same order as this.startPointers.
|
||||
*/
|
||||
readonly currentPointers: Pointer[] = [];
|
||||
|
||||
private _startCallback: StartCallback;
|
||||
private _moveCallback: MoveCallback;
|
||||
private _endCallback: EndCallback;
|
||||
|
||||
/**
|
||||
* Track pointers across a particular element
|
||||
*
|
||||
* @param element Element to monitor.
|
||||
* @param callbacks
|
||||
*/
|
||||
constructor (private _element: HTMLElement, callbacks: PointerTrackerCallbacks) {
|
||||
const {
|
||||
start = () => true,
|
||||
move = noop,
|
||||
end = noop
|
||||
} = callbacks;
|
||||
|
||||
this._startCallback = start;
|
||||
this._moveCallback = move;
|
||||
this._endCallback = end;
|
||||
|
||||
// Add listeners
|
||||
if (self.PointerEvent) {
|
||||
this._element.addEventListener('pointerdown', this._pointerStart);
|
||||
} else {
|
||||
this._element.addEventListener('mousedown', this._pointerStart);
|
||||
this._element.addEventListener('touchstart', this._touchStart);
|
||||
this._element.addEventListener('touchmove', this._move);
|
||||
this._element.addEventListener('touchend', this._touchEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the start callback for this pointer, and track it if the user wants.
|
||||
*
|
||||
* @param pointer Pointer
|
||||
* @param event Related event
|
||||
* @returns Whether the pointer is being tracked.
|
||||
*/
|
||||
private _triggerPointerStart (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean {
|
||||
if (!this._startCallback(pointer, event)) return false;
|
||||
this.currentPointers.push(pointer);
|
||||
this.startPointers.push(pointer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for mouse/pointer starts. Bound to the class in the constructor.
|
||||
*
|
||||
* @param event This will only be a MouseEvent if the browser doesn't support
|
||||
* pointer events.
|
||||
*/
|
||||
@bind
|
||||
private _pointerStart (event: PointerEvent | MouseEvent) {
|
||||
if (event.button !== Button.Left) return;
|
||||
if (!this._triggerPointerStart(new Pointer(event), event)) return;
|
||||
|
||||
// Add listeners for additional events.
|
||||
// The listeners may already exist, but no harm in adding them again.
|
||||
if (isPointerEvent(event)) {
|
||||
this._element.setPointerCapture(event.pointerId);
|
||||
this._element.addEventListener('pointermove', this._move);
|
||||
this._element.addEventListener('pointerup', this._pointerEnd);
|
||||
} else { // MouseEvent
|
||||
window.addEventListener('mousemove', this._move);
|
||||
window.addEventListener('mouseup', this._pointerEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for touchstart. Bound to the class in the constructor.
|
||||
* Only used if the browser doesn't support pointer events.
|
||||
*/
|
||||
@bind
|
||||
private _touchStart (event: TouchEvent) {
|
||||
for (const touch of Array.from(event.changedTouches)) {
|
||||
this._triggerPointerStart(new Pointer(touch), event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for pointer/mouse/touch move events.
|
||||
* Bound to the class in the constructor.
|
||||
*/
|
||||
@bind
|
||||
private _move (event: PointerEvent | MouseEvent | TouchEvent) {
|
||||
const previousPointers = this.currentPointers.slice();
|
||||
const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'.
|
||||
Array.from(event.changedTouches).map(t => new Pointer(t)) :
|
||||
[new Pointer(event)];
|
||||
|
||||
let shouldCallback = false;
|
||||
|
||||
for (const pointer of changedPointers) {
|
||||
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
|
||||
if (index === -1) continue;
|
||||
shouldCallback = true;
|
||||
this.currentPointers[index] = pointer;
|
||||
}
|
||||
|
||||
if (!shouldCallback) return;
|
||||
|
||||
this._moveCallback(previousPointers, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the end callback for this pointer.
|
||||
*
|
||||
* @param pointer Pointer
|
||||
* @param event Related event
|
||||
*/
|
||||
@bind
|
||||
private _triggerPointerEnd (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean {
|
||||
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
|
||||
// Not a pointer we're interested in?
|
||||
if (index === -1) return false;
|
||||
|
||||
this.currentPointers.splice(index, 1);
|
||||
this.startPointers.splice(index, 1);
|
||||
|
||||
this._endCallback(pointer, event);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for mouse/pointer ends. Bound to the class in the constructor.
|
||||
* @param event This will only be a MouseEvent if the browser doesn't support
|
||||
* pointer events.
|
||||
*/
|
||||
@bind
|
||||
private _pointerEnd (event: PointerEvent | MouseEvent) {
|
||||
if (!this._triggerPointerEnd(new Pointer(event), event)) return;
|
||||
|
||||
if (isPointerEvent(event)) {
|
||||
if (this.currentPointers.length) return;
|
||||
this._element.removeEventListener('pointermove', this._move);
|
||||
this._element.removeEventListener('pointerup', this._pointerEnd);
|
||||
} else { // MouseEvent
|
||||
window.removeEventListener('mousemove', this._move);
|
||||
window.removeEventListener('mouseup', this._pointerEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for touchend. Bound to the class in the constructor.
|
||||
* Only used if the browser doesn't support pointer events.
|
||||
*/
|
||||
@bind
|
||||
private _touchEnd (event: TouchEvent) {
|
||||
for (const touch of Array.from(event.changedTouches)) {
|
||||
this._triggerPointerEnd(new Pointer(touch), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/lib/PointerTracker/missing-types.d.ts
vendored
Normal file
6
src/lib/PointerTracker/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// TypeScript, you make me sad.
|
||||
// https://github.com/Microsoft/TypeScript/issues/18756
|
||||
interface Window {
|
||||
PointerEvent: typeof PointerEvent;
|
||||
Touch: typeof Touch;
|
||||
}
|
||||
@@ -1,26 +1,3 @@
|
||||
import { Component, ComponentProps } from 'preact';
|
||||
|
||||
type WhenProps = ComponentProps<When> & {
|
||||
value: boolean,
|
||||
children?: (JSX.Element | (() => JSX.Element))[]
|
||||
};
|
||||
|
||||
type WhenState = {
|
||||
ready: boolean
|
||||
};
|
||||
|
||||
export class When extends Component<WhenProps, WhenState> {
|
||||
state: WhenState = {
|
||||
ready: !!this.props.value
|
||||
};
|
||||
|
||||
render({ value, children = [] }: WhenProps, { ready }: WhenState) {
|
||||
let child = children[0];
|
||||
if (value && !ready) this.setState({ ready: true });
|
||||
return ready ? (typeof child === 'function' ? child() : child) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator that binds values to their class instance.
|
||||
* @example
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
$toolbar-height: 56px;
|
||||
|
||||
$mdc-theme-primary: #263238;
|
||||
$mdc-theme-primary-light: #4f5b62;
|
||||
$mdc-theme-primary-dark: #000a12;
|
||||
$mdc-theme-secondary: #d81b60;
|
||||
$mdc-theme-secondary-light: #ff5c8d;
|
||||
$mdc-theme-secondary-dark: #a00037;
|
||||
$mdc-theme-secondary-dark: #a00037;
|
||||
$mdc-theme-background: #fff;
|
||||
@@ -1,7 +1,4 @@
|
||||
// @import 'material-components-web/material-components-web';
|
||||
@import './material-icons.scss';
|
||||
@import './reset.scss';
|
||||
// @import url('https://fonts.googleapis.com/icon?family=Material+Icons');
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
@@ -11,17 +8,3 @@ html, body {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #FAFAFA;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
color: #444;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.mdc-theme--dark {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
// @todo woff fallback!
|
||||
src: url(https://fonts.gstatic.com/s/materialicons/v36/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
Reference in New Issue
Block a user