Browse Source

Merge pull request #4000 from cmprince/i3908-image_rotation

I3908 image rotation, flip and invert
Jason Grout 7 years ago
parent
commit
8de8cf6d36

+ 4 - 1
docs/source/user/file_formats.rst

@@ -85,7 +85,10 @@ Images
 
 
 JupyterLab supports image data in cell output and as files in the above
 JupyterLab supports image data in cell output and as files in the above
 formats. In the image file viewer, you can use keyboard shortcuts such
 formats. In the image file viewer, you can use keyboard shortcuts such
-as ``+`` and ``-`` to zoom the image and ``0`` to reset the zoom level.
+as ``+`` and ``-`` to zoom the image, ``[`` and ``]`` to rotate the image, 
+and ``H`` and ``V`` to flip the image horizontally and vertically. Use 
+``I`` to invert the colors, and use ``0`` to reset the image.
+
 To edit an SVG image as a text file, right-click on the SVG filename in
 To edit an SVG image as a text file, right-click on the SVG filename in
 the file browser and select the “Editor” item in the “Open With”
 the file browser and select the “Editor” item in the “Open With”
 submenu:
 submenu:

+ 94 - 6
packages/imageviewer-extension/src/index.ts

@@ -18,13 +18,28 @@ import {
  */
  */
 namespace CommandIDs {
 namespace CommandIDs {
   export
   export
-  const resetZoom = 'imageviewer:reset-zoom';
+  const resetImage = 'imageviewer:reset-image';
 
 
   export
   export
   const zoomIn = 'imageviewer:zoom-in';
   const zoomIn = 'imageviewer:zoom-in';
 
 
   export
   export
   const zoomOut = 'imageviewer:zoom-out';
   const zoomOut = 'imageviewer:zoom-out';
+
+  export
+  const flipHorizontal = 'imageviewer:flip-horizontal';
+
+  export
+  const flipVertical = 'imageviewer:flip-vertical';
+
+  export
+  const rotateClockwise = 'imageviewer:rotate-clockwise';
+
+  export
+  const rotateCounterclockwise = 'imageviewer:rotate-counterclockwise';
+
+  export
+  const invertColors = 'imageviewer:invert-colors';
 }
 }
 
 
 
 
@@ -98,7 +113,7 @@ function activate(app: JupyterLab, palette: ICommandPalette, restorer: ILayoutRe
 
 
   const category = 'Image Viewer';
   const category = 'Image Viewer';
 
 
-  [CommandIDs.zoomIn, CommandIDs.zoomOut, CommandIDs.resetZoom]
+  [CommandIDs.zoomIn, CommandIDs.zoomOut, CommandIDs.resetImage, CommandIDs.rotateClockwise, CommandIDs.rotateCounterclockwise, CommandIDs.flipHorizontal, CommandIDs.flipVertical, CommandIDs.invertColors]
     .forEach(command => { palette.addItem({ command, category }); });
     .forEach(command => { palette.addItem({ command, category }); });
 
 
   return tracker;
   return tracker;
@@ -132,9 +147,39 @@ function addCommands(app: JupyterLab, tracker: IImageTracker) {
     isEnabled
     isEnabled
   });
   });
 
 
-  commands.addCommand('imageviewer:reset-zoom', {
-    execute: resetZoom,
-    label: 'Reset Zoom',
+  commands.addCommand('imageviewer:reset-image', {
+    execute: resetImage,
+    label: 'Reset Image',
+    isEnabled
+  });
+
+  commands.addCommand('imageviewer:rotate-clockwise', {
+    execute: rotateClockwise,
+    label: 'Rotate Clockwise',
+    isEnabled
+  });
+
+  commands.addCommand('imageviewer:rotate-counterclockwise', {
+    execute: rotateCounterclockwise,
+    label: 'Rotate Counterclockwise',
+    isEnabled
+  });
+
+  commands.addCommand('imageviewer:flip-horizontal', {
+    execute: flipHorizontal,
+    label: 'Flip image horizontally',
+    isEnabled
+  });
+
+  commands.addCommand('imageviewer:flip-vertical', {
+    execute: flipVertical,
+    label: 'Flip image vertically',
+    isEnabled
+  });
+
+  commands.addCommand('imageviewer:invert-colors', {
+    execute: invertColors,
+    label: 'Invert Colors',
     isEnabled
     isEnabled
   });
   });
 
 
@@ -154,11 +199,54 @@ function addCommands(app: JupyterLab, tracker: IImageTracker) {
     }
     }
   }
   }
 
 
