Fabric.js生成H5页面(React版)

6,840 阅读9分钟

Fabric.js生成H5页面(React版)

1、Fabric.js 简介

Fabric.js 是一个革命性的JavaScript库,它极大地简化了HTML5 canvas元素上的图形处理与交互设计工作。通过提供直观且功能丰富的API,Fabric.js让开发者能够以前所未有的轻松方式在网页上绘制、编辑、管理和动画化各种图形与图像。

这个库的核心优势在于其直观的对象模型,它让canvas上的每一个图形元素(如矩形、圆形、文本、图像等)都成为了可交互的对象。这意味着你不仅可以轻松绘制它们,还能对它们进行拖动、缩放、旋转、变形等复杂的操作,而无需深入底层的canvas绘图API。

Fabric.js还支持多层画布管理,让开发者能够轻松组织复杂的图形结构,通过图层的叠加与切换实现丰富的视觉效果。此外,它还内置了丰富的事件处理机制,能够响应鼠标、键盘和触摸等多种输入方式,使得图形与用户之间的交互更加自然流畅。

更令人称道的是,Fabric.js支持从多种格式(如SVG、JSON和图片文件)导入图形对象,并能够将绘制结果导出为相同或不同的格式。这一特性极大地提升了图形数据的共享与复用能力,为跨平台、跨应用的图形设计提供了极大便利。

最重要的是,Fabric.js的学习曲线非常平缓。其API设计得既简洁又强大,即便是初学者也能迅速上手并开发出功能丰富的图形应用程序。同时,社区中丰富的教程、示例和文档资源也为开发者提供了强大的支持。

总之,Fabric.js是每一个希望在网页上实现复杂图形交互的开发者不可或缺的工具。它以其强大的功能、直观的界面和易于上手的特点,让canvas操作变得前所未有的简单和高效。

2、实现多个页面切换

多个页面切换其实本质canvas始终都是一个,只是切换时根据数据进行更新,首先我们要将数据进行存储,为了方便操作可以使用context、redux、mobx都可以,这里本文使用的是mobx

Mobx是一个功能强大的状态管理库,其特点主要包括:

  1. 响应式编程:通过透明的响应式编程方式,当应用状态发生变化时,自动更新依赖的视图或计算值,减少手动更新和优化的需要。
  2. 简单易用:API设计简洁,学习成本低,开发者可以快速上手并用于状态管理,提高开发效率。
  3. 高效性能:只重新执行必要的计算,避免不必要的渲染,提升应用性能。
  4. 可扩展性:支持多存储(store)和复杂的状态逻辑,适用于大型和复杂的应用场景。
  5. 灵活性:与各种前端框架(如React、Vue等)和后端框架(如Node.js)兼容,提供灵活的集成选项。
// 子页面,存储每个item相关数据
import { makeAutoObservable } from 'mobx'
import { v4 as uuid } from 'uuid'

export class PageItemStore {
  id = uuid() // 唯一id
  pageAngle = 0  // 渐变旋转角度
  showAllFilter = false // 是否展示全部滤镜
  filterKey = 'normal' // 滤镜
  filterStyle = {} // 滤镜样式
  rectColor = { // 背景类型 bg-纯色背景 bg-linear 渐变背景
    type: 'bg',
    color: '#fff'
  }
  opacity = 0 // 透明度
  canvasData = null // 画布data
  constructor () {
    makeAutoObservable(this)
  }
}
// 主store,一些全局状态和一些公用方法会存在这里
import { makeAutoObservable } from 'mobx'
class CreateStore {
    canvas = null // fabric.Canvas实例
    pageList = [new PageItemStore()] // 子页面的数据都放在这个集合里
    // ... 还存了很多数据:loading页状态、页面滤镜、页面背景音乐等等...
}

数据存储主要就分为这两块,将数据进行统一管理,对pageList进行操作(增删改查),因为canvas只有一个,所以在切换页面时我们需要获取到当前选中的页面获取它的canvas数据,然后使用loadFromJSON去进行加载

