diff --git a/src/components/output/custom-els/PinchZoom/missing-types.d.ts b/src/components/output/custom-els/PinchZoom/missing-types.d.ts index 3fe15aba..36d680b8 100644 --- a/src/components/output/custom-els/PinchZoom/missing-types.d.ts +++ b/src/components/output/custom-els/PinchZoom/missing-types.d.ts @@ -11,6 +11,6 @@ interface Window { declare namespace JSX { interface IntrinsicElements { - "pinch-zoom": any + 'pinch-zoom': HTMLAttributes; } } diff --git a/src/components/output/custom-els/TwoUp/index.ts b/src/components/output/custom-els/TwoUp/index.ts new file mode 100644 index 00000000..0299fca4 --- /dev/null +++ b/src/components/output/custom-els/TwoUp/index.ts @@ -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); diff --git a/src/components/output/custom-els/TwoUp/missing-types.d.ts b/src/components/output/custom-els/TwoUp/missing-types.d.ts new file mode 100644 index 00000000..5f71cb61 --- /dev/null +++ b/src/components/output/custom-els/TwoUp/missing-types.d.ts @@ -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 + } +} diff --git a/src/components/output/custom-els/TwoUp/styles.css b/src/components/output/custom-els/TwoUp/styles.css new file mode 100644 index 00000000..3463ec1b --- /dev/null +++ b/src/components/output/custom-els/TwoUp/styles.css @@ -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)); + } +} diff --git a/src/components/output/index.tsx b/src/components/output/index.tsx index 9b5bfee0..05af4777 100644 --- a/src/components/output/index.tsx +++ b/src/components/output/index.tsx @@ -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 { state: State = {}; - canvas?: HTMLCanvasElement; + canvasLeft?: HTMLCanvasElement; + canvasRight?: HTMLCanvasElement; + pinchZoomLeft?: PinchZoom; + pinchZoomRight?: PinchZoom; + retargetedEvents = new WeakSet(); - 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 (
- - this.canvas = c as HTMLCanvasElement} width={img.width} height={img.height} /> - + + this.pinchZoomLeft = p as PinchZoom}> + this.canvasLeft = c as HTMLCanvasElement} width={img.width} height={img.height} /> + + this.pinchZoomRight = p as PinchZoom}> + this.canvasRight = c as HTMLCanvasElement} width={img.width} height={img.height} /> + +

And that's all the app does so far!

); diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts new file mode 100644 index 00000000..ac8e3b63 --- /dev/null +++ b/src/missing-types.d.ts @@ -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; + + interface DOMAttributes { + onTouchStartCapture?: TouchEventHandler; + onTouchEndCapture?: TouchEventHandler; + onTouchMoveCapture?: TouchEventHandler; + + onPointerDownCapture?: PointerEventHandler; + + onMouseDownCapture?: MouseEventHandler; + + onWheelCapture?: WheelEventHandler; + } +} + +interface CanvasRenderingContext2D { + filter: string; +}