-  function resetZoom(): void {
+  function resetImage(): void {
     const widget = tracker.currentWidget;
     const widget = tracker.currentWidget;
 
 
     if (widget) {
     if (widget) {
       widget.scale = 1;
       widget.scale = 1;
+      widget.colorinversion = 0;
+      widget.resetRotationFlip();
+    }
+  }
+
+  function rotateClockwise(): void {
+    const widget = tracker.currentWidget;
+
+    if (widget) {
+      widget.rotateClockwise();
+    }
+  }
+
+  function rotateCounterclockwise(): void {
+    const widget = tracker.currentWidget;
+
+    if (widget) {
+      widget.rotateCounterclockwise();
+    }
+  }
+
+  function flipHorizontal(): void {
+    const widget = tracker.currentWidget;
+
+    if (widget) {
+      widget.flipHorizontal();
+    }
+  }
+
+  function flipVertical(): void {
+    const widget = tracker.currentWidget;
+
+    if (widget) {
+      widget.flipVertical();
+    }
+  }
+
+  function invertColors(): void {
+    const widget = tracker.currentWidget;
+
+    if (widget) {
+      widget.colorinversion += 1;
+      widget.colorinversion %= 2;
     }
     }
   }
   }
 }
 }

+ 110 - 16
packages/imageviewer/src/widget.ts

@@ -37,11 +37,14 @@ class ImageViewer extends Widget implements DocumentRegistry.IReadyWidget {
    * Construct a new image widget.
    * Construct a new image widget.
    */
    */
   constructor(context: DocumentRegistry.Context) {
   constructor(context: DocumentRegistry.Context) {
-    super({ node: Private.createNode() });
+    super();
     this.context = context;
     this.context = context;
     this.node.tabIndex = -1;
     this.node.tabIndex = -1;
     this.addClass(IMAGE_CLASS);
     this.addClass(IMAGE_CLASS);
 
 
+    this._img = document.createElement('img');
+    this.node.appendChild(this._img);
+
     this._onTitleChanged();
     this._onTitleChanged();
     context.pathChanged.connect(this._onTitleChanged, this);
     context.pathChanged.connect(this._onTitleChanged, this);
 
 
@@ -79,10 +82,61 @@ class ImageViewer extends Widget implements DocumentRegistry.IReadyWidget {
       return;
       return;
     }
     }
     this._scale = value;
     this._scale = value;
-    let scaleNode = this.node.querySelector('div') as HTMLElement;
-    let transform: string;
-    transform = `scale(${value})`;
-    scaleNode.style.transform = transform;
+    this._updateStyle();
+  }
+
+  /**
+   * The color inversion of the image.
+   */
+  get colorinversion(): number {
+    return this._colorinversion;
+  }
+  set colorinversion(value: number) {
+    if (value === this._colorinversion) {
+        return;
+    }
+    this._colorinversion = value;
+    this._updateStyle();
+  }
+
+  /**
+   * Reset rotation and flip transformations.
+   */
+  resetRotationFlip(): void {
+    this._matrix = [1, 0, 0, 1];
+    this._updateStyle();
+  }
+
+  /**
+   * Rotate the image counter-clockwise (left).
+   */
+  rotateCounterclockwise(): void {
+    this._matrix = Private.prod(this._matrix, Private.rotateCounterclockwiseMatrix);
+    this._updateStyle();
+  }
+
+  /**
+   * Rotate the image clockwise (right).
+   */
+  rotateClockwise(): void {
+    this._matrix = Private.prod(this._matrix, Private.rotateClockwiseMatrix);
+    this._updateStyle();
+  }
+
+  /**
+   * Flip the image horizontally.
+   */
+  flipHorizontal(): void {
+    this._matrix = Private.prod(this._matrix, Private.flipHMatrix);
+    this._updateStyle();
+  }
+
+  /**
+   * Flip the image vertically.
+   */
+  flipVertical(): void {
+    this._matrix = Private.prod(this._matrix, Private.flipVMatrix);
+    this._updateStyle();
   }
   }
 
 
   /**
   /**
@@ -119,13 +173,25 @@ class ImageViewer extends Widget implements DocumentRegistry.IReadyWidget {
       return;
       return;
     }
     }
     let content = context.model.toString();
     let content = context.model.toString();
-    let src = `data:${cm.mimetype};${cm.format},${content}`;
-    let node = this.node.querySelector('img') as HTMLImageElement;
-    node.setAttribute('src', src);
+    this._img.src = `data:${cm.mimetype};${cm.format},${content}`;
+  }
+
+  /**
+   * Update the image CSS style, including the transform and filter.
+   */
+  private _updateStyle(): void {
+    let [a, b, c, d] = this._matrix;
+    let [tX, tY] = Private.prodVec(this._matrix, [1, 1]);
+    let transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0) translate(${tX < 0 ? -100 : 0}%, ${tY < 0 ? -100 : 0}%) `;
+    this._img.style.transform = `scale(${this._scale}) ${transform}`;
+    this._img.style.filter = `invert(${this._colorinversion})`;
   }
   }
 
 
   private _scale = 1;
   private _scale = 1;
+  private _matrix = [1, 0, 0, 1];
+  private _colorinversion = 0;
   private _ready = new PromiseDelegate<void>();
   private _ready = new PromiseDelegate<void>();
+  private _img: HTMLImageElement;
 }
 }
 
 
 
 
@@ -147,15 +213,43 @@ class ImageViewerFactory extends ABCWidgetFactory<ImageViewer, DocumentRegistry.
  */
  */
 namespace Private {
 namespace Private {
   /**
   /**
-   * Create the node for the image widget.
+   * Multiply 2x2 matrices.
    */
    */
   export
   export
-  function createNode(): HTMLElement {
-    let node = document.createElement('div');
-    let innerNode = document.createElement('div');
-    let image = document.createElement('img');
-    node.appendChild(innerNode);
-    innerNode.appendChild(image);
-    return node;
+  function prod([a11, a12, a21, a22]: number[], [b11, b12, b21, b22]: number[]): number[] {
+    return [a11 * b11 + a12 * b21, a11 * b12 + a12 * b22,
+            a21 * b11 + a22 * b21, a21 * b12 + a22 * b22];
   }
   }
+
+  /**
+   * Multiply a 2x2 matrix and a 2x1 vector.
+   */
+  export
+  function prodVec([a11, a12, a21, a22]: number[], [b1, b2]: number[]): number[] {
+    return [a11 * b1 + a12 * b2, a21 * b1 + a22 * b2];
+  }
+
+  /**
+   * Clockwise rotation transformation matrix.
+   */
+  export
+  const rotateClockwiseMatrix = [0, 1, -1, 0];
+
+  /**
+   * Counter-clockwise rotation transformation matrix.
+   */
+  export
+  const rotateCounterclockwiseMatrix = [0, -1, 1, 0];
+
+  /**
+   * Horizontal flip transformation matrix.
+   */
+  export
+  const flipHMatrix = [-1, 0, 0, 1];
+
+  /**
+   * Vertical flip transformation matrix.
+   */
+  export
+  const flipVMatrix = [1, 0, 0, -1];
 }
 }

+ 2 - 10
packages/imageviewer/style/index.css

@@ -8,19 +8,11 @@
   overflow: auto;
   overflow: auto;
 }
 }
 
 
-
-.jp-ImageViewer > div {
+.jp-ImageViewer > img {
+  box-sizing: border-box;
   transform-origin: top left;
   transform-origin: top left;
 }
 }
 
 
-
-.jp-ImageViewer img {
-  max-width: 100%;
-  max-height: 100%;
-  margin: auto;
-}
-
-
 .jp-ImageViewer::before {
 .jp-ImageViewer::before {
     content: '';
     content: '';
     display: block;
     display: block;

+ 47 - 2
packages/shortcuts-extension/schema/plugin.json

@@ -148,10 +148,10 @@
       },
       },
       "type": "object"
       "type": "object"
     },
     },
-    "imageviewer:reset-zoom": {
+    "imageviewer:reset-image": {
       "default": { },
       "default": { },
       "properties": {
       "properties": {
-        "command": { "default": "imageviewer:reset-zoom" },
+        "command": { "default": "imageviewer:reset-image" },
         "keys": { "default": ["0"] },
         "keys": { "default": ["0"] },
         "selector": { "default": ".jp-ImageViewer" }
         "selector": { "default": ".jp-ImageViewer" }
       },
       },
@@ -175,6 +175,51 @@
       },
       },
       "type": "object"
       "type": "object"
     },
     },
+    "imageviewer:rotate-clockwise": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:rotate-clockwise" },
+        "keys": { "default": ["]"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:rotate-counterclockwise": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:rotate-counterclockwise" },
+        "keys": { "default": ["["] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:flip-vertical": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:flip-vertical" },
+        "keys": { "default": ["V"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:flip-horizontal": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:flip-horizontal" },
+        "keys": { "default": ["H"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:invert-colors": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:invert-colors" },
+        "keys": { "default": ["I"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
     "inspector:open": {
     "inspector:open": {
       "default": { },
       "default": { },
       "properties": {
       "properties": {