ソースを参照

Merge pull request #175 from threema-ch/image-preview

Image preview
Danilo Bargen 9 年 前
コミット
70448d58fa

+ 3 - 0
index.html

@@ -67,6 +67,9 @@
             </h1>
         </header>
 
+        <!-- The overlay box that shows up if media is previewed -->
+        <mediabox></mediabox>
+
         <div id="main">
             <div id="status-bar">
                 <status-bar active="ctrl.expandStatusBar"></status-bar>

+ 2 - 0
src/directives.ts

@@ -30,6 +30,7 @@ import includeReplace from './directives/include_replace';
 import latestMessage from './directives/latest_message';
 import latestMessageState from './directives/latest_message_state';
 import location from './directives/location';
+import mediabox from './directives/mediabox';
 import memberListEditor from './directives/member_list_editor';
 import message from './directives/message';
 import messageContact from './directives/message_contact';
@@ -70,6 +71,7 @@ angular.module('3ema.directives').directive('eeeVerificationLevel', verification
 angular.module('3ema.directives').directive('includeReplace', includeReplace);
 angular.module('3ema.directives').directive('location', location);
 angular.module('3ema.directives').directive('memberListEditor', memberListEditor);
+angular.module('3ema.directives').directive('mediabox', mediabox);
 angular.module('3ema.directives').directive('searchbox', searchbox);
 angular.module('3ema.directives').directive('statusBar', statusBar);
 angular.module('3ema.directives').directive('threemaAction', threemaAction);

+ 88 - 0
src/directives/mediabox.ts

@@ -0,0 +1,88 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {MediaboxService} from '../services/mediabox';
+
+export default [
+    '$rootScope',
+    '$filter',
+    '$document',
+    'MediaboxService',
+    function($rootScope: ng.IRootScopeService,
+             $filter: ng.IFilterService,
+             $document: ng.IDocumentService,
+             mediaboxService: MediaboxService) {
+        return {
+            restrict: 'E',
+            scope: {},
+            bindToController: {},
+            controllerAs: 'ctrl',
+            controller: [function() {
+                // Data attributes
+                this.imageDataUrl = null;
+                this.caption = '';
+
+                // Close and save
+                this.close = ($event?: Event) => {
+                    if ($event !== undefined) {
+                        // If this was triggered by a click event, only close the box
+                        // if the click was directly on the target element.
+                        if ($event.target === $event.currentTarget) {
+                            this.imageDataUrl = null;
+                        }
+                    } else {
+                        this.imageDataUrl = null;
+                    }
+                };
+                this.save = () => {
+                    saveAs(new Blob([mediaboxService.data]), mediaboxService.filename || 'image.jpg');
+                };
+
+                // Listen to Mediabox service events
+                const filter = $filter('bufferToUrl') as (buffer: ArrayBuffer, mimeType: string) => string;
+                mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
+                    $rootScope.$apply(() => {
+                        this.imageDataUrl = filter(mediaboxService.data, 'image/jpeg');
+                        this.caption = mediaboxService.caption || mediaboxService.filename;
+                    });
+                });
+            }],
+            link($scope: any, $element: ng.IAugmentedJQuery, attrs) {
+                // Register event handler for ESC key
+                $document.on('keyup', (e: Event) => {
+                    const ke = e as KeyboardEvent;
+                    if (ke.key === 'Escape' && $scope.ctrl.imageDataUrl !== null) {
+                        $scope.$apply($scope.ctrl.close);
+                    }
+                });
+            },
+            // tslint:disable:max-line-length
+            template: `
+                <div class="box" ng-if="ctrl.imageDataUrl !== null">
+                    <md-icon class="save material-icons md-24" ng-click="ctrl.save()" aria-label="Save" translate-attr="{'aria-label': 'common.SAVE', 'title': 'common.SAVE'}">save</md-icon>
+                    <md-icon class="close material-icons md-24" ng-click="ctrl.close()" aria-label="Close" translate-attr="{'aria-label': 'common.CLOSE', 'title': 'common.CLOSE'}">close</md-icon>
+                    <div class="inner" ng-click="ctrl.close($event)">
+                        <img ng-src="{{ ctrl.imageDataUrl }}">
+                        <div class="caption">
+                            {{ ctrl.caption }}
+                        </div>
+                    </div>
+                </div>
+            `,
+        };
+    },
+];

+ 12 - 6
src/directives/message_media.html

@@ -3,29 +3,35 @@
 <span class="in-view-indicator" in-view="ctrl.thumbnailInView($inview)"></span>
 
 <div ng-if="ctrl.uploading">
-    <! -- Loading indicator -->
+    <!-- Loading indicator -->
     <div class="circle active center">
         <i class="material-icons md-24">file_upload</i>
         <div class="loading active"></div>
     </div>
 </div>
 <div ng-if="!ctrl.uploading">
-    <div ng-if="ctrl.showThumbnail" class="thumbnail {{ctrl.type}}" ng-click="ctrl.download()" ng-style="ctrl.thumbnailStyle">
+    <div ng-if="ctrl.showThumbnail" class="thumbnail {{ ctrl.type }}" ng-click="ctrl.download()" ng-style="ctrl.thumbnailStyle">
 
+        <!-- Loading indicator -->
         <div class="loading-wrapper" ng-class="{active: ctrl.downloading || ctrl.thumbnailDownloading}">
             <div class="loading"></div>
             <div class="loading-text" translate>messenger.DOWNLOADING</div>
         </div>
+
+        <!-- Thumbnail overlays for videos and GIFs -->
         <div class="overlay video" ng-if="ctrl.type === 'video' && !ctrl.downloading">
             <i class="material-icons md-light">play_circle_outline</i>
         </div>
         <div class="overlay gif" ng-if="ctrl.type === 'file' && ctrl.message.file.type === 'image/gif' && !ctrl.downloading">
             <i class="material-icons md-light">play_circle_outline</i>
         </div>
+
+        <!-- Thumbnails -->
         <img ng-if="ctrl.thumbnail !== null" ng-src="{{ctrl.thumbnail}}">
         <div ng-if="ctrl.message.thumbnail != undefined" class="thumbnail-loader">
             <img ng-src="{{ ctrl.message.thumbnail.preview | bufferToUrl: 'image/png' }}">
         </div>
+
     </div>
 
     <!-- Location -->
@@ -33,14 +39,14 @@
 
     <!-- Audio file -->
     <div class="file-message" ng-if="ctrl.type === 'audio'">
-        <! -- Loading indicator -->
+        <!-- Loading indicator -->
         <div class="circle" ng-click="ctrl.download()"
              ng-class="{active: !ctrl.downloading}"
              ng-if="!ctrl.downloaded">
             <i class="material-icons md-24">file_download</i>
             <div class="loading" ng-class="{active: ctrl.downloading}"></div>
         </div>
-        <! -- Play Indicator -->
+        <!-- Play Indicator -->
         <div class="circle" ng-click="ctrl.download()" ng-if="ctrl.downloaded">
             <i class="material-icons md-24">play_arrow</i>
         </div>
@@ -54,7 +60,7 @@
 
     <!-- Other file messages -->
     <div class="file-message" ng-if="ctrl.type === 'file' && !ctrl.isAnimGif" ng-click="ctrl.download()">
-        <! -- Loading indicator -->
+        <!-- Loading indicator -->
         <div class="circle"
              ng-class="{active: !ctrl.downloading}"
              ng-if="!ctrl.downloaded"
@@ -62,7 +68,7 @@
             <i class="material-icons md-24">file_download</i>
             <div class="loading" ng-class="{active: ctrl.downloading}"></div>
         </div>
-        <! -- Open Indicator -->
+        <!-- Open Indicator -->
         <div class="circle"
              ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview !== undefined"
              ng-style="{'background-image': 'url({{ctrl.message.thumbnail.preview | bufferToUrl: 'image/png'}})' }">

+ 10 - 2
src/directives/message_media.ts

