前端自定义右键菜单与图片复制(兼容H5)

PC端实现自定义右键菜单与图片"复制图片并加水印"功能的完整实践(兼容H5)

背景

在Web应用开发中,我们常常需要自定义右键菜单,以提升用户体验或实现更高级的交互功能。另一方面,针对图片内容的操作(如加水印并复制)也是内容安全和品牌营销常用的需求。如果要实现一个 兼容PC与H5端的右键自定义菜单 ,并在图片上集成"复制图片(并加水印)"的功能(支持文字/图片水印、可自定义水印位置和尺寸),应该如何设计?本文将从技术分享者的角度,详细介绍实现思路、关键代码和插件化/兼容性优化建议。


一、需求分解与技术选型

  1. 自定义右键菜单 :原生浏览器右键菜单无法完全控制,需拦截contextmenu事件,渲染自定义菜单。
  2. 菜单项"复制图片(并加水印)"
    • 图片渲染至canvas后添加水印(文字或图片方式均可,位置/尺寸可配置)。
    • 将处理后的图片写入剪切板(PC端首选Clipboard API,H5端兼容降级为图片下载等)。
  3. 兼容性要求:兼容主流桌面浏览器和移动端H5浏览器(触摸设备右键替代方案)。
  4. 扩展性/优雅性:代码结构应支持插件化,便于自定义菜单样式、扩展新功能。

二、实现流程详解

1. 右键菜单的实现

Step1: 拦截并阻止默认右键菜单
js 复制代码
document.addEventListener('contextmenu', function(e) {
    // 判断目标为目标图片或容器
    if (e.target.classList.contains('custom-context-img')) {
        e.preventDefault();
        showCustomMenu(e);
    }
});
Step2: 渲染自定义菜单并定位
js 复制代码
function showCustomMenu(e) {
    // 动态插入菜单DOM(可优化为组件)
    const menu = document.createElement('div');
    menu.className = 'custom-context-menu';
    menu.style.top = `${e.clientY}px`;
    menu.style.left = `${e.clientX}px`;
    menu.innerHTML = `
        <div class="custom-context-item" data-action="copy-watermark">复制图片(并加水印)</div>
        <div class="custom-context-item" data-action="download">下载图片</div>
        <!-- 其他菜单项 -->
    `;
    document.body.appendChild(menu);

    // 点击菜单项事件
    menu.addEventListener('click', (ev) => {
        const action = ev.target.dataset.action;
        handleMenuAction(action, e.target); // e.target为右击图片
        removeCustomMenu();
    });

    // 点击其他区域关闭菜单
    setTimeout(() => {
        document.addEventListener('click', removeCustomMenu, { once: true });
    }, 0);
}

function removeCustomMenu() {
    const menu = document.querySelector('.custom-context-menu');
    menu && menu.remove();
}
Step3: 兼容移动端(H5)

移动端无右键,通常可用"长按"事件模拟:

js 复制代码
let touchTimer = null;
document.addEventListener('touchstart', function(e) {
    if (e.target.classList.contains('custom-context-img')) {
        touchTimer = setTimeout(() => {
            showCustomMenu(e.touches[0]);
        }, 600); // 长按600ms触发
    }
});
document.addEventListener('touchend', () => { clearTimeout(touchTimer); });

2. "复制图片并加水印"功能详解

Step1: 将图片绘制到Canvas
js 复制代码
function drawImageToCanvas(img, width, height) {
    const canvas = document.createElement('canvas');
    canvas.width = width || img.naturalWidth || 300;
    canvas.height = height || img.naturalHeight || 150;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    return { canvas, ctx };
}
Step2: 添加文本或图片水印
js 复制代码
function addTextWatermark(ctx, text, opts) {
    ctx.save();
    ctx.globalAlpha = opts.opacity ?? 0.5;
    ctx.font = `${opts.fontWeight || 'bold'} ${opts.fontSize || 24}px ${opts.fontFamily || 'sans-serif'}`;
    ctx.fillStyle = opts.color || 'rgba(0,0,0,0.2)';
    ctx.textAlign = opts.align || 'right';
    ctx.textBaseline = opts.baseline || 'bottom';

    // 支持自定义位置
    const x = opts.x || ctx.canvas.width - 20;
    const y = opts.y || ctx.canvas.height - 20;
    ctx.rotate((opts.angle || 0) * Math.PI / 180);
    ctx.fillText(text, x, y);
    ctx.restore();
}