class CreateStore {
    // 切换页面
    changePage = (index, callback) => {
      this.pageIndex = index // 设置高亮
      const pageItem = this.getCurrentPage() // 获取当前页面数据
      this.loadFromJSON(pageItem, callback) // 加载数据
    }
    // 新增页面
    addPage = () => {
       // 本质就是重新new一个子类然后push到pageList中
    } 
    // 删除页面
    deletePage = () => {
       // 直接从pageList中进行删除,然后重新设置高亮index
    }
    // 复制页面
    copyPage = () => {
       // 根据现在的子类克隆(这里一定要深拷贝)一个新的
    }
    // 加载json
    loadFromJSON = (data, callback) => {
      if (!data) return
      this.setRectFilter({ style: data.filterStyle }) // 设置页面滤镜
      this.canvas.discardActiveObject()
      if (!data.canvasData) {
        return callback && callback()
      }
      this.canvas.loadFromJSON(data.canvasData, () => {
        const objects = this.canvas.getObjects()
        objects.forEach(item => {
          this.animation.carryAnimations(item) // 加载数据后执行动画(动画效果后面会将)
        })
        callback && callback() // 完事的回调
      })
    }
}

3、页面生成缩略图

既然有多个页面,那咱也得知道每个页面的样子呀,所以这边做了一个缩略图的效果,首先我们使用存储的画布数据生成一个等比缩放n倍的小型canvas,然后监听画布数据的变化,重新渲染canvas

注意:一定要等比缩放,要不然会出现展示不全的现象

class PageStore {
  // init 方法会在组件初始化的时候调用
  init = (canvas, workspace) => {
    this.canvas = canvas
    this.workspace = workspace
    this.render()
  }
  render = () => {
    this.modifiedCanvas()
    // 监听画布动作,这里制作一个这样的监听不够的
    // 我们还需要在修改属性,或者删除新增元素的时候手动调用this.modifiedCanvas方法
    this.canvas.on('object:modified', this.modifiedCanvas)
  }
  modifiedCanvas = () => {
    const pageItem = this.getCurrentPage()
    // 重新获取数据
    pageItem.canvasData = this.workspace.toObject()
  }
}
// 画布缩略图组件
const ThumbCanvas = observer(({ data }) => {
  const container = useRef()
  const canvas = useRef()
  useEffect(() => {
     // 这块是担心会模糊,所以将canvas放大了一倍
    canvas.current = new fabric.StaticCanvas(container.current, {
      width: container.current.clientWidth * 2,
      height: container.current.clientHeight * 2
    })
  }, [])
  
  useEffect(() => {
    // 监听数据变化,有变化我们重新加载数据就好咯
    loadFromJSON()
  }, [data.rectColor, data.canvasData])
  
  const loadFromJSON = () => {
    if (!canvas.current) return
    canvas.current.loadFromJSON(data.canvasData, () => {
      const rect = canvas.current.getObjects().find(item => item.id === WorkspaceId)
      const width = container.current.clientWidth
      const height = container.current.clientHeight
      const thumbZoom = width / 375
      // 将画布等比缩小
      canvas.current.setZoom(thumbZoom)
      // 设置x轴和y轴的偏移量,我们为了操作画布方便而设置的,这里要将它们计算进去
      const thumbViewportTransform = canvas.current.viewportTransform
      thumbViewportTransform[4] = -rect.left * thumbZoom
      thumbViewportTransform[5] = -rect.top * thumbZoom
      canvas.current.setViewportTransform(thumbViewportTransform)
      canvas.current.renderAll()
    })
  }
  
  // 之前不是放大了1倍嘛,这里我们使用样式将dom缩小1倍,生成后的canvas就变得又清楚,又不变形啦
  return <canvas style={{ transform: 'scale(0.5)', transformOrigin: 'left top' }} ref={container}/>
})

4、元素生成缩略图

首先获取到当前页面的全部元素,然后将它们转化成对象,之后调用toDataURL方法就能获取到图片了,同样为了保证图片的清晰图,也需要去等比放大,这样会显示的清楚一些

useEffect(() => {
  if (!page.canvasData || !page.canvasData.objects) return
  // 获取全部元素,要记得过滤出不需要展示在这里的元素
  const list = page.canvasData.objects.filter(item => {
    return item.id !== WorkspaceId && item.id !== HoverBorderId
  }).reverse()
  setList(list)
}, [page.canvasData])
const ObjectThumb = ({ object }) => {
  const [image, setImage] = useState('')
  const [isVideo, setIsVideo] = useState(false)
  
  useEffect(() => {
    if (object.videoUrl) {
      return setIsVideo(true)
    }
    try {
      // 这里的name就是faric.Text、fabric.Image这些,如果有自定义的元素,需要特殊处理一下
      const name = object.type[0].toUpperCase() + object.type.slice(1)
      fabric[name].fromObject(object, object => {
        // 防止一些元素被隐藏这里展示不出来
        object.set({
          visible: true,
          opacity: 1
        })
        setImage(object.toDataURL())
      })
    } catch (err) {
      console.log(err)
    }
  }, [object])
  // 视频里暂时还没处理
  if (isVideo) {
    return '视频'
  }
  if (!image) return null
  return <img style={{ zoom: '0.5' }} src={image} alt=""/>
}

5、设置页面滤镜

这个设置滤镜我这里是纯靠css样式去设置的,到了H5里面展示也是一样的,整体页面增加滤镜,我不太清楚Fabric.js有没有这样的能力,因为我在调研时发现设置滤镜都是单独给图片元素去设置的,有懂的大佬可以指点一下,感谢!

css 设置滤镜 developer.mozilla.org/zh-CN/searc…

// 提前内置好了一些样式,然后切换滤镜的时候给dom设置这里的样式就好了
export const filterList = [
  {
    title: '原图',
    style: {
      filter: 'none'
    },
    type: 'normal'
  },
  {
    title: '胶片',
    style: {
      filter: 'brightness(112%) contrast(77%) saturate(150%) sepia(18%)'
    },
    type: 'jiaopian'
  },
  {
    title: '蓝调',
    style: {
      filter: 'contrast(75%) saturate(105%) hue-rotate(-35deg) sepia(18%) brightness(105%) grayscale(30%)'
    },
    type: 'landiao'
  },
  {
    title: '日系',
    style: {
      filter: 'contrast(99%) hue-rotate(-33deg) sepia(21%) brightness(91%)'
    },
    type: 'rixi'
  },
  {
    title: '午茶',
    style: {
      filter: 'hue-rotate(-11deg) saturate(226%) brightness(90%) contrast(120%) sepia(60%)'
    },
    type: 'wucha'
  },
  {
    title: '褪色',
    style: {
      filter: 'brightness(115%) contrast(80%) saturate(60%)'
    },
    type: 'tuise'
  },
 //......
]

6、给元素添加一些动画

Fabric.js还支持动画效果,我们可以去设置元素的属性,控制动画的时长,监听动画执行中,和监听动画结束的状态,这些都是支持的

我们需要将动画属性绑定在元素上,因为考虑到会有复制元素的场景,所以将这些属性绑定在元素中,复制的时候就一并给复制了,不需要我们去做一些特殊处理,所以我们新建元素的时候会默认给元素添加一个animateList,用于存放这个元素的所有动画

import { v4 as uuid } from 'uuid'

// 默认动画参数
const DefaultValue = {
  time: 1, // 动画时长(秒)
  delay: 0, // 延迟时长(秒)
  count: 1, // 循环次数
  loop: false // 是否无限循环
}

