前情回顾

上一篇文章 中我们已经搭建完成了一个最简单的 devserver 了,现在可以比较方便的开发, require 你想要 require 的一切了,那么下面一步我们该干什么呢?

介绍一点知识

由于 h5 的相关 api 的开放,相对于原先前端极度依赖后端的情况已经出现了非常明显的改善,主要在于浏览器厂商实现了包括 blob 在内的相关 file 接口,使得前端对读写文件的能力大大提升,并且由于 blob 接口的出现,使得我们可以更加方便的作出一些黑科技操作。

blob那些事儿

相对于任何有一定计算机知识的小伙伴而言,blob 应该并不陌生,特别对于后端,数据库中基本用来储存二进制大文件的时候,通常都会选用 blob 作为储存格式,以 mysql 为例,LongBlob 可以储存多达 4G 大小的文件,而在前端,受限于部分浏览器的实现(主要是内存原因),同样并不建议用户对超出 4G 的 blob 进行操作。

那么我们拿 blob 可以做些什么?

  1. 读取文件
  2. 生成文件

首先写两个例子:

function dataURLtoBlob(dataurl) {
    var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
    str = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
    while(n--){
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], {type:mime});
}

这个是最常见的 图片 转 blob 的方式,至于是谁最早写的这种类型的函数,早就不可考据,这里就不备注作者了。

同样的如果我们对 zip 之类的文件的格式非常熟悉的话,事实上生成 zip 文件等并不是一个难题!

为什么要提 zip 文件呢? 假定我们的前端没有一个后端协助,最后要生成一个 h5 那坑定是一个静态文件了,要下载必须要要打包,这样我们就不得不需要一个 zip 库来实现这些操作(自己写难度太大了还是用库来的靠谱)!

zip.js

这里隆重介绍一下大名鼎鼎的 zipjs ! 好吧也不算大名鼎鼎,但是目前用下来,无出其右倒是真的。(主要这货兼容到了 IE6,太可怕了)

使用非常方便,并且基于 callback-promise (啊我想要同步方法啊喂!)

var zip = new JSZip();
 
zip.file("Hello.txt", "Hello World\n");
 
var img = zip.folder("images");
img.file("smile.gif", imgData, {base64: true});
 
zip.generateAsync({type:"blob"}).then(function(content) {
    // see FileSaver.js
    saveAs(content, "example.zip");
});

正文

上面扯淡了这么多,下面还是开始正文内容吧,首先写demo怎么着都得用 jquery 来的方便的多,不过没有了双向绑定库也是不方便那么一点,但是 demo 嘛,长得丑一点,搓一点也不是问题。

至于动画库,还是大名鼎鼎的 animate.css 吧,我们只需要提供和 ppt 一样的动画控制就可以了。

首先让我扩充一下webpack

1. html 文件的require

由于丧失了 mvvm 框架的支持,这里直接用的 require html 的方式,让 jquery 来辅助操作路由

首先对 config/webpack.dev.js 中对 loader 条目添加如下两行