function addImageWatermark(ctx, watermarkImg, opts) {
    ctx.save();
    ctx.globalAlpha = opts.opacity ?? 0.5;
    // 尺寸自定义
    const w = opts.width || watermarkImg.width;
    const h = opts.height || watermarkImg.height;
    // 位置自定义
    const x = opts.x !== undefined ? opts.x : (ctx.canvas.width - w - 20);
    const y = opts.y !== undefined ? opts.y : (ctx.canvas.height - h - 20);
    ctx.drawImage(watermarkImg, x, y, w, h);
    ctx.restore();
}
Step3: 写入剪切板(PC端)或兼容处理(H5)

PC端 Clipboard API:

js 复制代码
async function copyCanvasToClipboard(canvas) {
    try {
        // 仅安全环境支持
        canvas.toBlob(async (blob) => {
            await navigator.clipboard.write([
                new ClipboardItem({ [blob.type]: blob })
            ]);
            alert('已复制到剪贴板!');
        });
    } catch (err) {
        alert('复制失败,请使用图片下载');
    }
}

H5端降级:下载保存图片

js 复制代码
function saveCanvasAsImage(canvas) {
    const url = canvas.toDataURL('image/png');
    const link = document.createElement('a');
    link.href = url;
    link.download = 'watermark.png';
    link.click();
}
Step4: 菜单动作处理
js 复制代码
function handleMenuAction(action, imgElement) {
    if (action === 'copy-watermark') {
        const { canvas, ctx } = drawImageToCanvas(imgElement);
        // 示例:加文字水印
        addTextWatermark(ctx, 'Demo Watermark', {
            x: canvas.width - 120, y: canvas.height - 20,
            color: 'rgba(255,0,0,0.3)', fontSize: 28, angle: -15
        });
        // 示例:加图片水印
        /*
        const watermarkImg = new Image();
        watermarkImg.src = 'url_to_watermark.png';
        watermarkImg.onload = () => {
            addImageWatermark(ctx, watermarkImg, { x: 10, y: 10, width: 100, height: 60, opacity: 0.5 });
        } 
        */
        // 兼容性处理
        if (navigator.clipboard && window.ClipboardItem) {
            copyCanvasToClipboard(canvas);
        } else {
            saveCanvasAsImage(canvas);
        }
    }
    // 其他action: download等
}

三、样式与易用性优化

可用CSS美化菜单外观:

css 复制代码
.custom-context-menu {
    position: fixed;
    z-index: 999999;
    min-width: 180px;
    background: #fff;
    box-shadow: 0 2px 10px rgba(0,0,0,0.15);
    border-radius: 8px;
    overflow: hidden;
    font-family: sans-serif;
}
.custom-context-item {
    padding: 12px 18px;
    cursor: pointer;
    transition: background 0.15s;
}
.custom-context-item:hover {
    background: #4170ea;
    color: #fff;
}

四、兼容性&扩展性说明

  • 跨平台兼容:基于DOM事件的右键/长按兼容绝大多数主流浏览器;Clipboard API自动降级到图片下载。
  • 水印方式灵活:支持文字/图片任意组合、自定义参数,便于嵌入企业Logo或时效标记。
  • 扩展性方案:将菜单渲染逻辑、图片处理逻辑模块解耦,便于后续增加菜单项或迁移到Vue/React等主流前端框架中。
  • 安全性提示:部分Clipboard API特性需HTTPS和权限,移动端请适当引导用户下载图片。

五、完整Demo工程&总结

完整Demo代码:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>PC端自定义右键菜单与图片复制加水印 Demo</title>
    <style>
        body { font-family: "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: #f6f8fa; min-height: 100vh; margin: 0; padding: 0; }
        .img-container { margin: 32px auto; text-align: center; }
        .custom-context-img { border-radius: 10px; box-shadow: 0 4px 12px #0001; margin: 12px 0; max-width: 100%; }
        .custom-context-menu {
            position: fixed;
            z-index: 99999;
            min-width: 200px;
            background: #fff;
            border-radius: 7px;
            box-shadow: 0 4px 18px rgba(0,0,0,0.18);
            padding: 7px 0;
            transition: opacity 0.12s;
            user-select: none;
            font-family: inherit;
        }
        .custom-context-menu .custom-context-item {
            padding: 11px 22px;
            cursor: pointer;
            font-size: 16px;
            color: #434658;
            transition: background 0.12s, color 0.12s;
        }
        .custom-context-menu .custom-context-item:hover {
            background: #007aff;
            color: #fff;
        }
        @media (max-width: 600px) {
            .img-container { padding: 12px; }
        }
    </style>
</head>
<body>
    <div class="img-container">
        <h2>自定义右键菜单图片演示</h2>
        <img
            class="custom-context-img"
            src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600"
            width="420"
            alt="演示图片"
        >
        <br>
        <img
            class="custom-context-img"
            src="https://images.unsplash.com/photo-1465101162946-4377e57745c3?w=600"
            width="420"
            alt="演示图片2"
        >
        <p style="color:#888;margin-top:24px;font-size:15px;">支持PC右键,也可移动端长按图片</p>
    </div>

    <script>
    // -- 1. 显示自定义右键菜单 (兼容PC和H5端) --
    (function(){
        let menuEl;
        let longPressTimer = null;

        function removeMenu() {
            if (menuEl) {
                menuEl.remove();
                menuEl = null;
            }
        }

        // 主入口:在图片上右键
        document.addEventListener('contextmenu', function(e) {
            if (e.target.classList.contains('custom-context-img')) {
                e.preventDefault();
                showMenuAt(e.clientX, e.clientY, e.target);
            }
        });

        // 主入口:长按移动端图片
        document.addEventListener('touchstart', function(e) {
            const t = e.target;
            if (t.classList.contains('custom-context-img')) {
                longPressTimer = setTimeout(() => {
                    const touch = e.touches[0];
                    showMenuAt(touch.clientX, touch.clientY, t);
                }, 660);
            }
        });
        document.addEventListener('touchend', function(){ clearTimeout(longPressTimer); });
        document.addEventListener('touchmove', function(){ clearTimeout(longPressTimer); });

        // 关闭菜单
        document.addEventListener('click', removeMenu);
        document.addEventListener('scroll', removeMenu, true);

        function showMenuAt(x, y, imgEl) {
            removeMenu();
            menuEl = document.createElement('div');
            menuEl.className = 'custom-context-menu';
            menuEl.innerHTML = `
                <div class="custom-context-item" data-act="copy-wm">复制图片(并加水印)</div>
                <div class="custom-context-item" data-act="download-wm">下载加水印图片</div>
                <div class="custom-context-item" data-act="normal-download">直接下载原图片</div>
            `;
            menuEl.style.top = y + 'px';
            menuEl.style.left = x + 'px';

            // 溢出窗口自动适配
            setTimeout(() => {
                const rect = menuEl.getBoundingClientRect();
                if (rect.right > window.innerWidth) {
                    menuEl.style.left = (window.innerWidth - rect.width - 14) + 'px';
                }
                if (rect.bottom > window.innerHeight) {
                    menuEl.style.top = (window.innerHeight - rect.height - 8) + 'px';
                }
            }, 8);

            menuEl.onclick = (ev) => {
                const act = ev.target.dataset.act;
                if (act) {
                    handleMenuAction(act, imgEl);
                    removeMenu();
                }
                ev.stopPropagation();
            };
            document.body.appendChild(menuEl);
        }

        // -- 2. 菜单项操作逻辑 --
        async function handleMenuAction(action, img) {
            if (action === "copy-wm" || action === "download-wm") {
                // 加水印
                const {canvas} = await drawImageWithWatermark(img, {
                    type: 'text', // 'text' or 'image'
                    text: 'Copilot Demo', // 水印内容
                    color: 'rgba(255,255,255,1)', // 水印颜色
                    fontSize: 32, // 水印字体大小
                    angle: 0, // 角度
                    pos: 'br', // 'br'(右下),'tl'(左上)... 可自定义
                    opacity: 0.9,
                    offsetX: 24, offsetY: 24
                    // 如为图片形式,加 { type:'image', watermarkUrl:..., ...}
                });

                if (action === "copy-wm") {
                    if (navigator.clipboard && window.ClipboardItem) {
                        canvas.toBlob(async (blob) => {
                            try {
                                await navigator.clipboard.write([new window.ClipboardItem({ [blob.type]: blob })]);
                                // 复制成功弹窗预览
                                showImagePreview(canvas.toDataURL('image/png'), '已复制带水印的图片!你可以Ctrl+V粘贴到聊天窗口或文档里查看。');
                            } catch {
                                alert("复制失败,建议使用下载保存方式");
                            }
                        }, 'image/png');
                    } else {
                        saveCanvas(canvas, 'watermarked.png');
                    }
                }
                if (action === "download-wm") {
                    saveCanvas(canvas, 'watermarked.png');
                }
            }
            if (action === "normal-download") {
                const a = document.createElement("a");
                a.href = img.src;
                a.download = "origin.png";
                a.click();
            }
        }

        // -- 3. Canvas 绘制和加水印支持自定义位置/字体等 --
        function drawImageWithWatermark(imgEl, options) {
            return new Promise((resolve) => {
                // 支持跨域图片
                const img = new window.Image();
                img.crossOrigin = "anonymous";
                img.onload = () => {
                    const w = img.width, h = img.height;
                    const canvas = document.createElement("canvas");
                    canvas.width = w; canvas.height = h;
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(img, 0, 0, w, h);

                    if (options.type === 'text') {
                        const text = options.text || "Watermark";
                        ctx.save();
                        ctx.globalAlpha = options.opacity ?? 0.8;
                        ctx.font = `bold ${options.fontSize||26}px sans-serif`;
                        ctx.fillStyle = options.color || "rgba(255,0,0,0.25)";
                        ctx.textAlign = "right";
                        ctx.textBaseline = "bottom";
                        // 计算位置
                        let x=0, y=0;
                        const marginX=options.offsetX||20, marginY=options.offsetY||20;
                        if (options.pos === 'br') { x = w - marginX; y = h - marginY; }
                        else if (options.pos === 'tr') { x = w - marginX; y = marginY + (options.fontSize||26);}
                        else if (options.pos === 'tl') { x = marginX; y = (options.fontSize||26) + marginY; }
                        else if (options.pos === 'bl') { x = marginX; y = h - marginY; }
                        else { x = w - marginX; y = h - marginY; }

                        // 旋转角度
                        if (options.angle) {
                            ctx.translate(x, y);
                            ctx.rotate(options.angle * Math.PI / 180);
                            ctx.translate(-x, -y);
                        }
                        ctx.fillText(text, x, y);
                        ctx.restore();
                    }
                    if (options.type === 'image' && options.watermarkUrl) {
                        const wm = new window.Image();
                        wm.crossOrigin = "anonymous";
                        wm.onload = () => {
                            ctx.save();
                            ctx.globalAlpha = options.opacity||0.5;
                            // 尺寸
                            const wmw = options.wmWidth||Math.floor(w*0.18), wmh = options.wmHeight||Math.floor(h*0.15);
                            // 位置
                            const wx = options.offsetX ? options.offsetX : (w - wmw - 18);
                            const wy = options.offsetY ? options.offsetY : (h - wmh - 16);
                            ctx.drawImage(wm, wx, wy, wmw, wmh);
                            ctx.restore();
                            resolve({canvas, ctx});
                        };
                        wm.src = options.watermarkUrl;
                        return; // 异步resolve
                    }
                    resolve({canvas, ctx});
                };
                img.src = imgEl.src;
            });
        }

        // 保存canvas图片
        function saveCanvas(canvas, filename) {
            const url = canvas.toDataURL('image/png');
            const a = document.createElement("a");
            a.href = url;
            a.download = filename||"图片.png";
            a.click();
        }

        // 4. 复制成功后弹窗预览图片
        function showImagePreview(imgUrl, tip) {
            const div = document.createElement('div');
            div.className = 'image-preview-mask';
            div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999999;background:rgba(0,0,0,0.27);display:flex;flex-direction:column;align-items:center;justify-content:center;';
            div.innerHTML = `
                <div style="background:#fff;padding:22px 22px 15px 22px;border-radius:8px;box-shadow:0 4px 24px #0002;max-width:92vw;">
                    <div style="font-size:16px;color:#222;margin-bottom:10px;">${tip||'预览'}</div>
                    <img src="${imgUrl}" style="max-width:460px;max-height:48vh;box-shadow:0 2px 10px #0001;border-radius:6px;" />
                    <div style="text-align:center;margin-top:12px;">
                        <button onclick="this.closest('.image-preview-mask').remove()" style="padding:4px 21px;font-size:15px;background:#007aff;color:#fff;border:none;border-radius:4px;cursor:pointer;">关闭</button>
                    </div>
                </div>
            `;
            document.body.appendChild(div);
        }

    })();
    </script>
</body>
</html>

总结

本文系统分享了跨端自定义右键菜单与**图片"复制并加水印"**的实现思路与完整代码,解决了常见的兼容性与可扩展性痛点。核心思路为Canvas图片处理+剪贴板API按需降级,并通过优雅的事件解耦和样式设计确保良好用户体验。相关功能不只适用于日常项目,也适合二次封装输出为通用插件,提高团队开发效率。

react组件版本
js 复制代码
// 组件封装
import React, { useRef, useEffect, useState } from "react";

/**
 * WatermarkContextMenu
 * 可复用的图片右键自定义菜单 + 水印复制/保存组件
 *
 * Props:
 * - imgSrc: 图片url(必传)
 * - render?: 传入自定义图片节点(可选,优先渲染)
 * - watermark: {
 *      type: "text" | "image",
 *      text?: string,
 *      color?: string,
 *      fontSize?: number,
 *      angle?: number,
 *      pos?: "br" | "bl" | "tr" | "tl",
 *      opacity?: number,
 *      offsetX?: number,
 *      offsetY?: number,
 *      imageUrl?: string,     // type=image 时的水印图片
 *      wmWidth?: number,
 *      wmHeight?: number,
 *   }
 * - menuItems?: 追加自定义菜单项(数组: { label: string, onClick: ()=>void }[])
 */

const menuBase = [
  {
    id: "copy-wm",
    label: "复制图片(并加水印)",
  },
  {
    id: "download-wm",
    label: "下载加水印图片",
  },
  {
    id: "normal-download",
    label: "直接下载原图片",
  },
];

/** 工具方法,将图片绘制到canvas并加水印 */
function drawImageWithWatermark({ img, options }) {
  return new Promise((resolve, reject) => {
    const maybeLoad = img.complete
      ? Promise.resolve()
      : new Promise((res, rej) => {
          img.onload = () => res();
          img.onerror = rej;
        });
    maybeLoad
      .then(() => {
        const w = img.naturalWidth || img.width,
          h = img.naturalHeight || img.height;
        const canvas = document.createElement("canvas");
        canvas.width = w;
        canvas.height = h;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0, w, h);
        if (options.type === "text") {
          const text = options.text || "Watermark";
          ctx.save();
          ctx.globalAlpha = options.opacity ?? 0.38;
          ctx.font = `bold ${options.fontSize || 26}px sans-serif`;
          ctx.fillStyle = options.color || "rgba(255,0,0,0.25)";
          ctx.textAlign = "right";
          ctx.textBaseline = "bottom";
          let x = 0,
            y = 0;
          const marginX = options.offsetX || 20,
            marginY = options.offsetY || 20;
          if (options.pos === "br") {
            x = w - marginX;
            y = h - marginY;
          } else if (options.pos === "tr") {
            x = w - marginX;
            y = marginY + (options.fontSize || 26);
          } else if (options.pos === "tl") {
            x = marginX;
            y = (options.fontSize || 26) + marginY;
          } else if (options.pos === "bl") {
            x = marginX;
            y = h - marginY;
          } else {
            x = w - marginX;
            y = h - marginY;
          }
          if (options.angle) {
            ctx.translate(x, y);
            ctx.rotate((options.angle * Math.PI) / 180);
            ctx.translate(-x, -y);
          }
          ctx.fillText(text, x, y);
          ctx.restore();
          resolve({ canvas, ctx });
        } else if (
          options.type === "image" &&
          options.imageUrl &&
          options.imageUrl.length > 0
        ) {
          const wm = new window.Image();
          wm.crossOrigin = "anonymous";
          wm.onload = () => {
            ctx.save();
            ctx.globalAlpha = options.opacity || 0.5;
            const wmw = options.wmWidth || Math.floor(w * 0.18),
              wmh = options.wmHeight || Math.floor(h * 0.15);
            const wx =
              typeof options.offsetX === "number"
                ? options.offsetX
                : w - wmw - 18;
            const wy =
              typeof options.offsetY === "number"
                ? options.offsetY
                : h - wmh - 16;
            ctx.drawImage(wm, wx, wy, wmw, wmh);
            ctx.restore();
            resolve({ canvas, ctx });
          };
          wm.onerror = reject;
          wm.src = options.imageUrl;
        } else {
          resolve({ canvas, ctx });
        }
      })
      .catch(reject);
  });
}

function saveCanvas(canvas, filename) {
  const url = canvas.toDataURL("image/png");
  const a = document.createElement("a");
  a.href = url;
  a.download = filename || "图片.png";
  a.click();
}

/** 复制成功弹窗预览 */
function PreviewModal({ imgUrl, tip, onClose }) {
  useEffect(() => {
    // 支持键盘 Esc 关闭
    function handle(e) {
      if (e.key === "Escape") onClose();
    }
    window.addEventListener("keydown", handle);
    return () => window.removeEventListener("keydown", handle);
  }, [onClose]);
  return (
    <div
      className="wm-preview-mask"
      style={{
        position: "fixed",
        zIndex: 999999,
        left: 0,
        top: 0,
        right: 0,
        bottom: 0,
        background: "rgba(0,0,0,0.27)",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
      }}
      tabIndex={-1}
      onClick={onClose}
    >
      <div
        style={{
          background: "#fff",
          padding: 22,
          borderRadius: 8,
          boxShadow: "0 4px 24px #0002",
          maxWidth: "92vw",
        }}
        onClick={e => e.stopPropagation()}
      >
        <div
          style={{
            fontSize: 16,
            color: "#222",
            marginBottom: 10,
            textAlign: "center",
          }}
        >
          {tip || "预览"}
        </div>
        <img
          src={imgUrl}
          alt="预览"
          style={{
            maxWidth: 460,
            maxHeight: "48vh",
            boxShadow: "0 2px 10px #0001",
            borderRadius: 6,
          }}
        />
        <div style={{ textAlign: "center", marginTop: 12 }}>
          <button
            onClick={onClose}
            style={{
              padding: "4px 21px",
              fontSize: 15,
              background: "#007aff",
              color: "#fff",
              border: "none",
              borderRadius: 4,
              cursor: "pointer",
            }}
          >
            关闭
          </button>
        </div>
      </div>
    </div>
  );
}

/** 主组件 */
export default function WatermarkContextMenu({
  imgSrc,
  render,
  watermark = {
    type: "text",
    text: "Copilot Demo",
    color: "rgba(0,0,0,0.25)",
    fontSize: 32,
    angle: -22,
    pos: "br",
    opacity: 0.55,
    offsetX: 24,
    offsetY: 24,
  },
  menuItems = [],
}) {
  const imgRef = useRef();
  const menuRef = useRef();
  const [menu, setMenu] = useState(null); // {x, y, visible}
  const [preview, setPreview] = useState(null); // {imgUrl, tip}

  // 弹菜单
  function openMenu(e) {
    e.preventDefault?.();
    const x = e.clientX || (e.touches && e.touches[0].clientX) || 100;
    const y = e.clientY || (e.touches && e.touches[0].clientY) || 100;
    setMenu({ x, y });
  }

  useEffect(() => {
    if (!menu) return;
    function close() {
      setMenu(null);
    }
    window.addEventListener("click", close);
    window.addEventListener("scroll", close, true);
    return () => {
      window.removeEventListener("click", close);
      window.removeEventListener("scroll", close, true);
    };
  }, [menu]);

  // 长按兼容H5
  useEffect(() => {
    let timer;
    const node = imgRef.current;
    if (!node) return;
    function onTouchStart(e) {
      timer = setTimeout(() => {
        openMenu(e);
      }, 660);
    }
    function clear() {
      clearTimeout(timer);
    }
    node.addEventListener("touchstart", onTouchStart);
    node.addEventListener("touchend", clear);
    node.addEventListener("touchmove", clear);
    return () => {
      node.removeEventListener("touchstart", onTouchStart);
      node.removeEventListener("touchend", clear);
      node.removeEventListener("touchmove", clear);
    };
  }, []);

  async function doMenuAction(id) {
    setMenu(null);
    if (id === "copy-wm" || id === "download-wm") {
      const img = imgRef.current;
      try {
        const { canvas } = await drawImageWithWatermark({
          img,
          options: watermark,
        });
        if (id === "copy-wm") {
          if (navigator.clipboard && window.ClipboardItem) {
            canvas.toBlob(async (blob) => {
              try {
                await navigator.clipboard.write([
                  new window.ClipboardItem({ [blob.type]: blob }),
                ]);
                setPreview({
                  imgUrl: canvas.toDataURL("image/png"),
                  tip:
                    "已复制带水印的图片!你可以Ctrl+V粘贴到聊天窗口或文档里验证。",
                });
              } catch {
                saveCanvas(canvas, "watermarked.png");
              }
            }, "image/png");
          } else {
            saveCanvas(canvas, "watermarked.png");
          }
        } else if (id === "download-wm") {
          saveCanvas(canvas, "watermarked.png");
        }
      } catch (err) {
        alert("处理失败: " + err);
      }
    } else if (id === "normal-download") {
      const img = imgRef.current;
      const a = document.createElement("a");
      a.href = img.src;
      a.download = "origin.png";
      a.click();
    }
  }

  // 右键菜单样式
  const menuStyle =
    menu &&
    {
      position: "fixed",
      zIndex: 99999,
      minWidth: 200,
      background: "#fff",
      borderRadius: 7,
      boxShadow: "0 4px 18px rgba(0,0,0,0.18)",
      padding: "7px 0",
      userSelect: "none",
      fontFamily: "inherit",
      left: menu.x,
      top: menu.y,
    };

  return (
    <>
      <span
        style={{ display: "inline-block", cursor: "pointer", position: "relative" }}
        onContextMenu={openMenu}
        tabIndex={0}
      >
        {render ? (
          // 用户自定义子节点,自动绑定ref
          React.cloneElement(render, { ref: imgRef })
        ) : (
          <img
            ref={imgRef}
            className="custom-context-img"
            src={imgSrc}
            alt="可自定义右键菜单图片"
            style={{
              borderRadius: 10,
              boxShadow: "0 4px 12px #0001",
              margin: "12px 0",
              maxWidth: "100%",
              cursor: "pointer",
            }}
          />
        )}
        {/* 菜单 */}
        {menu && (
          <div ref={menuRef} style={menuStyle}>
            {menuBase.map((item) => (
              <div
                key={item.id}
                style={{
                  padding: "11px 22px",
                  fontSize: 16,
                  color: "#434658",
                  cursor: "pointer",
                  transition: "background 0.12s, color 0.12s",
                  borderRadius: 3,
                  marginBottom: 2,
                }}
                onClick={() => doMenuAction(item.id)}
                onTouchEnd={e => { e.preventDefault(); doMenuAction(item.id); }}
              >
                {item.label}
              </div>
            ))}
            {menuItems.map((item, i) => (
              <div
                key={`cus-${i}`}
                style={{
                  padding: "11px 22px",
                  fontSize: 16,
                  color: "#434658",
                  cursor: "pointer",
                  borderRadius: 3,
                  transition: "background 0.12s, color 0.12s",
                }}
                onClick={() => {
                  setMenu(null);
                  item.onClick && item.onClick();
                }}
                onTouchEnd={e => {
                  e.preventDefault();
                  setMenu(null);
                  item.onClick && item.onClick();
                }}
              >
                {item.label}
              </div>
            ))}
          </div>
        )}
      </span>
      {/* 复制/保存弹窗 */}
      {preview && (
        <PreviewModal
          imgUrl={preview.imgUrl}
          tip={preview.tip}
          onClose={() => setPreview(null)}
        />
      )}
    </>
  );
}
js 复制代码
// 组件使用示例
import React from "react";
import WatermarkContextMenu from "./WatermarkContextMenu";

export default function App() {
  return (
    <div style={{ textAlign: "center", margin: 40 }}>
      <h2>自定义右键菜单图片演示 - React 组件</h2>
      <WatermarkContextMenu
        imgSrc="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600"
        watermark={{
          type: "text",
          text: "Copilot Demo",
          color: "rgba(255,0,0,0.22)",
          fontSize: 34,
          angle: -20,
          pos: "br",
          opacity: 0.58,
          offsetX: 22,
          offsetY: 22,
        }}
        menuItems={[
          {
            label: "自定义菜单动作",
            onClick: () => alert("你点击了自定义菜单~"),
          },
        ]}
      />
      <br />
      <WatermarkContextMenu
        imgSrc="https://images.unsplash.com/photo-1465101162946-4377e57745c3?w=600"
      />
      <p style={{ color: "#999" }}>
        支持PC右键,也可移动端长按图片弹出菜单
      </p>
    </div>
  );
}
相关推荐
浮游本尊2 小时前
React 18.x 学习计划 - 第八天:React测试
前端·学习·react.js
麦麦在写代码2 小时前
前端学习1
前端·学习
sg_knight2 小时前
微信小程序中 WebView 组件的使用与应用场景
前端·javascript·微信·微信小程序·小程序·web·weapp
凯子坚持 c2 小时前
生产级 Rust Web 应用架构:使用 Axum 实现模块化设计与健壮的错误处理
前端·架构·rust
IT_陈寒3 小时前
Python 3.12新特性实战:5个让你的代码效率翻倍的隐藏技巧!
前端·人工智能·后端
程序员小寒3 小时前
前端高频面试题之Vuex篇
前端·javascript·面试
网硕互联的小客服3 小时前
如何解决 Linux 文件系统挂载失败的问题?
linux·服务器·前端·网络·chrome
程序员爱钓鱼4 小时前
Python 编程实战 · 实用工具与库 — Flask 路由与模板
前端·后端·python
合作小小程序员小小店7 小时前
web开发,在线%超市销售%管理系统,基于idea,html,jsp,java,ssh,sql server数据库。
java·前端·sqlserver·ssh·intellij-idea