export const animateType = {
  fadeIn: {
    name: '淡入',
    animateName: 'fadeIn',
    easing: 'easeInQuad',
    image: 'https://ossprod.jrdaimao.com/file/1723013159219136.svg',
    selectImage: 'https://ossprod.jrdaimao.com/file/1723012794337348.svg'
  },
  leftToRight: {
    name: '向右移入',
    animateName: 'leftToRight',
    easing: 'easeOutQuad',
    image: 'https://ossprod.jrdaimao.com/file/1723013249717692.svg',
    selectImage: 'https://ossprod.jrdaimao.com/file/17230128203441.svg'
  },
  rightToLeft: {
    name: '向左移入',
    animateName: 'rightToLeft',
    easing: 'easeOutQuad',
    image: 'https://ossprod.jrdaimao.com/file/1723013394770325.svg',
    selectImage: 'https://ossprod.jrdaimao.com/file/1723013368851234.svg'
  },
  topToBottom: {
    name: '向下移入',
    animateName: 'topToBottom',
    easing: 'easeOutQuad',
    image: 'https://ossprod.jrdaimao.com/file/1723013447843661.svg',
    selectImage: 'https://ossprod.jrdaimao.com/file/1723013466318949.svg'
  },
  // ......
}

// 传出type(动画类型),可以生成一个动画
export const createAnimate = (type) => {
  const options = animateType[type]
  const name = options.name
  const animateName = options.animateName
  const easing = options.easing
  return {
    ...DefaultValue,
    name,
    animateName,
    easing,
    id: uuid()
  }
}

// 展示用的list
export const list = Object.keys(animateType).reduce((prev, next) => {
  return [
    ...prev,
    {
      key: next,
      ...animateType[next]
    }
  ]
}, [])
// 新增文字元素
addText = (props) => {
  const { text: textValue = '双击编辑正文', fontSize = 14, ...otherProps } = props || {}
  const text = new fabric.Textbox(textValue, {
    fontSize,
    fontFamily: 'serif',
    fontWeight: 'normal',
    textAlign: 'justify-center',
    ...otherProps,
    ...this.createShareAttr() // 一些公用属性
  })
  this.firstAddObject(text)
}

// 第一次添加元素(包含默认位置、默认动画)
firstAddObject = (object) => {
  this.canvas.add(object).setActiveObject(object)
  this.workspace.align.center() // 让元素居中
  object.animateList.push(createAnimate('fadeIn')) // 创建动画 默认为淡入动画
  this.workspace.animation.carryAnimations() // 执行动画
  this.canvas.renderAll()
}

// 执行动画
// 因为这里是pc端动画只执行一即可,所以没有处理动画次数和循环动画的场景(H5里面处理了)
carryAnimations = (animateObject, value, callback) => {
  const object = animateObject || this.canvas.getActiveObject()
  let list = null
  if (value) {
    list = Array.isArray(value) ? value : [value]
  } else {
    list = object.animateList
  }
  if (!list || !list.length || !object) return
  
  object.set({
    hasControls: false,
    borderColor: 'transparent'
  })
  const newList = [...list]
  // 这里是模拟了一个队列,然后按顺序执行动画
  const start = (item) => {
    if (!object || !item) return
    if (item.delay) {
      this.sleep(this.toDuration(item.delay))
    }
    const fn = this[item.animateName]
    fn && fn(object, item.time, item.easing).then(() => {
      if (newList.length === 0) {
        object.set({
          hasControls: true,
          borderColor: 'blue'
        })
        this.canvas.renderAll()
        // 动画全部执行完成
        callback && callback()
      }
      start(newList.shift())
    })
  }
  start(newList.shift())
}

单个动画

多个动画

动画效果基本上都是大同小异,只要你知道怎么去设置元素的属性,和控制动画执行的时机就可以啦,像我这里是封装好了一整套的动画逻辑,可以随时去新加一些动画,而且不用改很多的代码哦,基本就是修改配置文件就好了

7、给元素添加点击事件

添加点击事件的道理总的来说其实和添加动画是一样的,也需要把事件绑定在元素中,在pc端这里我们就是需要记录一下点击事件的类型,真正处理事件的地方还是在H5里

8、最终效果

下面的例子中我们创建了几个页面并添加了一些动画效果和触发事件,总体来说效果还是可以的,如果能做出一些精美的模板把他们组合起来我想效果会更好一些

QQ20240826-202213.jpg 作者:洞窝-永升