# viewer **Repository Path**: cjbgitee/viewer ## Basic Information - **Project Name**: viewer - **Description**: viewerjs学习文档及代码 - **Primary Language**: JavaScript - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 1 - **Created**: 2021-05-27 - **Last Updated**: 2024-11-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 导读:内容简介 [viewerjs](https://github.com/fengyuanchen/viewerjs)是一款强大的图形查看器,本文是对其源码进行解读和学习 viewerjs可以学习搭建一个框架的设计流程,提升自身的编程能力 学习本项目需要熟练掌握html、js、css,了解nodejs、npm、es6、scss # 一 搭建项目 本项目使用npm管理相关依赖,因此初始化项目前需要安装nodejs环境 使用命令行工具cmd创建文件夹viewer作为项目根目录,参数使用默认回车即可 ```shell D:\> mkdir viewer D:\> cd viewer D:\viewer> npm init ... package name: (viewer) version: (1.0.0) description: entry point: (index.js) test command: git repository: keywords: author: license: (ISC) ``` 项目初始化以后会生成package.json ```json # 注:以下开始 -xxx 为注释信息 { "name": "viewer", -项目名称 "version": "1.0.0", -项目版本 "description": "", -项目描述 "main": "index.js", -入口文件 "scripts": { -运行命令 "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", -作者名称 "license": "ISC" -项目协议 } ``` > .editorconfig 定义文件格式 ```yaml # 根目录,往下匹配文件 root = true # 匹配全部文件 [*] # 设置字符集 charset = utf-8 # 设置换行符 end_of_line = lf # 设置缩进空格数 indent_size = 4 # 设置缩进风格 space/tab indent_style = space # 设置文件结尾插入空行 insert_final_newline = true # 设置删除一行中的前后空格 trim_trailing_whitespace = true ``` # 二 测试环境 本项目使用以下测试库,编写对应测试案例,可以对项目功能进行自动化测试,提升开发效率: [karma](https://www.npmjs.com/package/karma)提供多种浏览器运行环境,使用插件可以在控制台查看对应测试结果 karma以[mocha](https://mochajs.org/)测试库和[chai](https://www.chaijs.com/)断言库作为插件,搭建测试环境 ## 1.开发依赖 ```shell D:\viewer> cnpm install --save-dev karma mocha chai karma-mocha karma-chai karma-mocha-reporter karma-chrome-launcher D:\viewer> npx karma ``` 安装成功以后package.json中添加devDependencies,可以使用npx命令查看依赖包参数信息 ```json { ... "devDependencies": { -开发依赖 "chai": "^4.3.4", "karma": "^6.3.2", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^3.1.0", "karma-mocha": "^2.0.1", "karma-mocha-reporter": "^2.2.5", "mocha": "^8.4.0" } ... } ``` ## 2.初始化karma ```shell D:\viewer> karma init ... # 测试框架 Which testing framework do you want to use ? > mocha # 是否使用Require.js Do you want to use Require.js ? > no # 是否自动捕获浏览器 Do you want to capture any browsers automatically ? > Chrome > # 测试文件的位置 What is the location of your source and test files ? > ./tests/*.spec.js > # 是否应排除以前模式包含的任何文件 Should any of the files included by the previous patterns be excluded ? > node_modules > # 你想让Karma监视所有的文件并运行变更测试吗 Do you want Karma to watch all the files and run the tests on change ? > yes ... ``` 生成后的配置文件karma.conf.js ```js module.exports = function (config) { config.set({ basePath: '', -项目路径 frameworks: ['mocha'], -测试框架,添加chai files: ['./tests/*.spec.js'], -文件位置 exclude: ['node_modules'], -排除文件 preprocessors: {}, -预处理器 reporters: ['progress'], -测试报告,改为mocha port: 9876, -使用默认值 colors: true, -使用默认值 logLevel: config.LOG_INFO, -使用默认值 autoWatch: true, -自动监测代码 browsers: ['Chrome'], -使用浏览器 singleRun: false, concurrency: Infinity }) } ``` ## 3.测试案例 本小节创建一个测试案例,启动测试环境后显示图片 > tests/help.js ```js let url = "https://fengyuanchen.github.io/viewerjs/images"; window.createContainer = function () { const container = document.createElement('div'); container.className = 'container'; document.body.appendChild(container); return container; }; window.createImage = function () { const container = window.createContainer(); const image = document.createElement('img'); image.src = `${url}/tibet-1.jpg`; container.appendChild(image); return image; }; window.createImageList = function () { const container = window.createContainer(); const list = document.createElement('ul'); list.innerHTML = (`
  • `); container.appendChild(list); return list; }; ``` > tests/image.spec.js ```js describe('image test', function () { it('image test case', function () { let image = window.createImage(); }); }); ``` > karma.conf.js ```js module.exports = function (config) { config.set({ ... files: [ './tests/help.js', -加载help.js才能引用里面的方法 './tests/*.spec.js' ] ... }) } ``` > package.json ```json { ... "scripts": { "test": "karma start" } ... } ``` > npm run test 启动测试环境 项目启动以后会自动打开Chrome浏览器,点击DEBUG查看图片 # 三 项目环境 ## 1.项目结构 ``` src/index.js/scss -入口文件 src/css/viewer.scss -css文件 src/js/viewer.js -定义框架 src/js/defaults.js -定义变量 src/js/consts.js -定义常量 src/js/utils.js -定义工具 src/js/template.js -定义模版 src/js/methods.js -定义方法 src/js/events.js -事件绑定 src/js/handlers.js -事件处理 src/js/render.js -图片渲染 tests/* -测试文件 ``` ## 2.开发依赖 项目开发中使用es6、scss: - rollup 编译 es6 为浏览器可以执行的 javascript - sass 预处理器将 scss 编译为可执行的 css - 安装 node-sass 时需要访问 github.com ```shell script D:\viewer> cnpm install --save-dev rollup rollup-plugin-babel @babel/core karma-rollup-preprocessor node-sass @metahub/karma-sass-preprocessor ``` 更新karma.conf.js ``` const babel = require('rollup-plugin-babel') module.exports = function (config) { config.set({ basePath: '', frameworks: ['mocha', 'chai'], files: [ -加载源文件及测试文件 'src/index.js', 'src/index.scss', './tests/help.js', './tests/*.spec.js' ], exclude: ['node_modules'], preprocessors: { -配置预处理器 'src/index.js': ['rollup'], 'src/index.scss': ['sass'] }, plugins: [ -加载karma插件 'karma-*', '@metahub/karma-sass-preprocessor' ], rollupPreprocessor: { -设置rollup参数 plugins: [babel()], output: { format: 'iife', name: 'Viewer', sourcemap: 'inline' } }, sassPreprocessor: { -设置sass参数 options: { sourcemap: true } }, reporters: ['mocha'], autoWatch: true, browsers: ['Chrome'], singleRun: false, concurrency: Infinity }) } ``` 为项目添加 js 检查工具 eslint 和 css 检查工具 stylelint ``` D:\viewer> cnpm install --save-dev eslint stylelint stylelint-config-standard ``` 初始化 eslint 配置文件 (.eslintrc.js) ``` D:\viewer> eslint --init √ How would you like to use ESLint? · problems √ What type of modules does your project use? · esm √ Which framework does your project use? · none √ Does your project use TypeScript? · No / Yes √ Where does your code run? · browser, node √ What format do you want your config file to be in? · JavaScript Successfully created .eslintrc file in D:\viewer ``` 也可以直接创建 .eslintrc ```json { "env": { "browser": true, "es2021": true, "node": true }, "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 12, "sourceType": "module" }, "rules": { "no-param-reassign": "off", "no-restricted-properties": "off", "no-unused-vars": "off" -关闭变量未使用 }, "overrides": [ -设置测试文件规则 { "files": "tests/**/*.spec.js", "env": { "mocha": true }, "rules": { "no-undef": "off" -关闭变量未定义 } } ] } ``` 创建stylelint配置文件.stylelintrc ```json { "extends": "stylelint-config-standard" } ``` package.json添加测试命令 ```json { ... "scripts": { "test": "karma start", "lint": "npm run lint:js && npm run lint:css", "lint:js": "eslint src tests *.js --fix", "lint:css": "stylelint src/**/*.{css,scss,html} --fix" }, ... } // 执行命令 // npm run lint // npm run lint:js // npm run lint:css ``` ## 3.申明文件 ​ [申明文件](https://www.tslang.cn/docs/handbook/declaration-files/introduction.html)可以预定义方法和类型,避免工具中出现不存在的提示 > types/index.d.ts ```typescript declare class Viewer { constructor(element: Element); } declare module 'viewer' { export default Viewer; } ``` > package.json ```json { ... "main": "index.js", "types": "types/index.d.ts", -引用申明文件 ... } ``` ## 4.调用Viewer 本小节使用es6定义Viewer,并在测试Case中调用Viewer,启动测试后查看打印信息 > src/js/viewer.js ```js // 定义Viewer class Viewer { // 默认构造器 constructor(element) { console.log('viewer', 'constructor'); this.init(); } // 初始化方法 init() { console.log('viewer', 'init'); } } // 对外暴露Viewer export default Viewer; ``` > src/index.js ```js // 入口文件引用Viewer import Viewer from "./js/viewer"; export default Viewer; ``` > tests/image.spec.js ```js // 编写测试案例 describe('image test', function () { it('image test case', function () { let image = window.createImage(); let viewer = new Viewer(image); }); }); ``` cmd 中 运行 `npm run test` 命令,在控制台可以看到测试输出结果 在测试Chrome浏览器中F12调用控制台,在Console中也可以看到结果 # 四 项目开发 ## 1.Event > 元素添加事件 - 元素上添加 ```html function show() { alert('点击'); } ``` - document添加 ```html document.getElementById('add').onclick = function () { alert('添加'); }; ``` - addEventListener添加 element.addEventListener(type, listener, options) ```html document.getElementById('ele').addEventListener('click', function () { alert('元素'); }); ``` listener默认接受参数为event,使用对象解构可以从事件中获取到点击的对象target ```js element.addEventListener('click', function (event) { console.log(event); }); // 解构对象event的target属性: const {target}=event 等价于 const target=event.target element.addEventListener('click', function ({ target }) { console.log(target); }); // function匿名函数可以使用箭头函数: (param) => {...} 等价于 fucntion (param) {...} element.addEventListener('click', ({ target }) => { console.log(target); }); // element.dispatchEvent(event)可以触发元素事件,默认返回true // element的事件可取消且调用event.preventDefault()后调用dispatchEvent返回false ``` ## 2.Viewer click 本小节对事件进行封装,且对元素添加点击事件,启动测试后查看点击后的打印结果 > src/js/utils.js ```js // 对addEventListener进行封装 export function addListener(element, type, listener, options = {}) { element.addEventListener(type, listener, options); } ``` > src/js/methods.js ```js // 定义方法 export default { show() { console.log('method', 'show'); return this; }, view() { console.log('method', 'view'); return this; } } ``` > types/index.d.ts ```typescript ... declare class Viewer { constructor(element: Element); show(): Viewer; view(): Viewer; } ... ``` > src/js/viewer.js ```js // 引用listener,methods import {addListener} from "./utils"; import methods from "./methods"; class Viewer { constructor(element) { console.log('viewer', 'constructor'); this.element = element; this.init(); } init() { console.log('viewer', 'init'); const {element} = this; // 为element添加点击事件 addListener(element, 'click', ({target}) => { if (target.tagName.toLowerCase() === 'img') { // 点击图片时调用view方法 this.view(); } }); } } // 将methods绑定为Viewer的属性 Object.assign(Viewer.prototype, methods); export default Viewer; ``` ​ 以上流程思路如下: - 封装listener事件 - 定义方法view() - init()中添加点击事件 - 点击对象为图片时调用view() `npm run test` 运行后点击图片,可以在浏览器控制台查看点击后的输出结果 ## 3.Container 本小节创建加载图片的容器,在点击图片时显示该容器 > src/js/utils.js ```js ... // 添加元素Class属性 export function addClass(element, value) { if (element.classList) { element.classList.add(value) return; } const className = element.className.trim(); if (!className) { element.className = value; } else { element.className = `${className} ${value}`; } } ``` > src/js/template.js ```js // 容器模版 export default (`
    `) ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener} from "./utils"; import methods from "./methods"; class Viewer { constructor(element) { console.log('viewer', 'constructor'); this.element = element; this.isShown = false; -标记状态 this.ready = false; -标记状态 this.init(); } init() { console.log('viewer', 'init'); const {element} = this; const {ownerDocument} = element; const body = ownerDocument.body || ownerDocument.documentElement; this.body = body; // 获取页面滚动条宽度和右边距 this.scrollbarWidth = window.innerWidth - ownerDocument.documentElement.clientWidth; this.initialBodyPaddingRight = window.getComputedStyle(body).paddingRight; addListener(element, 'click', ({target}) => { if (target.tagName.toLowerCase() === 'img') { this.view(); } }); } // ready container build() { // 获取element const {element} = this; // 创建templat const template = document.createElement('div'); // 获取容器模版 template.innerHTML = TEMPLATE; // 获取容器中div const viewer = template.querySelector('.viewer-container'); this.viewer = viewer; addClass(viewer, 'viewer-fixed'); // 为容器添加背景 addClass(viewer, 'viewer-backdrop'); // 将容器置为顶层 viewer.style.zIndex = '2021'; // 获取页面body let container = element.ownerDocument.querySelector('body'); // 追加容器到body container.appendChild(viewer); // 设置状态 this.ready = true; } } Object.assign(Viewer.prototype, methods); export default Viewer; ``` > src/css/viewer.scss ```scss // 除了添加div(container),也需要为div设置css样式 html, body { margin: 0; padding: 0; } .viewer { &-container { -容器样式 top: 0; left: 0; right: 0; bottom: 0; direction: ltr; font-size: 0; line-height: 0; overflow: hidden; position: absolute; } &-fixed { -填充界面 position: fixed; } &-open { -隐藏滚动条 overflow: hidden; } &-backdrop { -容器背景色 background-color: rgba(0, 0, 0, 0.5); } } ``` > src/index.scss ```scss @import "./css/viewer.scss"; ``` > src/js/methods.js ```js // 在前面已经为元素添加点击事件,点击图片时触发view方法 import {addClass} from "./utils"; export default { show() { console.log('method', 'show'); // 容器未添加调用build() if (!this.ready) { this.build(); } // 隐藏滚动条 this.open(); // 标记显示状态 this.isShown = true; return this; }, view() { console.log('method', 'view'); // 容器未显示调用show() if (!this.isShown) { this.show(); } return this; }, open() { const {body} = this; // 添加样式,隐藏页面滚动条 addClass(body, 'viewer-open'); body.style.paddingRight = `${this.scrollbarWidth + (parseFloat(this.initialBodyPaddingRight) || 0)}px`; } } ``` 启动测试环境后点击图片,Viewer会加载container并设置背景色 ## 4.Close 本小节为Container添加关闭按钮 ### 4.1添加按钮 - container中增加关闭按钮div,调整div样式 - div添加伪元素.viewer-close::before > src/js/template.js ```js export default (`
    `) ``` > src/js/viewer.js ```js ... class Viewer { ... build() { ... const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); this.viewer = viewer; this.button = button; // 添加样式 addClass(button, 'viewer-close'); addClass(viewer, 'viewer-fixed'); ... } } ... ``` > src/css/viewer.scss ```scss ... .viewer { &-button { -调整div样式 background-color: rgba(0, 0, 0, 0.5); width: 80px; -宽度 height: 80px; -高度 position: absolute; -绝对定位 border-radius: 50%; -调为圆形 top: -40px; -向上移动 right: -40px; -向右移动 cursor: pointer; -鼠标样式 overflow: hidden; -隐藏滚动条 &:focus, &:hover { -状态颜色 background-color: rgba(0, 0, 0, 0.8); } &::before { -内部伪元素位置 left: 15px; bottom: 15px; position: absolute; } } &-close { &::before { -伪元素背景图 background-image: url('images/icons.png'); background-repeat: no-repeat; -是否重复 background-size: 280px; -背景图大小 color: transparent; display: block; font-size: 0; line-height: 0; height: 20px; -伪元素高度 width: 20px; -伪元素宽度 } } &-close::before { -调整伪元素背景图位置 background-position: -260px 0; -背景图位置 content: 'Close'; } } ``` > karma.conf.js ```js // 对图片所在文件夹进行设置 module.exports = function (config) { config.set({ ... files: [ 'src/index.js', 'src/index.scss', './tests/help.js', './tests/*.spec.js', { -排除images文件夹 pattern: '*/images/*', included: false } ] ... }) } ``` > 设计思路 - 用绝对位置调整button位置至右侧 - 用border-radius将div调整为圆形 - 向上向右移动宽高的一半,保留四分之一 - 伪元素背景图设置为包含多个图标的图片icons.png - 调整伪元素及其背景图片大小位置 ### 4.2点击事件 本小节为关闭按钮添加点击事件,获取按钮 data-viewer-action 属性 > src/js/utils.js ```js // 获取小写字母加数字,大写字母 const REGEXP_HYPHENATE = /([a-z\d])([A-Z])/g; // 字符串从驼峰式(camelCase)转为短横线式(kebab-case) export function hyphenate(value) { return value.replace(REGEXP_HYPHENATE, '$1-$2').toLowerCase(); } // 获取元素属性 export function getData(element, name) { if (element.dataset) { return element.dataset[name]; } return element.getAttribute(`data-${hyphenate(name)}`); } ``` > src/js/handlers.js ```js import {getData} from "./utils"; export default { click(event) { -点击事件处理函数 const {target} = event console.log(getData(target, 'viewerAction')); } } ``` > src/js/events.js ```js import {addListener} from "./utils"; export default { bind() { // 绑定处理函数handlers.click到button点击事件上 addListener(this.button, 'click', this.click.bind(this)) } } ``` > src/js/methods.js ```js import {addClass} from "./utils"; export default { show() { console.log('method', 'show'); if (!this.ready) { this.build(); } this.open(); // 显示container时触发bind this.bind(); this.isShown = true; return this; }, ... } ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener} from "./utils"; import methods from "./methods"; import events from "./events"; import handlers from "./handlers"; ... // 将事件绑定及处理函数设置为Viewer的属性 Object.assign(Viewer.prototype, events, handlers, methods); export default Viewer; ``` 启动测试环境点击关闭按钮时,打印模版中关闭按钮元素的 data-viewer-action 属性:mix ### 4.3Close函数 > src/js/utils.js ```js // 移除元素class,改变样式 export function removeClass(element, value) { if (element.classList) { element.classList.remove(value) return } if (element.className.indexOf(value) >= 0) { element.className = element.className.replace(value, '') } } ``` > src/css/viewer.js ```scss .viewer { ... &-hide { display: none; } &-backdrop { background-color: rgba(0, 0, 0, 0.5); } } ``` > src/js/viewer.js ```js ... class Viewer { ... build() { const {element} = this; const template = document.createElement('div'); template.innerHTML = TEMPLATE; const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); this.viewer = viewer; this.button = button; addClass(button, 'viewer-close'); addClass(viewer, 'viewer-fixed'); addClass(viewer, 'viewer-backdrop'); // 初始化Viewer时默认不显示 addClass(viewer, 'viewer-hide'); viewer.style.zIndex = '2021'; let container = element.ownerDocument.querySelector('body'); container.appendChild(viewer); this.ready = true; } } Object.assign(Viewer.prototype, events, handlers, methods); export default Viewer; ``` > src/js/methods.js ```js import {addClass, removeClass} from "./utils"; export default { show() { console.log('method', 'show'); if (!this.ready) { this.build(); } this.open(); this.bind(); // 点击图片时显示Viewer removeClass(this.viewer, 'viewer-hide') this.isShown = true; return this; }, hide() { console.log('method', 'hide'); // 点击关闭时隐藏Viewer addClass(this.viewer, 'viewer-hide') this.close() this.isShown = false return this; }, ... close() { // 修改页面滚动条和右边距 const {body} = this; removeClass(body, 'viewer-open'); body.style.paddingRight = this.initialBodyPaddingRight; } } ``` > src/js/handlers.js ```js import {getData} from "./utils"; export default { click(event) { const {target} = event const action = getData(target, 'viewerAction') switch (action) { case 'mix': // 点击关闭按钮时调用hide this.hide() } } } ``` 启动测试环境,测试关闭按钮 ## 5.Image ### 5.1显示图片 本小节在容器中添加一画布,点击原图片显示Container时显示图片 > src/js/template.js ```js // 添加canvas export default (`
    `) ``` > src/js/utils.js ```js export const isNaN = Number.isNaN || Window.isNaN; export function isNumber(value) { return typeof value === 'number' && !isNaN(value) } export function isObject(value) { return typeof value === 'object' && value !== null } export function isFunction(value) { return typeof value === 'function' } export function forEach(data, callback) { if (data && isFunction(callback)) { // 遍历数组对象 if (Array.isArray(data) || isNumber(data.length)) { const {length} = data for (let i = 0; i < length; i++) { // 执行call时,function内部this变量指向call第一个参数data // 其余参数根据function实际参数进行接受 if (callback.call(data, data[i], i, data) === false) { break } } } else if (isObject(data)) { // 遍历普通对象 Object.keys(data).forEach((key) => { callback.call(data, data[key], key, data) }) } } return data } // 元素设置属性 export function setData(element, name, data) { if (element.dataset) { element.dataset[name] = data } else { element.setAttribute(`data-${hyphenate(name)}`, data) } } ``` > src/js/viewer.js ```js import TEMPLATE from './template' import {addClass, addListener, forEach, setData} from "./utils"; import methods from "./methods"; import events from "./events"; import handlers from "./handlers"; import render from "./render"; class Viewer { ... init() { const {element} = this; const isImg = element.tagName.toLowerCase() === 'img'; const images = []; forEach(isImg ? [element] : element.querySelector('img'), (image) => { images.push(image); }) this.isImg = isImg; this.length = images.length; this.images = images; ... } build() { ... const viewer = template.querySelector('.viewer-container'); const button = template.querySelector('.viewer-button'); const canvas = template.querySelector('.viewer-canvas'); this.viewer = viewer; this.button = button; this.canvas = canvas; this.list = template.querySelector('.viewer-list'); // 设置dataViewerAction属性 setData(canvas, 'viewerAction', 'hide'); ... } } // 添加render Object.assign(Viewer.prototype, render, events, handlers, methods); export default Viewer; ``` > src/js/render.js ```js import {forEach} from "./utils"; export default { render() { this.initContainer() this.initViewer() this.initList() }, initContainer() { // 设置container宽高度 this.containerData = { width: window.innerWidth, height: window.innerHeight } }, initViewer() { // 设置Viewer宽高度 this.viewerData = Object.assign({}, this.containerData) }, initList() { // 遍历图片 const {element, list} = this const items = [] forEach(this.images, (image, index) => { const {src} = image const item = document.createElement('li') const img = document.createElement('img') img.setAttribute('data-index', index) img.setAttribute('data-original-url', src) img.setAttribute('data-viewer-action', 'viewer') img.setAttribute('role', 'button') item.appendChild(img) list.appendChild(item) items.push(item) }) this.items = items } } ``` > src/js/methods.js ```js import {addClass, getData, removeClass} from "./utils"; export default { show() { if (!this.ready) { this.build(); } this.open(); this.bind(); // 调用render this.render(); removeClass(this.viewer, 'viewer-hide'); this.isShown = true; return this; }, ... view() { if (!this.isShown) { this.show(); } const {element, canvas} = this; this.index = 0; const item = this.items[this.index]; const img = item.querySelector('img'); const image = document.createElement('img'); image.src = getData(img, 'originalUrl'); image.alt = img.getAttribute('alt'); this.image = image; canvas.innerHTML = ''; canvas.appendChild(image); return this; }, ... } ``` > src/css/viewer.scss ```scss .viewer { ... &-container {...} &-canvas { -设置canvas及内部图片样式 position: absolute; overflow: hidden; left: 0; right: 0; top: 0; bottom: 0; & > img { height: auto; margin: 15px auto; max-width: 90% !important; width: auto; } } ... } ``` ### 5.2加载动画 本小节在显示图片前显示加载动画 > src/css/viewer.scss ```scss .viewer { ... &-invisible { visibility: hidden; } @keyframes viewer-spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } &-loading { &::after { animation: viewer-spinner 1s linear infinite; display: inline-block; position: absolute; content: ''; height: 40px; width: 40px; top: 50%; left: 50%; z-index: 1; border: 4px solid rgba(255, 255, 255, 0.1); border-left-color: rgba(255, 255, 255, 0.5); border-radius: 50%; margin-left: -20px; margin-top: -20px; } } } ``` > src/js/methods.js ```js export default { ... view() { ... this.image = image this.index = 0 // 隐藏图片 addClass(image, 'viewer-invisible') // 添加加载动画 addClass(canvas, 'viewer-loading') canvas.innerHTML = '' canvas.appendChild(image) return this; }, ... } ``` ### 5.3自适应 本小节根据Viewer大小,自适应调整图片大小及位置 > 设计思路 Viewer-transition 过渡动画完成时触发 transitionend 事件 shown Items-image 加载完成时触发 load 事件 loadImage 保持图片宽高比调整图片大小及位置 > 计算图片宽度和高度(保持宽高比) 1. 调整图片高度到 Viewer.height , 如果图片宽度大于 Viewer.width , 按照2号方案执行 2. 调整图片宽度到 Viewer.width , 设置图片高度 ```js // 0.保持宽高比 image.width/image.height = width/height (调整后的宽高度) // 1.按高度调整 height = Viewer.height width = Viewer.height*(image.width/image.height) = Viewer.height*aspectRatio // 2.按宽度调整 width = Viewer.width height = Viewer.width/(image.width/image.height) = Viewer.width/aspectRatio ``` > [load](https://www.w3school.com.cn/jquery/event_load.asp)事件 ```js // 任何带有 URL 的元素(比如图像、脚本、框架、内联框架)已加载时触发 load 事件 let img = document.createElement('img'); img.src = 'https://fengyuanchen.github.io/viewerjs/images/tibet-1.jpg' img.addEventListener('load', (event) => { console.log(event) }) document.body.appendChild(img) ``` > transitionend ```html test transition
    ``` > src/js/utils.js ```js // 添加事件 export function addListener(element, type, listener, options = {}) { console.log('utils', 'addListener'); element.addEventListener(type, listener, options); } // 移除事件 export function removeListener(element, type, listener, options = {}) { console.log('utils', 'removeListener'); element.removeEventListener(type, listener, options) } // 触发事件,常用于触发自定义事件 export function dispatchEvent(element, type, data) { console.log('utils', 'dispatchEvent'); let event; if (isFunction(Event) && isFunction(CustomEvent)) { event = new CustomEvent(type, { detail: data, bubbles: true, cancelable: true }) } else { event = document.createEvent('CustomEvent') event.initCustomEvent(type, true, true, data) } return element.dispatchEvent(event) } export function toggleClass(element, value, added) { if (!value) return if (isNumber(element.length)) { forEach(element, (elem) => { toggleClass(elem, value, added) }) return; } if (added) { addClass(element, value) } else { removeClass(element, value) } } export function getImageNaturalSizes(image, callback) { const newImage = document.createElement('img') if (image.naturalWidth) { callback(image.naturalWidth, image.naturalHeight) return newImage; } const body = document.body || document.documentElement newImage.onload = () => { callback(newImage.width, newImage.height) } newImage.src = image.src newImage.style.cssText = (` top:0;left:0;opacity:0; z-index:-1;position:absolute; max-height:none!important; max-width:none!important; min-height:0!important; min-width:0!important; `) body.appendChild(newImage) return newImage } export function getTransforms({rotate, scaleX, scaleY, translateX, translateY}) { const values = [] if (isNumber(translateX) && translateX !== 0) { values.push(`translateX(${translateX}px)`) } if (isNumber(translateY) && translateY !== 0) { values.push(`translateY(${translateY}px)`) } if (isNumber(rotate) && rotate !== 0) { values.push(`rotate(${rotate}deg`) } if (isNumber(scaleX) && scaleX !== 0) { values.push(`scaleX(${scaleX})`) } if (isNumber(scaleY) && scaleY !== 0) { values.push(`scaleY(${scaleY})`) } const transform = values.length ? values.join(' ') : 'none' return {WebkitTransform: transform, msTransform: transform, transform} } const REGEXP_SUFFIX = /^(?:width|height|left|top|marginLeft|marginTop)$/; export function setStyle(element, styles) { const {style} = element; forEach(styles, (value, property) => { if (REGEXP_SUFFIX.test(property) && isNumber(value)) { value += 'px'; } style[property] = value; }); } ``` > src/js/viewer.js ```js class Viewer { constructor(element) { this.element = element this.fading = false this.hiding = false this.imageData = {} this.isImg = false this.length = 0 this.isShown = false this.ready = false this.showing = false this.timeout = false this.viewed = false this.viewing = false this.zooming = false this.init() } ... build() { ... this.canvas = canvas this.footer = template.querySelector('.viewer-footer') this.list = template.querySelector('.viewer-list') addClass(viewer, 'viewer-fade') ... } } ``` src/css/viewer.scss ```scss .viewer { ... &-footer { left: 0; right: 0; bottom: 0; overflow: hidden; position: absolute; text-align: center; } &-navbar { background-color: rgba(0, 0, 0, 0.5); overflow: hidden; } &-list { box-sizing: content-box; height: 50px; margin: 0; overflow: hidden; padding: 1px 0; & > li { color: transparent; cursor: pointer; float: left; font-size: 0; height: 50px; width: 30px; line-height: 0; overflow: hidden; opacity: 0.5; transition: opacity 0.15s; &:hover { opacity: 0.75; } } } ... &-invisible { visibility: hidden; } &-fade { opacity: 0; } &-in { opacity: 1; } &-transition { transition: all 0.3s; } ... } ``` > src/js/handlers.js ```js import {getData, getImageNaturalSizes, getTransforms, removeClass, setStyle} from "./utils"; export default { click(event) { const {target} = event const action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() } }, load() { // image加载完时执行 const {image, viewerData} = this removeClass(image, 'viewer-invisible') removeClass(this.canvas, 'viewer-loading') image.style.cssText = (` height:0;width:0; position:absolute; max-width:none!important; margin-left:${viewerData.width / 2}px; margin-top:${viewerData.height / 2}px; `) this.initImage(() => { this.renderImage(() => { this.viewed = true this.viewing = false }) }) }, loadImage(event) { // list-img加载完时执行 const image = event.target const parent = image.parentNode const parentWidth = parent.offsetWidth || 30 const parentHeight = parent.offsetHeight || 50 const filled = !!getData(image, 'filled') getImageNaturalSizes(image, (naturalWidth, naturalHeight) => { const aspectRatio = naturalWidth / naturalHeight let width = parentWidth let height = parentHeight if (parentHeight * aspectRatio > parentWidth) { if (filled) { width = parentHeight * aspectRatio } else { height = parentWidth / aspectRatio } } else if (filled) { height = parentWidth / aspectRatio } else { width = parentHeight * aspectRatio } setStyle(image, Object.assign({width, height}, getTransforms({ translateX: (parentWidth - width) / 2, translateY: (parentHeight - height) / 2 }))) }) } } ``` > src/js/render.js ```js import {addListener, forEach, getImageNaturalSizes, getTransforms, removeClass, setData, setStyle} from "./utils"; export default { ... initList() { ... this.items = items forEach(items, (item) => { const image = item.firstElementChild setData(image, 'filled', true) // image加载完时触发load addListener(image, 'load', (event) => { this.loadImage(event) }, {once: true}) }) }, renderList() { console.log('render', 'renderList') const width = this.items[0].offsetWidth || 30 const outerWidth = width + 1 // 设置viewer-list样式 setStyle(this.list, Object.assign({ width: outerWidth * this.length }, getTransforms({ translateX: ((this.viewerData.width - width) / 2) - outerWidth }))) }, resetList() { console.log('render', 'resetList') const {list} = this removeClass(list, 'viewer-transition') setStyle(list, getTransforms({translateX: 0})) }, initImage(done) { console.log('render', 'initImage') const {image, viewerData} = this const footerHeight = this.footer.offsetHeight const viewerWidth = viewerData.width const viewerHeight = Math.max(viewerData.height - footerHeight, footerHeight) const oldImageData = this.imageData || {} let sizingImage sizingImage = getImageNaturalSizes(image, (naturalWidth, naturalHeight) => { const aspectRatio = naturalWidth / naturalHeight let width = viewerWidth let height = viewerHeight this.imageInitialzing = false if (viewerHeight * aspectRatio > viewerWidth) { // 按宽度调整 height = viewerWidth / aspectRatio } else { // 按高度调整 width = viewerHeight * aspectRatio } width = Math.min(width * 0.9, naturalWidth) height = Math.min(height * 0.9, naturalHeight) const imageData = { naturalWidth, naturalHeight, aspectRatio, ratio: width / naturalWidth, width, height, left: (viewerWidth - width) / 2, top: (viewerHeight - height) / 2 } const initialImageData = Object.assign({}, imageData) this.imageData = imageData this.initialImageData = initialImageData if (done) done() }) }, renderImage(done) { console.log('render', 'renderImage') const {image, imageData} = this setStyle(image, Object.assign({ width: imageData.width, height: imageData.height, marginLeft: imageData.left, marginTop: imageData.top }, getTransforms(imageData))) if (done) { if (this.viewing || this.zooming) { addListener(image, 'transitionend', (event) => { this.imageRendering = false done() }, {once: true}) } else { done() } } }, resetImage() { console.log('render', 'resetImage') if (this.viewing || this.viewed) { const {image} = this if (this.viewing) { this.viewing.abort() } image.parentNode.removeChild(image) this.image = null } } } ``` > src/js/methods.js ```js import {addClass, addListener, getData, removeClass} from "./utils"; export default { show() { console.log('method', 'show'); if (this.showing || this.isShown) { return this } if (!this.ready) { this.build() if (this.ready) this.show() return this } this.showing = true this.open() removeClass(this.viewer, 'viewer-hide') const {viewer} = this const shown = this.shown.bind(this) // 添加动画 addClass(viewer, 'viewer-transition') // 强制css3执行动画 viewer.initialOffsetWidth = viewer.offsetWidth addListener(viewer, 'transitionend', shown, {once: true}) addClass(viewer, 'viewer-in') return this; }, hide() { console.log('method', 'hide'); if (this.hiding || !(this.isShown || this.showing)) { return this } this.hiding = true removeClass(this.viewer, 'viewer-in') this.hidden() return this; }, view() { console.log('method', 'view'); if (!this.isShown) { return this.show() } const {element, canvas} = this this.index = 0 const item = this.items[this.index] const img = item.querySelector('img') const image = document.createElement('img') image.src = getData(img, 'originalUrl') image.alt = img.getAttribute('alt') this.image = image addClass(image, 'viewer-invisible') addClass(canvas, 'viewer-loading') canvas.innerHTML = '' canvas.appendChild(image) this.renderList() // image加载完触发load事件 addListener(image, 'load', this.load.bind(this), {once: true}) // 定时清除invisible if (this.timeout) clearTimeout(this.timeout) this.timeout = setTimeout(() => { removeClass(image, 'viewer-invisible') this.timeout = false }, 1000) return this; }, ... shown() { console.log('method', 'shown') this.fulled = true this.isShown = true this.render() this.bind() this.showing = false if (this.ready && this.isShown) { // 动画结束调用view this.view() } }, hidden() { console.log('method', 'hidden') this.fulled = false this.viewed = false this.isShown = false this.close() this.unbind() // 隐藏viewer addClass(this.viewer, 'viewer-hide') // 初始化list this.resetList() this.resetImage() this.hiding = false } } ``` ## 6.Toolbar 本节为Viewer添加工具栏及其事件 ### 6.1工具栏 > src/js/costants.js ```js // 定义工具栏按钮名称常量 export const BUTTONS = [ 'zoom-in', 'zoom-out', 'one-to-one', 'reset', 'prev', 'play', 'next', 'rotate-left', 'rotate-right', 'flip-horizontal', 'flip-vertical' ] ``` > src/js/template.js ```js export default (`
    `) ``` > src/css/viewer.scss ```scss .viewer { // 工具栏图标样式 &-zoom-in, &-zoom-out, &-one-to-one, &-reset, &-prev, &-play, &-next, &-rotate-left, &-rotate-right, &-flip-horizontal, &-flip-vertical, &-close { &::before { background-image: url('images/icons.png'); background-repeat: no-repeat; background-size: 280px; color: transparent; display: block; font-size: 0; height: 20px; line-height: 0; width: 20px; } } &-zoom-in::before { background-position: 0 0; content: 'Zoom In'; } &-zoom-out::before { background-position: -20px 0; content: 'Zoom Out'; } &-one-to-one::before { background-position: -40px 0; content: 'One to One'; } &-reset::before { background-position: -60px 0; content: 'Reset'; } &-prev::before { background-position: -80px 0; content: 'Previous'; } &-play::before { background-position: -100px 0; content: 'Play'; } &-next::before { background-position: -120px 0; content: 'Next'; } &-rotate-left::before { background-position: -140px 0; content: 'Rotate Left'; } &-rotate-right::before { background-position: -160px 0; content: 'Rotate Right'; } &-flip-horizontal::before { background-position: -180px 0; content: 'Flip Horizontal'; } &-flip-vertical::before { background-position: -200px 0; content: 'Flip Vertical'; } &-close::before { background-position: -260px 0; content: 'Close'; } ... &-footer { left: 0; right: 0; bottom: 0; overflow: hidden; position: absolute; text-align: center; } &-title { color: #ccc; display: inline-block; font-size: 12px; line-height: 1; margin: 0 5% 5px; max-width: 90%; opacity: 0.8; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.15s; white-space: nowrap; &:hover { opacity: 1; } } // 工具栏样式 &-toolbar { & > ul { display: inline-block; margin: 0 auto 5px; overflow: hidden; padding: 3px 0; & > li { background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; cursor: pointer; float: left; width: 24px; height: 24px; overflow: hidden; transition: background-color 0.15s; &:hover { background-color: rgba(0, 0, 0, 0.8); } &::before { margin: 2px; } & + li { margin-left: 1px; } } } } ... } ``` > src/js/viewer.js ```js class Viewer { ... build() { const {element} = this; const template = document.createElement('div'); template.innerHTML = TEMPLATE; const viewer = template.querySelector('.viewer-container'); const title = template.querySelector('.viewer-title') const toolbar = template.querySelector('.viewer-toolbar') const navbar = template.querySelector('.viewer-navbar') const button = template.querySelector('.viewer-button'); const canvas = template.querySelector('.viewer-canvas'); this.viewer = viewer; this.title = title this.toolbar = toolbar this.navbar = navbar this.button = button; this.canvas = canvas; this.footer = viewer.querySelector('.viewer-footer') this.list = template.querySelector('.viewer-list'); addClass(viewer, 'viewer-backdrop'); setData(canvas, 'viewerAction', 'hide'); const list = document.createElement('ul') forEach(BUTTONS, (value, index) => { const item = document.createElement('li') item.setAttribute('role', 'button') addClass(item, `viewer-${value}`) setData(item, 'viewerAction', value) list.appendChild(item) }) toolbar.appendChild(list) ... } } ``` > src/js/handlers.js ```js export default { click(event) { console.log('handlers', 'click') const {target} = event let action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() break case 'zoom-in': console.log('zoom-in') break case 'zoom-out': console.log('zoom-out'); break case 'one-to-one': console.log('one-to-one'); break case 'reset': console.log('reset'); break case 'prev': console.log('prev'); break case 'play': console.log('play'); break case 'next': console.log('next'); break case 'rotate-left': console.log('rotate-left'); break case 'rotate-right': console.log('rotate-right'); break case 'flip-horizontal': console.log('flip-horizontal'); break case 'flip-vertical': console.log('flip-vertical'); break } }, ... } ``` ### 6.2Zoom函数 > src/js/methods.js ```js export default { ... zoom(ratio, hasTooltip = false, _originalEvent = null) { const {imageData} = this ratio = Number(ratio) if (ratio < 0) { ratio = 1 / (1 - ratio) } else { ratio = 1 + ratio } this.zoomTo((imageData.width * ratio) / imageData.naturalWidth, hasTooltip, _originalEvent) return this }, zoomTo(ratio, hasTooltip = false, _originalEvent = false, _zoomable = false) { const {imageData} = this const {width, height, naturalWidth, naturalHeight} = imageData ratio = Math.max(0, ratio) if (isNumber(ratio) && this.viewed) { if (!_zoomable) { // 限制缩放最大最小比例 ratio = Math.min(Math.max(ratio, 0.01), 100) } this.zooming = true const newWidth = naturalWidth * ratio const newHeight = naturalHeight * ratio const offsetWidth = newWidth - width const offsetHeight = newHeight - height imageData.left -= offsetWidth / 2 imageData.top -= offsetHeight / 2 imageData.width = newWidth imageData.height = newHeight imageData.ratio = ratio this.renderImage(() => { this.zooming = false }) } return this }, ... } ``` > src/js/handlers.js ```js export default { click(event) { const {target} = event let action = getData(target, 'viewerAction') switch (action) { case 'mix': this.hide() break case 'zoom-in': this.zoom(0.1, true) break case 'zoom-out': this.zoom(-0.1, true) break } }, ... } ```