@@ -15,21 +15,26 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
 import {WebClientService} from '../services/webclient';
 
 export default [
     'WebClientService',
+    'MediaboxService',
     'MessageService',
     '$rootScope',
     '$mdDialog',
     '$timeout',
     '$log',
     '$filter',
-    function(webClientService: WebClientService, messageService: MessageService,
+    function(webClientService: WebClientService,
+             mediaboxService: MediaboxService,
+             messageService: MessageService,
              $rootScope: ng.IRootScopeService,
              $mdDialog: ng.material.IDialogService,
-             $timeout: ng.ITimeoutService, $log: ng.ILogService,
+             $timeout: ng.ITimeoutService,
+             $log: ng.ILogService,
              $filter: ng.IFilterService) {
         return {
             restrict: 'EA',
@@ -161,6 +166,9 @@ export default [
 
                                 switch (this.message.type) {
                                     case 'image':
+                                        const caption = message.caption || '';
+                                        mediaboxService.setMedia(buffer, messageService.getFileName(message), caption);
+                                        break;
                                     case 'video':
                                         saveAs(new Blob([buffer]), messageService.getFileName(message));
                                         break;

+ 1 - 0
src/sass/app.scss

@@ -42,6 +42,7 @@
 @import "components/avatar_area";
 @import "components/drag_file";
 @import "components/buttons";
+@import "components/mediabox";
 
 // Sections: Styles specific to individual pages or sections.
 // Nothing to see here yet!

+ 103 - 0
src/sass/components/_mediabox.scss

@@ -0,0 +1,103 @@
+mediabox {
+    .box {
+        position: absolute;
+        left: 0;
+        top: 0;
+        right: 0;
+        bottom: 0;
+        padding: 24px;
+        z-index: 41;
+        background-color: rgba(0, 0, 0, 0.75);
+
+        // Transitions
+        $mediabox-transition-duration: 0.1s;
+        &.ng-enter {
+            opacity: 0;
+            transition: $mediabox-transition-duration linear opacity;
+            img {
+                transform: scale(0.95);
+                transition: $mediabox-transition-duration linear transform;
+            }
+        }
+        &.ng-enter-active {
+            opacity: 1;
+            img {
+                transform: scale(1.0);
+            }
+        }
+        &.ng-leave {
+            opacity: 1;
+            transition: $mediabox-transition-duration linear opacity;
+            img {
+                transform: scale(1.0);
+                transition: $mediabox-transition-duration linear transform;
+            }
+        }
+        &.ng-leave-active {
+            opacity: 0;
+            img {
+                transform: scale(0.95);
+            }
+        }
+
+
+        md-icon {
+            display: block;
+            box-sizing: border-box;
+            position: absolute;
+            height: 28px;
+            width: 28px;
+            top: 24px;
+            color: rgba(255, 255, 255, 0.8);
+            background-color: rgba(0, 0, 0, 0.7);
+            border: 2px solid rgba(255, 255, 255, 0.8);
+            border-radius: 50%;
+            user-select: none;
+            @include mouse-hand;
+
+            &:focus {
+                color: rgba(255, 255, 255, 1.0);
+                border: 2px solid rgba(255, 255, 255, 1.0);
+            }
+
+            &.save {
+                right: 60px;
+                font-size: 20px;
+                padding: 2px;
+            }
+
+            &.close {
+                right: 24px;
+            }
+        }
+
+        .inner {
+            width: 100%;
+            height: 100%;
+            display: flex;
+            flex-direction: column;
+            justify-content: center;
+            align-items: center;
+
+            &:focus {
+                outline: 0;
+            }
+
+            img {
+                max-height: calc(100% - 28px - 28px);
+                max-width: 100%;
+                user-select: none;
+                box-shadow: 0 0 50px rgba(0, 0, 0, 0.7);
+            }
+
+            .caption {
+                display: flex;
+                align-items: flex-end;
+                height: 28px;
+                margin: 4px 0 0;
+                color: rgba(255, 255, 255, 0.8);
+                font-size: 1.3em;
+            }
+        }
+    }
+}

+ 2 - 0
src/services.ts

@@ -21,6 +21,7 @@ import {ControllerService} from './services/controller';
 import {ControllerModelService} from './services/controller_model';
 import {FingerPrintService} from './services/fingerprint';
 import {TrustedKeyStoreService} from './services/keystore';
+import {MediaboxService} from './services/mediabox';
 import {MessageService} from './services/message';
 import {MimeService} from './services/mime';
 import {NotificationService} from './services/notification';
@@ -54,4 +55,5 @@ angular.module('3ema.services', [])
 .service('ControllerService', ControllerService)
 .service('StringService', StringService)
 .service('SettingsService', SettingsService)
+.service('MediaboxService', MediaboxService)
 ;

+ 70 - 0
src/services/mediabox.ts

@@ -0,0 +1,70 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {AsyncEvent} from 'ts-events';
+
+/**
+ * This service is responsible for showing / hiding the media box.
+ */
+export class MediaboxService {
+
+    private logTag: string = '[MediaboxService]';
+
+    private $log: ng.ILogService;
+
+    /**
+     * This event is triggered every time the media element changes.
+     *
+     * The boolean parameter indicates whether media content is available or not.
+     */
+    public evtMediaChanged = new AsyncEvent<boolean>();
+
+    /**
+     * The full-resolution media data.
+     */
+    public data: ArrayBuffer | null = null;
+    public caption: string = '';
+    public filename: string = '';
+
+    public static $inject = ['$log'];
+    constructor($log: ng.ILogService) {
+        this.$log = $log;
+    }
+
+    /**
+     * Update media data.
+     */
+    public setMedia(data: ArrayBuffer, filename: string, caption: string) {
+        this.$log.debug(this.logTag, 'Media data updated');
+        this.data = data;
+        this.filename = filename;
+        this.caption = caption;
+        this.evtMediaChanged.post(data !== null);
+    }
+
+    /**
+     * Clear media data.
+     */
+    public clearMedia() {
+        this.$log.debug(this.logTag, 'Media data cleared');
+        this.data = null;
+        this.filename = '';
+        this.caption = '';
+        this.evtMediaChanged.post(false);
+    }
+
+}