rules: [
    // 别的配置
    ...,
    // => 下面是新加的
    {
        test: /\.html$/,
        loader: "html-loader",
    }, {
        test: /\.(png|jpg)$/,
        loader: 'url-loader?limit=8192'
    }

然后测试一下可以 require 将来路径正确就可以了。

2. 编写一个 canvas + menu

下面是实现一个 canvas 去实现一些 拖拽事件, 这里我们首要主要的第一点是这样的!

由于各种设备显示等等问题,后期为了保证显示效果一致,一般都会用 flexible js去控制,但是说实话,这对于前端而言并不方便,这里选择使用一个更加早期的方案也就是 响应式的方法 去实现

这样我们需要框死这个区域 最小的区域是 320 * 480 px, 也就是说在最差情况下,我们的屏幕会显示这个区域的内容。

随后是限定一个最大的显示大小 430 * 820 px(至于为什么留这么多的空还是不行是 iphonex 闹的,反正注意要取个整就行)

随后 整个 canvas 的大小应该大致比他稍大一点,这样就大概给个 500 * 850 px 即可,但是这样竖向已经远远超出了正常显示器的显示范围( 768px ),不过好在我们认为(瞎想)用户一定会在pc大屏上去编辑这个东西,所以不用考虑这么多!

大致样子就和下面那个差不多,核心显示区域至少要保证在稍大于红色区域位置:

显示效果

对于这些 template 我们大致规定一下目录结构是这样的:

2024年06月01日记录: 图片已经丢失 这里暂时无法展示

html是具体的 html 模板, canvas 是具体的 css 文件,当然css也是可以的,主要配置一下 webpack, index.js具体需要实现生产 jquery 对象等功能,这里写个class去解决重复劳动问题。

import $ from 'jquery'
export default class Template {
    _dom = null
    _template = ''
    _entry = []
    constructor ({
        template,
        entries
    }) {
        this._dom = $(template)
        this._template = template
        if (!entries) return 
        for (const key of entries) {
            if (key) {
                let dom = this._dom.find(key)
                this._entry.push([key, dom])
                this[key] = dom
            }
        }
    }
    forEach (callback) {
        this._entry.forEach(([key, dom], index, array) => {
            callback.call(dom, key, index, array)
        })
    }
}

然后我们约定俗成的使用这样一个函数去挂载生成的实例:

function appendTemplate (template) {
    this.append(template._dom)
}

2.1 功能实现

具体实现的功能大致和下面那个 demo 差不多,不过实际的功能要相对复杂的多:

{% jsfiddle icepro/cnkdg8do js,css,html,result dark 100% 400px %}

大致什么样子的呢,先简单的描述一下,最后还是画一个流程图吧!

首先我们是一个编辑器,所以实际上,不太可能说每次一次操作都像上面那样去一次绑定dom啊等等,这样弄个几个插件就写不下去了,所以需要一个 类似与 event center 的东西去中心化的控制他

由于没有使用一些 mvvm 类的框架,所以这一部分的内容需要我们手工自己实现大致原理就是常见的那些事件委托的写法。

思路很简单

  1. 主要需要监听 mousedown,mousemove,mouseup,这三个事件全部绑定在顶层(root),也就是 body 上面
  2. 区块响应,这个很好理解,比如你从 menu 拖拽一个东西到 canvas,这样不可能说挨个去写,而是应该在响应阶段去判断是否在 目标区块 内就行了
  3. 鼠标位置,集成化的处理鼠标事件以减少每一次响应插件时候的性能消耗
  4. 插件隔离化的数据,但是需要的话还是能够通过插件名称定向获得数据,自动化 初始化 注入 data

简单的将就和下面那个逻辑差不多,让我们先编写一个event center

事例图片

首先我们先写好三个基础方法,这个是 export 出去给其他类使用的:

const mouseEventMap = new Map();
const data = {
    mouse: {},
    region
};
const REGION = new Map();
/**
 * {Object} eventCenter 共有的data
 */
window.__eventCenterData = data;
// TODO: 这里会继续写逻辑,标记 1
 
/**
 * 注册事件
 * @param {string} pluginName
 * @param {{mousedown: (()), mousemove: (()), mouseup: (())}}option
 */
export function bindEvent (pluginName, option, pluginData) {
    mouseEventMap.set(pluginName, option);
    data[pluginName] = pluginData
}
/**
 * 移除事件
 * @param {string} pluginName
 */
export function removeEvent (pluginName) {
    mouseEventMap.delete(pluginName);
}
/**
 * 注册区域块
 * @param {string} name
 * @param {node} dom
 */
export function registerRegion (name, dom) {
    REGION.set(name, dom);
}

然后该怎么写呢?

弄个循环注册下就行了

// 最上方引入jquery
import $ from 'jquery';
// lodash 都是 null safe 的直接用lodash相较之es6安全
import _get from 'lodash/get';
import _isFunction from 'lodash/isFunction';
// 标记1位置添加一些功能
 
['mousedown', 'mouseup', 'mousemove'].forEach(value => {
    $dom.on(value, function(event) {
        let target = $(event.target);
        let keys = mouseEventMap.keys();
        let settings = {
            originalEvent: event,
            data
        };
        // TODO: 在这里判断一下 region 标记 2
        for (let key of keys) {
            if (mouseEventMap.has(key)) {
                let mouseEventValue = mouseEventMap.get(key);
                let mouseEvent = _get(mouseEventValue, value);
                if (mouseEvent && _isFunction(mouseEvent)) {
                    if (mouseEvent.call(target, settings) === false) {
                        return false
                    }
                }
            }
        }
    });
});

然后我们需要实现region,主要是鼠标为止相关的事情,这里需要注意的是,页面存在滚动条,所以要把滚动距离也计算进去

// 在开头定义这样一个对象,用来储存和region有关的信息
/**
 * 鼠标所处区域
 * @type {{regions: Array, has: region.has, init: (())}}
 */
const region = {
    regions: [],
    regionsMouse: new Map(),
    has: function (name) {
        return this.regions.indexOf(name) !== -1;
    },
    init () {
        this.regions = [];
    },
    /**
     * 判断是否在区域内
     * @param name
     * @param targetDom
     * @returns {boolean}
     */
    isInRegion (name, targetDom) {
        if (REGION.has(name)) {
            let sourceRegion = REGION.get(name);
            let sourceRect = sourceRegion.getBoundingClientRect();
            let targetRect = targetDom.getBoundingClientRect();
            return sourceRect.top < targetRect.top &&
                    sourceRect.left < targetDom.left &&
                    sourceRect.right > targetDom.right &&
                    sourceRect.bottom > targetDom.bottom
        }
        return false
    }
};

然后我们只需要在 标记2 出对于鼠标位置处理一下就可以拿来用了:

// 标记 2 处
let bodyScroll = $(document).scrollTop();
let mouseTop = event.clientY + bodyScroll;
data.mouse.x = event.clientX;
data.mouse.y = mouseTop;
data.mouse.originY = event.clientY;
region.init();
REGION.forEach((regionValue, key) => {
    if (regionValue && regionValue.getBoundingClientRect) {
        // TODO: 其实没有必要JSON parse,这个后面在处理
        let rect = JSON.parse(JSON.stringify(regionValue.getBoundingClientRect()));
        // 高度实际位置为 鼠标 y - bodyScroll
        rect.top += bodyScroll;
        if(value==='mousedown') {
            console.log(JSON.stringify(data.mouse) + JSON.stringify(rect))
        }
        // TODO: 制作成通用位置判断方法
        if (
            data.mouse.y > rect.top &&
            data.mouse.y <= rect.top + rect.height &&
            data.mouse.x > rect.left &&
            data.mouse.x <= rect.left + rect.width
        ) {
            region.regions.push(key);
        }
    }
});

这样我们就完成了event Center的基本配置,那么到时候怎么使用呢? 我们以 menus 为例子来实现:

假设我们的menus长差不多这样,我们用 data-drag-type 和 data-drag-element 去标记实际操作的对象

<div class="menu">
    <ul>
        <li id="menuImage" data-drag-type="menus" data-drag-element="image">image</li>
        <li id="menuText" data-drag-type="menus" data-drag-element="text">text</li>
    </ul>
</div>

全局绑定一个 menu item 做拖拽显示

<div class="global-menus-item" id="globalMenusItem"></div>

在假定不同的plugin间使用一个简单的发布订阅者模型做 event bus 中间件,这样就有了如下结构:

// menus 的dom实体 单例
import menus from './../template/menu/index.js';
// 拖拽显示的 dom 实体 单例
import menuItem from './../template/menu/menu-item'
// event bus
import Event from './../utils/eventBus.js';
// 区域注册器
import {registerRegion} from './../utils/eventCenter';
// 事件中心
import { bindEvent } from './../utils/eventCenter.js'
// 插件名称就是menu
const PLUGIN_NAME = 'menus';
// 注册menus区域
registerRegion('menus', menus._dom[0]);
// canvas 区域注册名称
const CANVAS_REGION = 'canvas';
// 绑定事件中心
bindEvent(PLUGIN_NAME, {
    mousedown: function ({data}) {
        // 被拖拽的事件
        let type = this.data('drag-type');
        if (type === PLUGIN_NAME) {
            let item = this[0].getBoundingClientRect();
            // 设定被拖拽的元素
            data[PLUGIN_NAME].dragedElement = this.data('drag-element');
            // 对拖拽显示物件显示其名称
            menuItem.name = this.data('drag-element');
            // 记录鼠标偏移位置
            data[PLUGIN_NAME].originalOffset = {
                deltaX: item.x - data.mouse.x,
                deltaY: item.y - data.mouse.y
            }
            // 渲染位置
            menuItem.render({
                x: data.mouse.x + data[PLUGIN_NAME].originalOffset.deltaX,
                y: data.mouse.y + data[PLUGIN_NAME].originalOffset.deltaY
            })
            // 设定被拖拽元素dom
            data[PLUGIN_NAME].dragedDom = menuItem
        }
    },
    mouseup: function ({data}) {
        // mouseup 代表元素就结束拖拽
        data[PLUGIN_NAME].dragedDom.hide()
        if (data.region.has(CANVAS_REGION) && data[PLUGIN_NAME].dragedElement) {
            // 如果在canvas区域内,向canvas派发事件
            Event.trigger('canvas:createElement', data[PLUGIN_NAME].dragedElement,{
                x: data.mouse.x + data[PLUGIN_NAME].originalOffset.deltaX,
                y: data.mouse.y + data[PLUGIN_NAME].originalOffset.deltaY
            })
        }
        // 清除绑定状态
        data[PLUGIN_NAME].dragedElement = null
    },
    mousemove: function ({data}) {
        if (data[PLUGIN_NAME].dragedElement) {
            // 移动过程中,持续刷新位置
            data[PLUGIN_NAME].dragedDom.render({
                x: data.mouse.x + data[PLUGIN_NAME].originalOffset.deltaX,
                y: data.mouse.y + data[PLUGIN_NAME].originalOffset.deltaY
            })
        }
    }
}, {
    dragedElement: null,
    dragedDom: null
})
// 注册器
function init ($root) {
    $root.appendTemplate(menus);
}
 
export default function () {
    return {
        init,
        menus
    }
}

这样就实现了一个简单的插件,这样在 canvas 那一块就可以这样去生成元素了

function createElement (type, position) {
    console.log(type, position)
    // TODO: 真的去生成一个元素
}
Event.listen('canvas:createElement', createElement)

然后怎么玩呢,大家可以看这个 demo

大致就张差不多上面那样

3. 仓促的结尾一下

这里先仓促的结尾一下,这篇文章的总字数已经达到了快1万字,对于单篇的文章而言实在太长了,后面打算是写前端如何生成打包文件下载这个简陋的页面的, 那么大家就期待一下下片文章吧!

本文标题:[h5] 写个h5编辑器有多难?[1]

永久链接:https://iceprosurface.com/2018/05/01/2018/h5/h5-1/

作者授权:本文由 icepro 原创编译并授权刊载发布。

版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。

查看源码: