viewerjs是一款强大的图形查看器,本文是对其源码进行解读和学习
viewerjs可以学习搭建一个框架的设计流程,提升自身的编程能力
学习本项目需要熟练掌握html、js、css,了解nodejs、npm、es6、scss
本项目使用npm管理相关依赖,因此初始化项目前需要安装nodejs环境
使用命令行工具cmd创建文件夹viewer作为项目根目录,参数使用默认回车即可
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
# 注:以下开始 -xxx 为注释信息
{
"name": "viewer", -项目名称
"version": "1.0.0", -项目版本
"description": "", -项目描述
"main": "index.js", -入口文件
"scripts": { -运行命令
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "", -作者名称
"license": "ISC" -项目协议
}
.editorconfig 定义文件格式
# 根目录,往下匹配文件
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提供多种浏览器运行环境,使用插件可以在控制台查看对应测试结果
karma以mocha测试库和chai断言库作为插件,搭建测试环境
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命令查看依赖包参数信息
{
...
"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"
}
...
}
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
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
})
}
本小节创建一个测试案例,启动测试环境后显示图片
tests/help.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 = (`
<li><img src="${url}/tibet-1.jpg"></li>
<li><img src="${url}/tibet-2.jpg"></li>
<li><img src="${url}/tibet-3.jpg"></li>
<li><img src="${url}/tibet-4.jpg"></li>
<li><img src="${url}/tibet-5.jpg"></li>
`);
container.appendChild(list);
return list;
};
tests/image.spec.js
describe('image test', function () {
it('image test case', function () {
let image = window.createImage();
});
});
karma.conf.js
module.exports = function (config) {
config.set({
...
files: [
'./tests/help.js', -加载help.js才能引用里面的方法
'./tests/*.spec.js'
]
...
})
}
package.json
{
...
"scripts": {
"test": "karma start"
}
...
}
npm run test 启动测试环境
项目启动以后会自动打开Chrome浏览器,点击DEBUG查看图片
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/* -测试文件
项目开发中使用es6、scss:
rollup 编译 es6 为浏览器可以执行的 javascript
sass 预处理器将 scss 编译为可执行的 css
安装 node-sass 时需要访问 github.com
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
{
"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
{
"extends": "stylelint-config-standard"
}
package.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
申明文件可以预定义方法和类型,避免工具中出现不存在的提示
types/index.d.ts
declare class Viewer {
constructor(element: Element);
}
declare module 'viewer' {
export default Viewer;
}
package.json
{
...
"main": "index.js",
"types": "types/index.d.ts", -引用申明文件
...
}
本小节使用es6定义Viewer,并在测试Case中调用Viewer,启动测试后查看打印信息
src/js/viewer.js
// 定义Viewer
class Viewer {
// 默认构造器
constructor(element) {
console.log('viewer', 'constructor');
this.init();
}
// 初始化方法
init() {
console.log('viewer', 'init');
}
}
// 对外暴露Viewer
export default Viewer;
src/index.js
// 入口文件引用Viewer
import Viewer from "./js/viewer";
export default Viewer;
tests/image.spec.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中也可以看到结果
元素添加事件
<input type="button" value="点击" onclick="show()">
function show() { alert('点击'); }
<input type="button" value="添加" id="add">
document.getElementById('add').onclick = function () { alert('添加'); };
addEventListener添加
element.addEventListener(type, listener, options)
<input type="button" value="元素" id="ele">
document.getElementById('ele').addEventListener('click', function () {
alert('元素');
});
listener默认接受参数为event,使用对象解构可以从事件中获取到点击的对象target
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
本小节对事件进行封装,且对元素添加点击事件,启动测试后查看点击后的打印结果
src/js/utils.js
// 对addEventListener进行封装
export function addListener(element, type, listener, options = {}) {
element.addEventListener(type, listener, options);
}
src/js/methods.js
// 定义方法
export default {
show() {
console.log('method', 'show');
return this;
},
view() {
console.log('method', 'view');
return this;
}
}
types/index.d.ts
...
declare class Viewer {
constructor(element: Element);
show(): Viewer;
view(): Viewer;
}
...
src/js/viewer.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;
以上流程思路如下:
npm run test
运行后点击图片,可以在浏览器控制台查看点击后的输出结果
本小节创建加载图片的容器,在点击图片时显示该容器
src/js/utils.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
// 容器模版
export default (`<div class="viewer-container" touch-action="none"></div>`)
src/js/viewer.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
// 除了添加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
@import "./css/viewer.scss";
src/js/methods.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并设置背景色
本小节为Container添加关闭按钮
src/js/template.js
export default (`
<div class="viewer-container" touch-action="none">
<div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)
src/js/viewer.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
...
.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
// 对图片所在文件夹进行设置
module.exports = function (config) {
config.set({
...
files: [
'src/index.js',
'src/index.scss',
'./tests/help.js',
'./tests/*.spec.js',
{ -排除images文件夹
pattern: '*/images/*',
included: false
}
]
...
})
}
设计思路
本小节为关闭按钮添加点击事件,获取按钮 data-viewer-action 属性
src/js/utils.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
import {getData} from "./utils";
export default {
click(event) { -点击事件处理函数
const {target} = event
console.log(getData(target, 'viewerAction'));
}
}
src/js/events.js
import {addListener} from "./utils";
export default {
bind() {
// 绑定处理函数handlers.click到button点击事件上
addListener(this.button, 'click', this.click.bind(this))
}
}
src/js/methods.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
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
src/js/utils.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
.viewer {
...
&-hide {
display: none;
}
&-backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
}
src/js/viewer.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
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
import {getData} from "./utils";
export default {
click(event) {
const {target} = event
const action = getData(target, 'viewerAction')
switch (action) {
case 'mix':
// 点击关闭按钮时调用hide
this.hide()
}
}
}
启动测试环境,测试关闭按钮
本小节在容器中添加一画布,点击原图片显示Container时显示图片
src/js/template.js
// 添加canvas
export default (`
<div class="viewer-container" touch-action="none">
<div class="viewer-canvas"></div>
<div class="viewer-footer">
<div class="viewer-navbar">
<ul class="viewer-list"></ul>
</div>
</div>
<div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)
src/js/utils.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
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
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
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
.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;
}
}
...
}
本小节在显示图片前显示加载动画
src/css/viewer.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
export default {
...
view() {
...
this.image = image
this.index = 0
// 隐藏图片
addClass(image, 'viewer-invisible')
// 添加加载动画
addClass(canvas, 'viewer-loading')
canvas.innerHTML = ''
canvas.appendChild(image)
return this;
},
...
}
本小节根据Viewer大小,自适应调整图片大小及位置
设计思路
Viewer-transition 过渡动画完成时触发 transitionend 事件 shown
Items-image 加载完成时触发 load 事件 loadImage
保持图片宽高比调整图片大小及位置
计算图片宽度和高度(保持宽高比)
// 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事件
// 任何带有 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test transition</title>
<style type="text/css">
#viewer {
width: 800px; height: 600px;
background-color: gray; opacity: 1;
transition: 1s;
}
#viewer:hover { width: 900px; }
</style>
</head>
<body>
<div id="viewer"></div>
</body>
<script type="text/javascript">
// transition 完成时触发 transitionend 事件
document.getElementById('viewer').addEventListener('transitionend', (event) => {
console.log(event)
})
</script>
</html>
src/js/utils.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
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
.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
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
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
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
}
}
本节为Viewer添加工具栏及其事件
src/js/costants.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
export default (`
<div class="viewer-container" touch-action="none">
<div class="viewer-canvas"></div>
<div class="viewer-footer">
<div class="viewer-title"></div>
<div class="viewer-toolbar"></div>
<div class="viewer-navbar">
<ul class="viewer-list"></ul>
</div>
</div>
<div role="button" class="viewer-button" data-viewer-action="mix"></div>
</div>
`)
src/css/viewer.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
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
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
}
},
...
}
src/js/methods.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
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
}
},
...
}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。