Integrating two-up (#34)

This commit is contained in:
Jake Archibald
2018-05-18 14:52:00 +01:00
committed by GitHub
parent 634dfe3717
commit a7598b6602
6 changed files with 286 additions and 17 deletions

View File

@ -11,6 +11,6 @@ interface Window {
declare namespace JSX {
interface IntrinsicElements {
"pinch-zoom": any
'pinch-zoom': HTMLAttributes;
}
}

View File

@ -0,0 +1,111 @@
import * as styles from './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
const legacyClipCompat = 'legacy-clip-compat';
/**
* A split view that the user can adjust. The first child becomes
* the left-hand side, and the second child becomes the right-hand side.
*/
export default class TwoUp extends HTMLElement {
static get observedAttributes () { return [legacyClipCompat]; }
private readonly _handle = document.createElement('div');
/**
* The position of the split in pixels.
*/
private _position = 0;
/**
* The value of _position when the pointer went down.
*/
private _positionOnPointerStart = 0;
/**
* Has connectedCallback been called yet?
*/
private _everConnected = false;
constructor () {
super();
this._handle.className = styles.twoUpHandle;
// Watch for children changes.
// Note this won't fire for initial contents,
// so _childrenChange is also called in connectedCallback.
new MutationObserver(() => this._childrenChange())
.observe(this, { childList: true });
// Watch for pointers on the handle.
const pointerTracker: PointerTracker = new PointerTracker(this._handle, {
start: (_, event) => {
// We only want to track 1 pointer.
if (pointerTracker.currentPointers.length === 1) return false;
event.preventDefault();
this._positionOnPointerStart = this._position;
return true;
},
move: () => {
this._pointerChange(
pointerTracker.startPointers[0],
pointerTracker.currentPointers[0]
);
}
});
}
connectedCallback () {
this._childrenChange();
if (!this._everConnected) {
// Set the initial position of the handle.
requestAnimationFrame(() => {
const bounds = this.getBoundingClientRect();
this._position = bounds.width / 2;
this._setPosition();
});
this._everConnected = true;
}
}
/**
* If true, this element works in browsers that don't support clip-path (Edge).
* However, this means you'll have to set the height of this element manually.
*/
get noClipPathCompat () {
return this.hasAttribute(legacyClipCompat);
}
set noClipPathCompat (val: boolean) {
if (val) {
this.setAttribute(legacyClipCompat, '');
} else {
this.removeAttribute(legacyClipCompat);
}
}
/**
* Called when element's child list changes
*/
private _childrenChange () {
// Ensure the handle is the last child.
// The CSS depends on this.
if (this.lastElementChild !== this._handle) {
this.appendChild(this._handle);
}
}
/**
* Called when a pointer moves.
*/
private _pointerChange (startPoint: Pointer, currentPoint: Pointer) {
const bounds = this.getBoundingClientRect();
this._position = this._positionOnPointerStart + (currentPoint.clientX - startPoint.clientX);
// Clamp position to element bounds.
this._position = Math.max(0, Math.min(this._position, bounds.width));
this._setPosition();
}
private _setPosition () {
this.style.setProperty('--split-point', `${this._position}px`);
}
}
customElements.define('two-up', TwoUp);

View 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 {
"two-up": HTMLAttributes
}
}

View File

@ -0,0 +1,61 @@
two-up {
display: grid;
position: relative;
--split-point: 0;
}
two-up > * {
/* Overlay all children on top of each other, and let
two-up's layout contain all of them. */
grid-area: 1/1;
}
two-up[legacy-clip-compat] > :not(.twoUpHandle) {
position: absolute;
}
.twoUpHandle {
touch-action: none;
position: relative;
width: 10px;
background: red;
transform: translateX(var(--split-point)) translateX(-50%);
will-change: transform;
}
.twoUpHandle::after {
content: '';
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 40px;
background: red;
border-radius: 20px;
}
two-up > :nth-child(1):not(.twoUpHandle) {
-webkit-clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
clip-path: inset(0 calc(100% - var(--split-point)) 0 0);
}
two-up > :nth-child(2):not(.twoUpHandle) {
-webkit-clip-path: inset(0 0 0 var(--split-point));
clip-path: inset(0 0 0 var(--split-point));
}
/*
Even in legacy-clip-compat, prefer clip-path if it's supported.
It performs way better in Safari.
*/
@supports not ((clip-path: inset(0 0 0 var(--split-point))) or (-webkit-clip-path: inset(0 0 0 var(--split-point)))) {
two-up[legacy-clip-compat] > :nth-child(1):not(.twoUpHandle) {
clip: rect(auto var(--split-point) auto auto);
}
two-up[legacy-clip-compat] > :nth-child(2):not(.twoUpHandle) {
clip: rect(auto auto auto var(--split-point));
}
}

View File

@ -1,6 +1,10 @@
import { h, Component } from 'preact';
import PinchZoom from './custom-els/PinchZoom';
import './custom-els/PinchZoom';
import './custom-els/TwoUp';
import * as style from './style.scss';
import { bind } from '../../lib/util';
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
type Props = {
img: ImageBitmap
@ -10,34 +14,89 @@ type State = {};
export default class App extends Component<Props, State> {
state: State = {};
canvas?: HTMLCanvasElement;
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
retargetedEvents = new WeakSet<Event>();
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);
updateCanvases(img: ImageBitmap) {
for (const [i, canvas] of [this.canvasLeft, this.canvasRight].entries()) {
if (!canvas) throw Error('Missing canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw Error('Expected 2d canvas context');
if (i === 1) {
// This is temporary, to show the images are different
ctx.filter = 'hue-rotate(180deg)';
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
}
}
componentDidMount() {
this.updateCanvas(this.props.img);
this.updateCanvases(this.props.img);
}
componentDidUpdate({ img }: Props) {
if (img !== this.props.img) this.updateCanvas(this.props.img);
if (img !== this.props.img) this.updateCanvases(this.props.img);
}
@bind
onPinchZoomLeftChange(event: Event) {
if (!this.pinchZoomRight || !this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
this.pinchZoomRight.setTransform({
scale: this.pinchZoomLeft.scale,
x: this.pinchZoomLeft.x,
y: this.pinchZoomLeft.y
});
}
/**
* We're using two pinch zoom elements, but we want them to stay in sync. When one moves, we
* update the position of the other. However, this is tricky when it comes to multi-touch, when
* one finger is on one pinch-zoom, and the other finger is on the other. To overcome this, we
* redirect all relevant pointer/touch/mouse events to the first pinch zoom element.
*
* @param event Event to redirect
*/
@bind
onRetargetableEvent(event: Event) {
const targetEl = event.target as HTMLElement;
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
// If the event is on the handle of the two-up, let it through.
if (targetEl.closest('.' + twoUpHandle)) return;
// If we've already retargeted this event, let it through.
if (this.retargetedEvents.has(event)) return;
// Stop the event in its tracks.
event.stopImmediatePropagation();
event.preventDefault();
// Clone the event & dispatch
// Some TypeScript trickery needed due to https://github.com/Microsoft/TypeScript/issues/3841
const clonedEvent = new (event.constructor as typeof Event)(event.type, event);
this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent);
}
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>
<two-up
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
onTouchMoveCapture={this.onRetargetableEvent}
onPointerDownCapture={this.onRetargetableEvent}
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom onChange={this.onPinchZoomLeftChange} ref={p => this.pinchZoomLeft = p as PinchZoom}>
<canvas class={style.outputCanvas} ref={c => this.canvasLeft = c as HTMLCanvasElement} width={img.width} height={img.height} />
</pinch-zoom>
<pinch-zoom ref={p => this.pinchZoomRight = p as PinchZoom}>
<canvas class={style.outputCanvas} ref={c => this.canvasRight = c as HTMLCanvasElement} width={img.width} height={img.height} />
</pinch-zoom>
</two-up>
<p>And that's all the app does so far!</p>
</div>
);

22
src/missing-types.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
// PRs to fix this:
// https://github.com/developit/preact/pull/1101
// https://github.com/developit/preact/pull/1102
declare namespace JSX {
type PointerEventHandler = EventHandler<PointerEvent>;
interface DOMAttributes {
onTouchStartCapture?: TouchEventHandler;
onTouchEndCapture?: TouchEventHandler;
onTouchMoveCapture?: TouchEventHandler;
onPointerDownCapture?: PointerEventHandler;
onMouseDownCapture?: MouseEventHandler;
onWheelCapture?: WheelEventHandler;
}
}
interface CanvasRenderingContext2D {
filter: string;
}