diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js new file mode 100644 index 0000000000000000000000000000000000000000..0799991aa40f4809da8e8dcff2608dbb405620c4 --- /dev/null +++ b/app/assets/javascripts/blob/sketch/index.js @@ -0,0 +1,73 @@ +import JSZip from 'jszip'; +import JSZipUtils from 'jszip-utils'; + +export default class SketchLoader { + constructor(container) { + this.container = container; + this.loadingIcon = this.container.querySelector('.js-loading-icon'); + + this.load(); + } + + load() { + return this.getZipFile() + .then(data => JSZip.loadAsync(data)) + .then(asyncResult => asyncResult.files['previews/preview.png'].async('uint8array')) + .then((content) => { + const url = window.URL || window.webkitURL; + const blob = new Blob([new Uint8Array(content)], { + type: 'image/png', + }); + const previewUrl = url.createObjectURL(blob); + + this.render(previewUrl); + }) + .catch(this.error.bind(this)); + } + + getZipFile() { + return new JSZip.external.Promise((resolve, reject) => { + JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } + + render(previewUrl) { + const previewLink = document.createElement('a'); + const previewImage = document.createElement('img'); + + previewLink.href = previewUrl; + previewLink.target = '_blank'; + previewImage.src = previewUrl; + previewImage.className = 'img-responsive'; + + previewLink.appendChild(previewImage); + this.container.appendChild(previewLink); + + this.removeLoadingIcon(); + } + + error() { + const errorMsg = document.createElement('p'); + + errorMsg.className = 'prepend-top-default append-bottom-default text-center'; + errorMsg.textContent = ` + Cannot show preview. For previews on sketch files, they must have the file format + introduced by Sketch version 43 and above. + `; + this.container.appendChild(errorMsg); + + this.removeLoadingIcon(); + } + + removeLoadingIcon() { + if (this.loadingIcon) { + this.loadingIcon.remove(); + } + } +} diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js new file mode 100644 index 0000000000000000000000000000000000000000..0640dd2685554f5899fee98aa03ce10c8e667492 --- /dev/null +++ b/app/assets/javascripts/blob/sketch_viewer.js @@ -0,0 +1,8 @@ +/* eslint-disable no-new */ +import SketchLoader from './sketch'; + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-sketch-viewer'); + + new SketchLoader(el); +}); diff --git a/app/models/blob.rb b/app/models/blob.rb index 95d2111a9924037aa788ad40bfde8a7938cdc9bd..1c451a3976fbf09663ce34828b12be0e6a471e7f 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -50,6 +50,10 @@ class Blob < SimpleDelegator text? && language&.name == 'Jupyter Notebook' end + def sketch? + binary? && extname.downcase.delete('.') == 'sketch' + end + def size_within_svg_limits? size <= MAXIMUM_SVG_SIZE end @@ -69,6 +73,8 @@ class Blob < SimpleDelegator 'image' elsif ipython_notebook? 'notebook' + elsif sketch? + 'sketch' elsif text? 'text' else diff --git a/app/views/projects/blob/_sketch.html.haml b/app/views/projects/blob/_sketch.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..dad9369cb2ae935ab828bad18ceca8b78e34ad68 --- /dev/null +++ b/app/views/projects/blob/_sketch.html.haml @@ -0,0 +1,7 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('sketch_viewer') + +.file-content#js-sketch-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } + .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } + = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 072ed8a3864e360e2a0cec2a4aa2bc2feefbea0f..fdba1f6541ecc4c5e6c8d50f0ce67bc76390189e 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -326,3 +326,21 @@ :why: https://github.com/domenic/opener/blob/1.4.3/LICENSE.txt :versions: [] :when: 2017-02-21 22:33:41.729629000 Z +- - :approve + - jszip + - :who: Phil Hughes + :why: https://github.com/Stuk/jszip/blob/master/LICENSE.markdown + :versions: [] + :when: 2017-04-05 10:38:46.275721000 Z +- - :approve + - jszip-utils + - :who: Phil Hughes + :why: https://github.com/Stuk/jszip-utils/blob/master/LICENSE.markdown + :versions: [] + :when: 2017-04-05 10:39:32.676232000 Z +- - :approve + - pako + - :who: Phil Hughes + :why: https://github.com/nodeca/pako/blob/master/LICENSE + :versions: [] + :when: 2017-04-05 10:43:45.897720000 Z diff --git a/config/webpack.config.js b/config/webpack.config.js index 36c09c14d567d7c0a12fa708c0865f5f797f47ab..99596ce21971371977fefbc39e666b989b4ce82f 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -37,6 +37,7 @@ var config = { monitoring: './monitoring/monitoring_bundle.js', network: './network/network_bundle.js', notebook_viewer: './blob/notebook_viewer.js', + sketch_viewer: './blob/sketch_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', snippet: './snippet/snippet_bundle.js', diff --git a/package.json b/package.json index 7b6c4556e2c4a83fb66df626351168407989f286..2c8c4e126cd23bc886156e010e528ba79deec6fe 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", "js-cookie": "^2.1.3", + "jszip": "^3.1.3", + "jszip-utils": "^0.0.2", "mousetrap": "^1.4.6", "pikaday": "^1.5.1", "raphael": "^2.2.7", diff --git a/spec/javascripts/blob/sketch/index_spec.js b/spec/javascripts/blob/sketch/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0e4431548c47e8c1aaef58653e8dd6369344458b --- /dev/null +++ b/spec/javascripts/blob/sketch/index_spec.js @@ -0,0 +1,118 @@ +/* eslint-disable no-new */ +import JSZip from 'jszip'; +import SketchLoader from '~/blob/sketch'; + +describe('Sketch viewer', () => { + const generateZipFileArrayBuffer = (zipFile, resolve, done) => { + zipFile + .generateAsync({ type: 'arrayBuffer' }) + .then((content) => { + resolve(content); + + setTimeout(() => { + done(); + }, 100); + }); + }; + + preloadFixtures('static/sketch_viewer.html.raw'); + + beforeEach(() => { + loadFixtures('static/sketch_viewer.html.raw'); + }); + + describe('with error message', () => { + beforeEach((done) => { + spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(() => new Promise((resolve, reject) => { + reject(); + + setTimeout(() => { + done(); + }); + })); + + new SketchLoader(document.getElementById('js-sketch-viewer')); + }); + + it('renders error message', () => { + expect( + document.querySelector('#js-sketch-viewer p'), + ).not.toBeNull(); + + expect( + document.querySelector('#js-sketch-viewer p').textContent.trim(), + ).toContain('Cannot show preview.'); + }); + + it('removes render the loading icon', () => { + expect( + document.querySelector('.js-loading-icon'), + ).toBeNull(); + }); + }); + + describe('success', () => { + beforeEach((done) => { + spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(() => new Promise((resolve) => { + const zipFile = new JSZip(); + zipFile.folder('previews') + .file('preview.png', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAA1JREFUeNoBAgD9/wAAAAIAAVMrnDAAAAAASUVORK5CYII=', { + base64: true, + }); + + generateZipFileArrayBuffer(zipFile, resolve, done); + })); + + new SketchLoader(document.getElementById('js-sketch-viewer')); + }); + + it('does not render error message', () => { + expect( + document.querySelector('#js-sketch-viewer p'), + ).toBeNull(); + }); + + it('removes render the loading icon', () => { + expect( + document.querySelector('.js-loading-icon'), + ).toBeNull(); + }); + + it('renders preview img', () => { + const img = document.querySelector('#js-sketch-viewer img'); + + expect(img).not.toBeNull(); + expect(img.classList.contains('img-responsive')).toBeTruthy(); + }); + + it('renders link to image', () => { + const img = document.querySelector('#js-sketch-viewer img'); + const link = document.querySelector('#js-sketch-viewer a'); + + expect(link.href).toBe(img.src); + expect(link.target).toBe('_blank'); + }); + }); + + describe('incorrect file', () => { + beforeEach((done) => { + spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(() => new Promise((resolve) => { + const zipFile = new JSZip(); + + generateZipFileArrayBuffer(zipFile, resolve, done); + })); + + new SketchLoader(document.getElementById('js-sketch-viewer')); + }); + + it('renders error message', () => { + expect( + document.querySelector('#js-sketch-viewer p'), + ).not.toBeNull(); + + expect( + document.querySelector('#js-sketch-viewer p').textContent.trim(), + ).toContain('Cannot show preview.'); + }); + }); +}); diff --git a/spec/javascripts/fixtures/sketch_viewer.html.haml b/spec/javascripts/fixtures/sketch_viewer.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f01bd00925a185d3166234047d137d14e401941c --- /dev/null +++ b/spec/javascripts/fixtures/sketch_viewer.html.haml @@ -0,0 +1,2 @@ +.file-content#js-sketch-viewer{ data: { endpoint: '/test_sketch_file.sketch' } } + .js-loading-icon diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 552229e9b0765f33ad3c7435b9f31243a7e9918e..ba110383977f8f3dc75ea845c294d20b6d121060 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -67,6 +67,20 @@ describe Blob do end end + describe '#sketch?' do + it 'is falsey with image extension' do + git_blob = Gitlab::Git::Blob.new(name: "design.png") + + expect(described_class.decorate(git_blob)).not_to be_sketch + end + + it 'is truthy with sketch extension' do + git_blob = Gitlab::Git::Blob.new(name: "design.sketch") + + expect(described_class.decorate(git_blob)).to be_sketch + end + end + describe '#video?' do it 'is falsey with image extension' do git_blob = Gitlab::Git::Blob.new(name: 'image.png') @@ -92,7 +106,8 @@ describe Blob do language: nil, lfs_pointer?: false, svg?: false, - text?: false + text?: false, + binary?: false ) described_class.decorate(double).tap do |blob| @@ -135,6 +150,11 @@ describe Blob do blob = stubbed_blob(text?: true, ipython_notebook?: true) expect(blob.to_partial_path(project)).to eq 'notebook' end + + it 'handles Sketch files' do + blob = stubbed_blob(text?: true, sketch?: true, binary?: true) + expect(blob.to_partial_path(project)).to eq 'sketch' + end end describe '#size_within_svg_limits?' do diff --git a/yarn.lock b/yarn.lock index f254668646c546d6e21e4df56e84ccc2cea88f83..48b847b84d0a7c851dfecf9a3336d2808a59ce75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1245,6 +1245,10 @@ core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" +core-js@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65" + core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -1581,6 +1585,10 @@ es6-map@^0.1.3: es6-symbol "~3.1.0" event-emitter "~0.3.4" +es6-promise@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6" + es6-promise@~4.0.3: version "4.0.5" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.0.5.tgz#7882f30adde5b240ccfa7f7d78c548330951ae42" @@ -2335,6 +2343,10 @@ ignore@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.2.tgz#1c51e1ef53bab6ddc15db4d9ac4ec139eceb3410" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -2764,6 +2776,20 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.3.6" +jszip-utils@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/jszip-utils/-/jszip-utils-0.0.2.tgz#457d5cbca60a1c2e0706e9da2b544e8e7bc50bf8" + +jszip@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.1.3.tgz#8a920403b2b1651c0fc126be90192d9080957c37" + dependencies: + core-js "~2.3.0" + es6-promise "~3.0.2" + lie "~3.1.0" + pako "~1.0.2" + readable-stream "~2.0.6" + karma-coverage-istanbul-reporter@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-0.2.0.tgz#5766263338adeb0026f7e4ac7a89a5f056c5642c" @@ -2868,6 +2894,12 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + dependencies: + immediate "~3.0.5" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -3349,6 +3381,10 @@ pako@~0.2.0: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" +pako@~1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.5.tgz#d2205dfe5b9da8af797e7c163db4d1f84e4600bc" + parse-asn1@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.0.0.tgz#35060f6d5015d37628c770f4e091a0b5a278bc23" @@ -3661,7 +3697,7 @@ readable-stream@~1.0.2: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@~2.0.0: +readable-stream@~2.0.0, readable-stream@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" dependencies: