electron+fabricjs+vue3实现一个添加水印工具

项目功能

  • 可批量上传、拖拽上传
  • 暂时只支持文字水印,图片水印开发中
  • 支持水印的不同字体、颜色、字号、旋转
  • 支持多个文字水印
  • 支持平铺水印
  • 右键复制或下载添加了水印的图片
  • 可批量导出结果到指定目录

iShot_2023-03-05_17.15.23.gif

iShot_2023-03-05_17.22.33.gif

iShot_2023-03-05_17.26.54.gif

iShot_2023-03-05_17.28.08.gif

web端

项目地址可访问这个查看,www.songweisuo.com/water/

不过由于受浏览器限制,没法访问本地文件系统,底部的输出目录和导出是无法使用的,无法批量操作,只能对每一张图片使用右键下载图片

创建web项目

使用vite创建基于vue3的项目,官网cn.vitejs.dev/guide/

引入fabric
  • 首先创建fabric的canvas对象
  • 根据当前图片大小来设置canvas画布大小
// 创建fabric的canvas对象
const canvas = new fabric.Canvas('canvas', {
  fireRightClick: true, // 启用右键,button的数字为3
  stopContextMenu: true, // 禁止默认右键菜单
});
// 设置成全局对象,这玩意放在pinia里有问题
global.canvas = canvas;
// 根据当前图片大小来设置canvas画布的大小
watch(currentPic, () => {
  if(currentPic.value?.base64) {
    fabric.Image.fromURL(currentPic.value.base64, (img) => {
      // 设置画布和图片一样大
      const [imgWidth, imgHeight] = [img.width!, img.height!];
      const [canvasWidth, canvasHeight] = getContainerSize(imgWidth, imgHeight);
      canvas.setWidth(canvasWidth);
      canvas.setHeight(canvasHeight);
      img.set({
        scaleX: canvasWidth / imgWidth,
        scaleY: canvasHeight / imgHeight,
      });
      // 设置背景
      canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
      canvas.renderAll();
    });
  }
});
创建水印对象

这里要在代码中创建两个水印对象,一个是fabric的fabric.Textbox对象,另一个是textProps对象,textProps是reactive对象,控制着右侧水印属性的变化

然后再通过监听fabric.Textbox的旋转和编辑事件和watchtextProps的各种属性来关联fabric.TextboxtextProps,相当于双向绑定了

在图片上操作水印,右侧属性面板会跟着变化,变化右侧属性面板,图片上的水印也会跟着变化

export const addWater = () => {
  const canvas = global.canvas;
  const { waterList } = waterStore;
  const textProps = waterList[waterList.length - 1];

  // 获取上一个添加的canvas元素
  let lastObject;
  if (lastTextProps) {
    const allObjects = canvas.getObjects();
    lastObject = allObjects.find(obj => obj.name === lastTextProps.name);
  }
  // 计算当前新增的canvas元素的位置
  const { left, top } = getTopLeft(lastObject);

  const text = new fabric.Textbox(textProps.text, {
    name: newName,
    top: top,
    left: left,
    angle: 0,
    fontFamily: textProps.fontFamily,
    fontSize: textProps.fontSize,
    fill: textProps.color, // 填充色:橙色
    fontWeight: textProps.bold ? 600 : 400,
    fontStyle: textProps.italic ? 'italic' : '',
    lineHeight: textProps.lineHeight,
    opacity: textProps.opacity / 100,
    originX: 'center',
    originY: 'center',
    transparentCorners: false, // 选中时,角是被填充了。true 空心;false 实心
    borderColor: '#16f1fc', // 选中时,边框颜色:天蓝
    borderScaleFactor: 1, // 选中时,边的粗细:5px
    borderDashArray: [4, 2], // 选中时,虚线边的规则
    cornerColor: '#a1de93', // 选中时,角的颜色是 青色
    cornerStyle: 'circle', // 选中时,叫的属性。默认rect 矩形;circle 圆形
    cornerSize: 10, // 选中时,角的大小为20
    cornerDashArray: [10, 2, 6], // 选中时,虚线角的规则
    borderOpacityWhenMoving: 0.6, // 当对象活动和移动时,对象控制边界的不透明度
  });
  text.lockScalingX = true;
  text.lockScalingY = true;

  text.on('rotating', e => {
    console.log(text.name);
    textProps.rotate = parseInt((e as any).transform.target.angle);
  });
  text.on('modified', e => {
    if (textProps.position !== PositionType.duplicate) {
      textProps.text = text.text || '';
    }
  });

  canvas.add(text);
  canvas.setActiveObject(text);

  watch(() => textProps.text, () => {
    if (textProps.position === PositionType.duplicate) {
      renderDuplicate(textProps, text);
    } else {
      text.text = textProps.text;
    }
    canvas.renderAll();
  });
  watch(() => textProps.fontFamily, () => {
    text.fontFamily = textProps.fontFamily;
    canvas.renderAll();
  });
  watch(() => textProps.fontSize, () => {
    text.fontSize = textProps.fontSize;
    canvas.renderAll();
  });
  watch(() => textProps.color, () => {
    // fill修改不生效,但改动fontSize加1减1后就生效了
    text.fontSize = textProps.fontSize + 1;
    text.fontSize = textProps.fontSize - 1;
    text.fill = textProps.color;
    canvas.renderAll();
  });
  watch(() => textProps.bold, () => {
    text.fontWeight = textProps.bold ? 600 : 400;
    canvas.renderAll();
  });
  watch(() => textProps.italic, () => {
    text.fontStyle = textProps.italic ? 'italic' : '';
    canvas.renderAll();
  });
  watch(() => textProps.rotate, () => {
    text.angle = textProps.rotate;
    canvas.renderAll();
  });
  watch(() => textProps.opacity, () => {
    text.opacity = textProps.opacity / 100;
    canvas.renderAll();
  });
  watch(() => textProps.lineHeight, () => {
    text.lineHeight = textProps.lineHeight;
    canvas.renderAll();
  });
  watch(() => textProps.position, () => {
    // 一些操作
  });
};

需要注意的是,mac端和windows端系统自带的字体不太相同,这里通过font-list这个npm包来事先获取系统自带的字体,筛选其中几个供选择

右键功能
  • 右键点击图片有复制图片和下载图片功能
  • 右键点击水印出上述功能外,还有有复制水印和删除水印功能
onMounted(() => {
  canvas.on('mouse:up', opt => {
    if (opt.button === 3) {
      // 当前鼠标位置
      let pointX = opt.pointer!.x;
      let pointY = opt.pointer!.y;
      const absoluteStyle = `
        position: absolute;
        left: ${pointX}px;
        top: ${pointY}px;
      `;
      // 点击水印
      if (opt.target) {
        // console.log(opt.target);
        rightClickObj = opt.target;
        objRightMenuData.show = true;
        objRightMenuData.style = absoluteStyle;
      }
      // 点击canvas
      else {
        rightMenuData.show = true;
        rightMenuData.style = absoluteStyle;
      }
    } else {
      rightClickObj = undefined;
      objRightMenuData.show = false;
      rightMenuData.show = false;
    }
  });
});
导出

导出选择的方案是图片列表中挨个切换图片,然后调用electron端提供的下载图片接口,保存完成后打开目录

async function save() {
  const canvas = global.canvas;
  canvas.discardActiveObject();
  canvas.renderAll();
  for (let i = 0; i < imgList.length; i++) {
    const imgBase64 = imgList[i].base64;
    fabric.Image.fromURL(imgBase64, (img) => {
      const [imgWidth, imgHeight] = [img.width!, img.height!];
      const [canvasWidth, canvasHeight] = getContainerSize(imgWidth, imgHeight);
      canvas.setWidth(canvasWidth);
      canvas.setHeight(canvasHeight);
      img.set({
        scaleX: canvasWidth / imgWidth,
        scaleY: canvasHeight / imgHeight,
      });
        // 设置背景
      canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas));
      canvas.renderAll();
    });
    currentPic.value = imgList[i];
    // fabric的canvas.toDataURL会压缩图片质量
    const htmlCanvas = document.querySelector('canvas');
    const canvasBase64 = htmlCanvas!.toDataURL('image/jpeg', 1);
    await window.electronAPI.saveFile(
      outputPath.value + '/watermark-' + imgList[i].name,
      canvasBase64,
    );
  }
  ElMessage.success('导出成功,已打开导出目录!');
  window.electronAPI.openDirectory(outputPath.value + '/watermark-' + imgList[0].name);
}

Electron端

electron端添加了3个方法供web端调用

选择保存目录
ipcMain.handle('openDirectoryDialog', function (event, defaultPath) {
  return dialog.showOpenDialog({
    defaultPath,
    properties: ['openDirectory'],
    title: '请选择保存目录',
    buttonLabel: '选择'
  }).then(result => {
    if (!result.canceled) {
      return result.filePaths[0];
    }
  });
});
保存图片
ipcMain.handle('saveFile', function (event, filePath, data) {
  let base64Data = data.replace(/^data:image/\w+;base64,/, '');
  let dataBuffer = new Buffer(base64Data, 'base64');
  fs.writeFile(filePath, dataBuffer, function (err) {
    return Promise.reject(err);
  });
  return Promise.resolve();
});
保存完图片后打开指定目录
ipcMain.on('openDirectory', function (event, directoryPath) {
  shell.showItemInFolder(directoryPath);
});

项目地址

欢迎讨论,下载使用

github.com/milugloomy/…