From a52ad3844707ec9dfcf512108254e4a97ea89ae1 Mon Sep 17 00:00:00 2001
From: Surma <surma@surma.link>
Date: Mon, 25 Feb 2019 19:31:32 +0000
Subject: [PATCH] Add position reset button and update zoom interaction

Fix set-rotation and HMR issues

Fix offliner.ts dev env issue. #342

Reset position only if image gets totally lost from the viewport

Migrate service worker existenceny check before its register call
---
 package.json                    |   1 +
 src/components/Output/index.tsx | 127 +++++++++++++++++++++++---------
 src/lib/offliner.ts             |   2 +
 src/missing-types.d.ts          |   2 +
 4 files changed, 97 insertions(+), 35 deletions(-)

diff --git a/package.json b/package.json
index 535b09be5..bd6fae5fd 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "gzip-size": "5.0.0",
     "html-webpack-plugin": "3.2.0",
     "husky": "1.3.1",
+    "intersection-observer": "^0.5.1",
     "idb-keyval": "3.1.0",
     "linkstate": "1.1.1",
     "loader-utils": "1.2.3",
diff --git a/src/components/Output/index.tsx b/src/components/Output/index.tsx
index 36ba4120f..1f0a8428c 100644
--- a/src/components/Output/index.tsx
+++ b/src/components/Output/index.tsx
@@ -34,6 +34,12 @@ interface State {
   scale: number;
   editingScale: boolean;
   altBackground: boolean;
+  isIntersecting: boolean;
+}
+
+interface IntersectionObserverEntry {
+  readonly intersectionRatio: number;
+  readonly isIntersecting: boolean;
 }
 
 const scaleToOpts: ScaleToOpts = {
@@ -48,33 +54,29 @@ export default class Output extends Component<Props, State> {
     scale: 1,
     editingScale: false,
     altBackground: false,
+    isIntersecting: true,
   };
   canvasLeft?: HTMLCanvasElement;
   canvasRight?: HTMLCanvasElement;
   pinchZoomLeft?: PinchZoom;
   pinchZoomRight?: PinchZoom;
   scaleInput?: HTMLInputElement;
+  threshold: number = 0;
   retargetedEvents = new WeakSet<Event>();
 
   componentDidMount() {
     const leftDraw = this.leftDrawable();
     const rightDraw = this.rightDrawable();
 
-    // Reset the pinch zoom, which may have an position set from the previous view, after pressing
-    // the back button.
-    this.pinchZoomLeft!.setTransform({
-      allowChangeEvent: true,
-      x: 0,
-      y: 0,
-      scale: 1,
-    });
-
     if (this.canvasLeft && leftDraw) {
       drawDataToCanvas(this.canvasLeft, leftDraw);
     }
     if (this.canvasRight && rightDraw) {
       drawDataToCanvas(this.canvasRight, rightDraw);
     }
+
+    this.initializeImage();
+    this.observeIntersection();
   }
 
   componentDidUpdate(prevProps: Props, prevState: State) {
@@ -127,6 +129,43 @@ export default class Output extends Component<Props, State> {
     return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
   }
 
+  @bind
+  private async initializeImage() {
+    await this.setRotation(true)();
+    this.setZoom(1)();
+    this.resetPosition();
+    this.setState({ altBackground: false });
+  }
+
+  @bind
+  private handleIntersect(entries: IntersectionObserverEntry[]) {
+    entries.forEach((entry: IntersectionObserverEntry) => {
+      // Value of isIntersecting is depended on threshold value on chrome.
+      // However, for safari, firefox and polyfill we just need to check also intersectionRatio.
+      // Realized different behavior: https://github.com/w3c/IntersectionObserver/issues/345
+      const isIntersecting = entry.isIntersecting && entry.intersectionRatio > this.threshold;
+      this.setState({ isIntersecting });
+    });
+  }
+
+  @bind
+  private async observeIntersection() {
+    if (!('intersectionObserver' in window)) {
+      await import('intersection-observer');
+    }
+
+    if (!this.pinchZoomLeft || !this.canvasLeft) return;
+
+    const options = {
+      root: this.pinchZoomLeft,
+      rootMargin: '0px',
+      threshold: this.threshold,
+    };
+    const observer = new IntersectionObserver(this.handleIntersect, options);
+
+    observer.observe(this.canvasLeft);
+  }
+
   private leftDrawable(props: Props = this.props): ImageData | undefined {
     return props.leftCompressed || (props.source && props.source.processed);
   }
@@ -135,39 +174,56 @@ export default class Output extends Component<Props, State> {
     return props.rightCompressed || (props.source && props.source.processed);
   }
 
+  // initial coordinates depends on the current scale and dimensions of the image.
   @bind
-  private toggleBackground() {
-    this.setState({
-      altBackground: !this.state.altBackground,
-    });
+  private resetPosition(scaleRatio: number = this.state.scale) {
+    if (this.canvasLeft) {
+      const { width, height } = this.canvasLeft;
+
+      this.pinchZoomLeft!.setTransform({
+        allowChangeEvent: true,
+        x: (width / 2) * (1 - scaleRatio),
+        y: (height / 2) * (1 - scaleRatio),
+        scale: scaleRatio,
+      });
+    }
   }
 
-  @bind
-  private zoomIn() {
-    if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
+  private setZoom(scaleRatio: number = 1) {
+    return () => {
+      if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
+
+      this.pinchZoomLeft.scaleTo(scaleRatio, scaleToOpts);
 
-    this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
+      // Now, reset position will be triggered when the image
+      // has been lost from the viewport with 0.2 threshold.
+      if (!this.state.isIntersecting) {
+        this.resetPosition(scaleRatio);
+      }
+    };
   }
 
   @bind
-  private zoomOut() {
-    if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
-
-    this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
+  private toggleBackground() {
+    this.setState({
+      altBackground: !this.state.altBackground,
+    });
   }
 
   @bind
-  private onRotateClick() {
-    const { inputProcessorState } = this.props;
-    if (!inputProcessorState) return;
-
-    const newState = cleanSet(
-      inputProcessorState,
-      'rotate.rotate',
-      (inputProcessorState.rotate.rotate + 90) % 360,
-    );
-
-    this.props.onInputProcessorChange(newState);
+  private setRotation(resetRotation?: boolean) {
+    return async() => {
+      const { inputProcessorState } = this.props;
+      if (!inputProcessorState) return;
+
+      const newState = cleanSet(
+        inputProcessorState,
+        'rotate.rotate',
+        resetRotation ? 0 : (inputProcessorState.rotate.rotate + 90) % 360,
+      );
+
+      return this.props.onInputProcessorChange(newState);
+    };
   }
 
   @bind
@@ -311,7 +367,7 @@ export default class Output extends Component<Props, State> {
 
         <div class={style.controls}>
           <div class={style.zoomControls}>
-            <button class={style.button} onClick={this.zoomOut}>
+            <button class={style.button} onClick={this.setZoom(this.state.scale / 1.25)}>
               <RemoveIcon />
             </button>
             {editingScale ? (
@@ -325,6 +381,7 @@ export default class Output extends Component<Props, State> {
                 value={Math.round(scale * 100)}
                 onInput={this.onScaleInputChanged}
                 onBlur={this.onScaleInputBlur}
+                onDblClick={this.initializeImage}
               />
             ) : (
               <span class={style.zoom} tabIndex={0} onFocus={this.onScaleValueFocus}>
@@ -332,12 +389,12 @@ export default class Output extends Component<Props, State> {
                 %
               </span>
             )}
-            <button class={style.button} onClick={this.zoomIn}>
+            <button class={style.button} onClick={this.setZoom(this.state.scale * 1.25)}>
               <AddIcon />
             </button>
           </div>
           <div class={style.buttonsNoWrap}>
-            <button class={style.button} onClick={this.onRotateClick} title="Rotate image">
+            <button class={style.button} onClick={this.setRotation()} title="Rotate image">
               <RotateIcon />
             </button>
             <button
diff --git a/src/lib/offliner.ts b/src/lib/offliner.ts
index 5c6e30c66..e881d320a 100644
--- a/src/lib/offliner.ts
+++ b/src/lib/offliner.ts
@@ -45,6 +45,8 @@ export async function offliner(showSnack: SnackBarElement['showSnackbar']) {
   // This needs to be a typeof because Webpack.
   if (typeof PRERENDER === 'boolean') return;
 
+  if (!('serviceWorker' in navigator)) return;
+
   if (process.env.NODE_ENV === 'production') {
     navigator.serviceWorker.register('../sw');
   }
diff --git a/src/missing-types.d.ts b/src/missing-types.d.ts
index 8d8366ae8..715ff632a 100644
--- a/src/missing-types.d.ts
+++ b/src/missing-types.d.ts
@@ -2,6 +2,8 @@ interface CanvasRenderingContext2D {
   filter: string;
 }
 
+declare module 'intersection-observer';
+
 // Handling file-loader imports:
 declare module '*.png' {
   const content: string;