【功能】海报设计编辑-web-拖动编辑-辅助线自动对齐-缩放-边框-可复制

141 阅读9分钟

PixPin_2025-09-30_16-35-51.png 这是一个完全在浏览器中运行的、所见即所得的网页海报生成工具。它的核心功能可以概括为以下几点:

  1. 自由布局与排版

    • 拖拽移动:画布中的所有元素(标题、文字、二维码)都可以通过鼠标或手指自由拖动位置。
    • 拖拽缩放:可以拖动元素右下角的控制点来改变其大小,其中二维码会保持长宽比。
    • 智能对齐:拖动元素时,会出现“九宫格”布局辅助线,当元素靠近画布的角落、边缘中点或正中心时,会自动吸附,实现快速、精准的对齐。
  2. 高度可定制化

    • 背景:用户可以上传自己的图片作为海报背景。
    • 文字:可以修改文字内容、颜色、大小、字体、粗细和对齐方式(左/中/右)。
    • 二维码:可以上传自定义的二维码图片。
    • 边框:所有元素都可以添加自定义颜色和宽度的边框,不设置宽度即为无边框。
  3. 灵活的元素管理

    • 多元素操作:画布上可以有多个同类元素,例如多个标题或多个文本框。
    • 元素复制:一键复制当前选中的元素,方便快速创建相似内容的模块。
    • 上下文面板:点击哪个元素,右侧的控制面板就会自动切换到该元素的专属设置项,操作直观。
    • 清爽界面:默认情况下,元素没有边框,只有被选中时才会出现蓝色虚线框,界面干净整洁。
  4. 一键生成与下载

    • 完成设计后,点击按钮即可生成一张高清的PNG格式图片,并自动下载到本地。
    • 像素级精准:生成的图片与预览区看到的画面完全一致,真正做到了“所见即所得”。

实现思路

整个应用巧妙地使用 原生JavaScriptHTML5Tailwind CSS 以及一个核心的第三方库 html2canvas 构建,不依赖任何重型框架,核心思路如下:

  1. 结构层 (HTML & CSS)

    • 布局:使用 Flexbox 构建了“左侧预览区、右侧控制区”的响应式两栏布局。
    • 画布:预览区 (#preview-area) 是一个相对定位 (position: relative) 的容器,作为所有可编辑元素的“画布”。
    • 元素:所有可拖动的元素 (.draggable) 都是绝对定位 (position: absolute),它们的 top, left, width, height 等样式由 JavaScript 动态控制。默认的边框被设置为透明,只有在被选中时 (.active-element) 才会显示蓝色边框。
  2. 交互层 (JavaScript)

    • 拖拽与缩放:这是交互的核心。通过监听元素的 mousedown (或 touchstart 触摸开始) 事件来启动拖拽或缩放操作。然后在整个 document 上监听 mousemove (或 touchmove) 事件来实时计算元素的新位置或新尺寸,并更新其 CSS 样式。最后在 mouseup (或 touchend) 事件上结束操作,并移除监听器。
    • 状态管理:使用一个全局变量 activeElement 来追踪当前被选中的元素。所有在控制面板上的操作(如修改颜色、大小)都只会应用到这个 activeElement 上。
    • 智能对齐:在拖动过程中,实时计算元素中心点与预设的9个“吸附点”的距离。如果距离小于某个阈值,就强制将元素的位置设置为吸附点的位置,从而实现“吸附”效果。
  3. 生成层 (核心:html2canvas)

    • 放弃模拟,直接“截图” :这是实现“所见即所得”的关键。我们不再用原生Canvas API去“模仿”和“重绘”HTML元素,因为这很难做到完美一致。
    • 调用 html2canvas:当用户点击“生成”按钮时,程序会调用 html2canvas 库,并将其指向预览区的DOM元素 (#preview-area)。
    • 精准捕获html2canvas 会像浏览器截图一样,遍历该元素及其所有子元素的DOM结构和CSS样式,生成一个像素级精准的Canvas对象。
    • 解决常见问题:在调用时传入了 { scrollY: -window.scrollY } 等关键参数,彻底解决了因页面滚动导致生成图片内容偏移的经典问题。同时,通过 useCORS: true 保证了用户上传的本地图片或网络图片能够被正确处理。
    • 导出:最后,将 html2canvas 返回的Canvas对象转换为PNG格式的DataURL,并创建一个隐藏的下载链接来触发浏览器下载。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>分享图生成器</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- 引入 html2canvas 库 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Koulen&family=ZCOOL+KuaiLe&display=swap" rel="stylesheet">
    <style>
        .font-noto { font-family: 'Noto Sans SC', sans-serif; }
        .font-koulen { font-family: 'Koulen', cursive; }
        .font-zcool { font-family: 'ZCOOL KuaiLe', cursive; }

        .draggable {
            position: absolute;
            cursor: move;
            user-select: none;
            /* 改动:将默认边框设置为透明 */
            border: 2px dashed transparent;
            box-sizing: border-box;
            transition: border-color 0.2s, box-shadow 0.2s;
            z-index: 10;
        }
        .draggable.active-element {
            border-color: #3b82f6;
            box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
            z-index: 20;
        }
        /* 在生成图片时临时隐藏边框 */
        .generating .draggable.active-element {
            border-color: transparent !important;
            box-shadow: none !important;
        }
        .draggable.dragging, .draggable.resizing {
            border-color: #3b82f6;
            z-index: 25;
        }
        .draggable:not(.has-border) { padding: 5px; }
        .draggable.has-border { border-style: solid; }

        .resize-handle {
            position: absolute; width: 12px; height: 12px;
            background: #3b82f6; border: 2px solid white; border-radius: 50%;
            z-index: 15; display: none;
        }
        .active-element .resize-handle { display: block; }
        /* 在生成图片时临时隐藏缩放手柄 */
        .generating .resize-handle {
            display: none !important;
        }
        
        .resize-handle.se { bottom: -6px; right: -6px; cursor: se-resize; }
        
        input[type="file"] { display: none; }
        .file-upload-label { cursor: pointer; display: inline-block; padding: 0.5rem 1rem; }
        body.no-select { user-select: none; }

        .guide-line { position: absolute; background-color: #3b82f6aa; z-index: 5; }
        #vertical-guide { width: 1px; height: 100%; top: 0; }
        #horizontal-guide { height: 1px; width: 100%; left: 0; }
        
        .control-btn-group button { border: 1px solid #d1d5db; padding: 4px 8px; }
        .control-btn-group button.active { background-color: #3b82f6; color: white; border-color: #3b82f6; }
        
        .control-section { display: none; }
        .control-section.visible { display: block; }

        #layout-guides .snap-point {
            position: absolute;
            width: 10px;
            height: 10px;
            background-color: rgba(239, 68, 68, 0.7);
            border-radius: 50%;
            transform: translate(-50%, -50%);
            transition: transform 0.1s, background-color 0.1s;
        }
        #layout-guides .snap-point.active {
            transform: translate(-50%, -50%) scale(2);
            background-color: rgba(220, 38, 38, 1);
        }
    </style>
</head>
<body class="bg-gray-100 font-noto">

    <div class="container mx-auto p-4 max-w-4xl">
        <h1 class="text-2xl font-bold text-center text-gray-800 mb-6">自定义分享图生成器</h1>

        <div class="flex flex-col lg:flex-row gap-8">
            <!-- 预览区域 -->
            <div class="lg:w-1/2 w-full flex-shrink-0">
                <h2 class="text-lg font-bold mb-2 text-gray-700">预览区 (点击元素进行编辑)</h2>
                <div id="preview-area" class="relative w-full aspect-[9/16] bg-gray-300 rounded-lg shadow-lg overflow-hidden">
                    <img id="bg-image-preview" src="https://placehold.co/375x667/d1d5db/374151?text=背景图" class="absolute top-0 left-0 w-full h-full object-cover" alt="背景预览">
                    <div id="vertical-guide" class="guide-line" style="display: none;"></div>
                    <div id="horizontal-guide" class="guide-line" style="display: none;"></div>
                    <div id="layout-guides" class="absolute top-0 left-0 w-full h-full pointer-events-none z-[5]" style="display: none;"></div>

                    <div id="title-preview" class="draggable text-center" data-type="title" style="top: 10%; left: 10%; width: 80%; min-height: 40px;">
                        <span id="title-text" class="element-text" style="color: #ffffff; font-weight: bold; font-size: 30px; text-shadow: 1px 1px 2px black;">这是主标题</span>
                        <div class="resize-handle se"></div>
                    </div>

                    <div id="content-preview" class="draggable text-left" data-type="content" style="top: 25%; left: 10%; width: 80%; min-height: 50px;">
                        <span id="content-text" class="element-text" style="color: #ffffff; font-weight: normal; font-size: 16px; text-shadow: 1px 1px 2px black;">这里是详细的文字内容。这段文字会自动处理换行。</span>
                        <div class="resize-handle se"></div>
                    </div>

                    <div id="qrcode-preview" class="draggable" data-type="qrcode" style="top: 75%; left: 35%; width: 30%; aspect-ratio: 1/1;">
                         <img id="qrcode-image-preview" class="w-full h-full pointer-events-none" src="https://placehold.co/150x150/ffffff/000000?text=QR+Code" alt="二维码预览">
                         <div class="resize-handle se"></div>
                    </div>
                </div>
            </div>

            <!-- 控制面板 -->
            <div class="lg:w-1/2 w-full">
                <h2 class="text-lg font-bold mb-2 text-gray-700">控制面板</h2>
                <div class="space-y-6 bg-white p-4 rounded-lg shadow-lg min-h-[500px]">
                     <div id="no-selection-panel" class="visible">
                        <p class="text-gray-500 text-center pt-10">请在左侧预览区点击一个元素开始编辑。</p>
                    </div>

                    <!-- 背景设置 -->
                    <div id="bg-controls" class="control-section">
                        <h3 class="font-bold text-gray-600 mb-2">背景设置</h3>
                        <label for="bg-upload" class="file-upload-label w-full text-center bg-blue-500 text-white rounded-md hover:bg-blue-600 transition">上传背景图</label>
                        <input type="file" id="bg-upload" accept="image/*">
                    </div>

                    <!-- 标题设置 -->
                    <div id="title-controls" class="control-section">
                        <div class="flex justify-between items-center mb-2">
                            <h3 class="font-bold text-gray-600">标题设置</h3>
                            <button class="duplicate-btn text-blue-500 hover:text-blue-700 font-bold">复制</button>
                        </div>
                        <input type="text" id="title-input" class="w-full p-2 border rounded-md">
                        <div class="grid grid-cols-2 gap-4 mt-2">
                            <div><label class="block text-sm font-medium text-gray-500">颜色</label><input type="color" id="title-color" value="#FFFFFF" class="w-full h-10 p-1 border rounded-md"></div>
                            <div><label class="block text-sm font-medium text-gray-500">大小: <span id="title-size-value">30</span>px</label><input type="range" id="title-size" min="12" max="100" value="30" class="w-full"></div>
                        </div>
                        <div class="mt-2"><label class="block text-sm font-medium text-gray-500">字体</label><select id="title-font" class="w-full p-2 border rounded-md"><option value="font-noto">思源黑体</option><option value="font-koulen">Koulen</option><option value="font-zcool">站酷快乐体</option></select></div>
                        <div class="flex items-center gap-4 mt-2">
                            <div class="control-btn-group" role="group">
                                <button type="button" data-prop="fontWeight" data-value="bold" class="toggle-bold">粗</button>
                                <button type="button" data-prop="fontWeight" data-value="normal" class="toggle-bold">细</button>
                            </div>
                            <div class="control-btn-group" role="group">
                                <button type="button" data-prop="textAlign" data-value="left">左</button>
                                <button type="button" data-prop="textAlign" data-value="center">中</button>
                                <button type="button" data-prop="textAlign" data-value="right">右</button>
                            </div>
                        </div>
                        <div class="grid grid-cols-2 gap-4 mt-2">
                            <div><label class="block text-sm font-medium text-gray-500">边框颜色</label><input type="color" id="title-border-color" value="#FFFFFF" class="w-full h-10 p-1 border rounded-md"></div>
                            <div><label class="block text-sm font-medium text-gray-500">边框: <span id="title-border-width-value">0</span>px</label><input type="range" id="title-border-width" min="0" max="20" value="0" class="w-full"></div>
                        </div>
                    </div>

                    <!-- 文字内容设置 -->
                    <div id="content-controls" class="control-section">
                         <div class="flex justify-between items-center mb-2">
                            <h3 class="font-bold text-gray-600">内容设置</h3>
                            <button class="duplicate-btn text-blue-500 hover:text-blue-700 font-bold">复制</button>
                        </div>
                         <textarea id="content-input" rows="3" class="w-full p-2 border rounded-md"></textarea>
                        <div class="grid grid-cols-2 gap-4 mt-2">
                            <div><label class="block text-sm font-medium text-gray-500">颜色</label><input type="color" id="content-color" value="#FFFFFF" class="w-full h-10 p-1 border rounded-md"></div>
                            <div><label class="block text-sm font-medium text-gray-500">大小: <span id="content-size-value">16</span>px</label><input type="range" id="content-size" min="10" max="80" value="16" class="w-full"></div>
                        </div>
                        <div class="mt-2"><label class="block text-sm font-medium text-gray-500">字体</label><select id="content-font" class="w-full p-2 border rounded-md"><option value="font-noto">思源黑体</option><option value="font-koulen">Koulen</option><option value="font-zcool">站酷快乐体</option></select></div>
                         <div class="flex items-center gap-4 mt-2">
                            <div class="control-btn-group" role="group">
                                <button type="button" data-prop="fontWeight" data-value="bold">粗</button>
                                <button type="button" data-prop="fontWeight" data-value="normal">细</button>
                            </div>
                            <div class="control-btn-group" role="group">
                                <button type="button" data-prop="textAlign" data-value="left">左</button>
                                <button type="button" data-prop="textAlign" data-value="center">中</button>
                                <button type="button" data-prop="textAlign" data-value="right">右</button>
                            </div>
                        </div>
                        <div class="grid grid-cols-2 gap-4 mt-2">
                            <div><label class="block text-sm font-medium text-gray-500">边框颜色</label><input type="color" id="content-border-color" value="#FFFFFF" class="w-full h-10 p-1 border rounded-md"></div>
                            <div><label class="block text-sm font-medium text-gray-500">边框: <span id="content-border-width-value">0</span>px</label><input type="range" id="content-border-width" min="0" max="20" value="0" class="w-full"></div>
                        </div>
                    </div>
                    
                    <!-- 二维码设置 -->
                    <div id="qrcode-controls" class="control-section">
                        <div class="flex justify-between items-center mb-2">
                            <h3 class="font-bold text-gray-600">二维码设置</h3>
                            <button class="duplicate-btn text-blue-500 hover:text-blue-700 font-bold">复制</button>
                        </div>
                        <label for="qrcode-upload" class="file-upload-label w-full text-center bg-blue-500 text-white rounded-md hover:bg-blue-600 transition">上传二维码</label>
                        <input type="file" id="qrcode-upload" accept="image/*">
                        <div class="grid grid-cols-2 gap-4 mt-2">
                            <div><label class="block text-sm font-medium text-gray-500">边框颜色</label><input type="color" id="qrcode-border-color" value="#FFFFFF" class="w-full h-10 p-1 border rounded-md"></div>
                            <div><label class="block text-sm font-medium text-gray-500">边框: <span id="qrcode-border-width-value">0</span>px</label><input type="range" id="qrcode-border-width" min="0" max="20" value="0" class="w-full"></div>
                        </div>
                    </div>

                    <!-- 生成按钮 -->
                    <div class="mt-6 border-t pt-6">
                        <button id="generate-btn" class="w-full bg-green-500 text-white font-bold py-3 rounded-md hover:bg-green-600 transition">生成并下载图片</button>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        document.addEventListener('DOMContentLoaded', () => {
            // Check for html2canvas
            if (typeof html2canvas === 'undefined') {
                alert('关键组件加载失败,请检查网络连接并刷新页面。');
                return;
            }

            let activeElement = null;
            let action = null;
            let startX, startY, startWidth, startHeight, startLeft, startTop;
            
            const previewArea = document.getElementById('preview-area');
            const noSelectionPanel = document.getElementById('no-selection-panel');
            const controlSections = document.querySelectorAll('.control-section');
            const layoutGuidesContainer = document.getElementById('layout-guides');

            // --- Element Selection Logic ---
            function setActiveElement(element) {
                if (activeElement) {
                    activeElement.classList.remove('active-element');
                }
                if (element) {
                    element.classList.add('active-element');
                    activeElement = element;
                    showControlsFor(element.dataset.type);
                    updateControlsFor(element);
                } else {
                    activeElement = null;
                    showControlsFor(null);
                }
            }

            function showControlsFor(type) {
                const effectiveType = type ? type.split('-')[0] : null;
                noSelectionPanel.classList.toggle('visible', !effectiveType);
                controlSections.forEach(section => {
                    const sectionType = section.id.replace('-controls', '');
                    section.classList.toggle('visible', sectionType === effectiveType || (effectiveType === null && sectionType === 'bg'));
                });
            }

            function updateControlsFor(element) {
                const type = element.dataset.type.split('-')[0];
                if (!type) return;

                const textSpan = element.querySelector('.element-text');

                if (type === 'title' || type === 'content') {
                    document.getElementById(`${type}-input`).value = textSpan.innerText;
                    document.getElementById(`${type}-color`).value = rgbToHex(textSpan.style.color);
                    const fontSize = parseInt(textSpan.style.fontSize);
                    document.getElementById(`${type}-size`).value = fontSize;
                    document.getElementById(`${type}-size-value`).textContent = fontSize;
                    const fontClass = Array.from(textSpan.classList).find(c => c.startsWith('font-'));
                    document.getElementById(`${type}-font`).value = fontClass || 'font-noto';
                    updateButtonGroup(`${type}-controls`, 'fontWeight', textSpan.style.fontWeight);
                    updateButtonGroup(`${type}-controls`, 'textAlign', element.style.textAlign);
                }

                const borderWidth = parseInt(element.style.borderWidth) || 0;
                document.getElementById(`${type}-border-width`).value = borderWidth;
                document.getElementById(`${type}-border-width-value`).textContent = borderWidth;
                document.getElementById(`${type}-border-color`).value = rgbToHex(element.style.borderColor) || '#FFFFFF';
            }
            
            function updateButtonGroup(panelId, prop, value) {
                const buttons = document.querySelectorAll(`#${panelId} button[data-prop='${prop}']`);
                buttons.forEach(btn => {
                    btn.classList.toggle('active', btn.dataset.value === value);
                });
            }

            // --- Initialization and Event Listeners ---
            function initialize() {
                for(let i = 0; i < 9; i++) {
                    const point = document.createElement('div');
                    point.className = 'snap-point';
                    layoutGuidesContainer.appendChild(point);
                }

                document.querySelectorAll('.draggable').forEach(el => {
                    el.addEventListener('mousedown', onInteractionStart);
                    el.addEventListener('touchstart', onInteractionStart, { passive: false });
                });

                previewArea.addEventListener('click', (e) => {
                    if (e.target === previewArea || e.target.id === 'bg-image-preview') {
                        setActiveElement(null);
                    }
                });
                
                setupControlListeners();
                // Restore default text shadow after removing it for consistency in previous step
                document.getElementById('title-text').style.textShadow = '1px 1px 2px black';
                document.getElementById('content-text').style.textShadow = '1px 1px 2px black';

                setActiveElement(document.getElementById('title-preview'));
            }

            function onInteractionStart(e) {
                if (e.type === 'touchstart') e.preventDefault();
                const element = this;
                setActiveElement(element);
                e.stopPropagation();

                const isResizeHandle = e.target.classList.contains('resize-handle');
                action = isResizeHandle ? 'resizing' : 'dragging';
                element.classList.add(action);
                
                const clientX = e.touches ? e.touches[0].clientX : e.clientX;
                const clientY = e.touches ? e.touches[0].clientY : e.clientY;

                startX = clientX;
                startY = clientY;
                startLeft = element.offsetLeft;
                startTop = element.offsetTop;
                startWidth = element.offsetWidth;
                startHeight = element.offsetHeight;
                
                if (action === 'dragging') showLayoutGuides();

                document.addEventListener('mousemove', onMove);
                document.addEventListener('touchmove', onMove, { passive: false });
                document.addEventListener('mouseup', onEnd);
                document.addEventListener('touchend', onEnd);
                document.body.classList.add('no-select');
            }

            function onMove(e) {
                if (!activeElement) return;
                e.preventDefault();
                const clientX = e.touches ? e.touches[0].clientX : e.clientX;
                const clientY = e.touches ? e.touches[0].clientY : e.clientY;
                const dx = clientX - startX;
                const dy = clientY - startY;

                if (action === 'resizing') handleResize(dx, dy);
                else if (action === 'dragging') handleDrag(dx, dy);
            }

            function onEnd() {
                if (activeElement) activeElement.classList.remove('dragging', 'resizing');
                document.getElementById('vertical-guide').style.display = 'none';
                document.getElementById('horizontal-guide').style.display = 'none';
                hideLayoutGuides();
                document.body.classList.remove('no-select');
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('touchmove', onMove);
                document.removeEventListener('mouseup', onEnd);
                document.removeEventListener('touchend', onEnd);
                action = null;
            }
            
            // --- Actions: Drag, Resize, Duplicate ---
            function handleResize(dx, dy) {
                let newWidth = startWidth + dx;
                let newHeight = startHeight + dy;
                const minWidth = 50, minHeight = 40;
                
                if (newWidth < minWidth) newWidth = minWidth;
                if (newHeight < minHeight) newHeight = minHeight;

                const type = activeElement.dataset.type.split('-')[0];
                if (type === 'qrcode') {
                    const aspectRatio = startWidth / startHeight;
                    newHeight = newWidth / aspectRatio;
                }
                activeElement.style.width = `${newWidth}px`;
                if(type !== 'qrcode') activeElement.style.height = `${newHeight}px`;
            }
            
            function handleDrag(dx, dy) {
                let newX = startLeft + dx;
                let newY = startTop + dy;
                
                const containerWidth = previewArea.clientWidth;
                const containerHeight = previewArea.clientHeight;
                const elemWidth = activeElement.offsetWidth;
                const elemHeight = activeElement.offsetHeight;
                
                const elemCenterX = newX + elemWidth / 2;
                const elemCenterY = newY + elemHeight / 2;
                const snapPoints = getSnapPoints(containerWidth, containerHeight);
                const snapThreshold = 20;
                let snapped = false;
                
                updateActiveSnapPoint(null);

                for(let i = 0; i < snapPoints.length; i++) {
                    const point = snapPoints[i];
                    const dist = Math.sqrt(Math.pow(elemCenterX - point.x, 2) + Math.pow(elemCenterY - point.y, 2));
                    if (dist < snapThreshold) {
                        newX = point.x - elemWidth / 2;
                        newY = point.y - elemHeight / 2;
                        snapped = true;
                        updateActiveSnapPoint(i);
                        break;
                    }
                }

                const centerSnapThreshold = 6;
                document.getElementById('vertical-guide').style.display = 'none';
                document.getElementById('horizontal-guide').style.display = 'none';
                if (!snapped) {
                    const targetCenterX = (containerWidth - elemWidth) / 2;
                    const targetCenterY = (containerHeight - elemHeight) / 2;
                    if (Math.abs(newX - targetCenterX) < centerSnapThreshold) {
                        newX = targetCenterX;
                        document.getElementById('vertical-guide').style.left = `${containerWidth / 2}px`;
                        document.getElementById('vertical-guide').style.display = 'block';
                    }
                    if (Math.abs(newY - targetCenterY) < centerSnapThreshold) {
                        newY = targetCenterY;
                        document.getElementById('horizontal-guide').style.top = `${containerHeight / 2}px`;
                        document.getElementById('horizontal-guide').style.display = 'block';
                    }
                }
                
                newX = Math.max(0, Math.min(newX, containerWidth - elemWidth));
                newY = Math.max(0, Math.min(newY, containerHeight - elemHeight));
                
                activeElement.style.left = `${newX}px`;
                activeElement.style.top = `${newY}px`;
            }

            function duplicateActiveElement() {
                if (!activeElement) return;
                const clone = activeElement.cloneNode(true);
                const originalType = activeElement.dataset.type.split('-')[0];
                const newId = `${originalType}-${Date.now()}`;
                clone.id = newId;
                clone.dataset.type = newId;

                const currentLeft = parseInt(activeElement.style.left) || 0;
                const currentTop = parseInt(activeElement.style.top) || 0;
                clone.style.left = `${currentLeft + 20}px`;
                clone.style.top = `${currentTop + 20}px`;
                
                if (originalType === 'qrcode') {
                    const img = clone.querySelector('img');
                    if(img) img.id = `qrcode-image-${Date.now()}`;
                }

                previewArea.appendChild(clone);
                clone.addEventListener('mousedown', onInteractionStart);
                clone.addEventListener('touchstart', onInteractionStart, { passive: false });
                setActiveElement(clone);
            }
            
            // --- Layout Guides ---
            function getSnapPoints(containerWidth, containerHeight) {
                return [
                    { x: 0, y: 0 }, { x: containerWidth / 2, y: 0 }, { x: containerWidth, y: 0 },
                    { x: 0, y: containerHeight / 2 }, { x: containerWidth / 2, y: containerHeight / 2 }, { x: containerWidth, y: containerHeight / 2 },
                    { x: 0, y: containerHeight }, { x: containerWidth / 2, y: containerHeight }, { x: containerWidth, y: containerHeight }
                ];
            }
            
            function showLayoutGuides() {
                const points = getSnapPoints(previewArea.clientWidth, previewArea.clientHeight);
                const pointElements = layoutGuidesContainer.children;
                for (let i = 0; i < points.length; i++) {
                    pointElements[i].style.left = `${points[i].x}px`;
                    pointElements[i].style.top = `${points[i].y}px`;
                }
                layoutGuidesContainer.style.display = 'block';
            }

            function hideLayoutGuides() {
                layoutGuidesContainer.style.display = 'none';
            }
            
            function updateActiveSnapPoint(index) {
                const pointElements = layoutGuidesContainer.children;
                for(let i = 0; i < pointElements.length; i++) {
                    pointElements[i].classList.toggle('active', i === index);
                }
            }


            // --- Control Panel Logic ---
            function setupControlListeners() {
                document.getElementById('bg-upload').addEventListener('change', (e) => handleFileUpload(e, document.getElementById('bg-image-preview')));
                document.getElementById('qrcode-upload').addEventListener('change', (e) => {
                     if (activeElement && activeElement.dataset.type.startsWith('qrcode')) {
                        handleFileUpload(e, activeElement.querySelector('img'));
                     }
                });

                ['title', 'content'].forEach(type => {
                    document.getElementById(`${type}-input`).addEventListener('input', e => {
                        if (activeElement?.dataset.type.startsWith(type)) activeElement.querySelector('.element-text').innerText = e.target.value;
                    });
                    document.getElementById(`${type}-color`).addEventListener('input', e => {
                        if (activeElement?.dataset.type.startsWith(type)) activeElement.querySelector('.element-text').style.color = e.target.value;
                    });
                    document.getElementById(`${type}-size`).addEventListener('input', e => {
                        if (activeElement?.dataset.type.startsWith(type)) {
                            activeElement.querySelector('.element-text').style.fontSize = `${e.target.value}px`;
                            document.getElementById(`${type}-size-value`).innerText = e.target.value;
                        }
                    });
                    document.getElementById(`${type}-font`).addEventListener('change', e => {
                       if (activeElement?.dataset.type.startsWith(type)) activeElement.querySelector('.element-text').className = `element-text ${e.target.value}`;
                    });
                });

                ['title', 'content', 'qrcode'].forEach(type => {
                     document.getElementById(`${type}-border-width`).addEventListener('input', e => {
                        if (activeElement?.dataset.type.startsWith(type)) {
                            const width = e.target.value;
                            activeElement.style.borderWidth = `${width}px`;
                            document.getElementById(`${type}-border-width-value`).innerText = width;
                            if (width > 0) {
                                activeElement.classList.add('has-border');
                                activeElement.style.borderColor = document.getElementById(`${type}-border-color`).value;
                            } else {
                                activeElement.classList.remove('has-border');
                            }
                        }
                    });
                    document.getElementById(`${type}-border-color`).addEventListener('input', e => {
                        if (activeElement?.dataset.type.startsWith(type) && parseInt(activeElement.style.borderWidth) > 0) {
                            activeElement.style.borderColor = e.target.value;
                        }
                    });
                });
                
                document.querySelectorAll('.control-btn-group button').forEach(button => {
                    button.addEventListener('click', () => {
                        if (!activeElement) return;
                        const prop = button.dataset.prop;
                        const value = button.dataset.value;
                        const textSpan = activeElement.querySelector('.element-text');
                        
                        if (prop === 'textAlign') {
                            activeElement.style[prop] = value;
                        } else if (textSpan) {
                            textSpan.style[prop] = value;
                        }
                        
                        button.parentElement.querySelectorAll('button').forEach(sib => sib.classList.remove('active'));
                        button.classList.add('active');
                    });
                });

                document.querySelectorAll('.duplicate-btn').forEach(btn => btn.addEventListener('click', duplicateActiveElement));
                
                document.getElementById('generate-btn').addEventListener('click', generateImage);
            }

            // --- Canvas Generation using html2canvas ---
            async function generateImage() {
                const generateBtn = document.getElementById('generate-btn');
                const previewElement = document.getElementById('preview-area');
                
                generateBtn.disabled = true;
                generateBtn.textContent = '生成中...';

                // Temporarily add a class to hide UI elements like borders and handles
                previewElement.classList.add('generating');
                const previouslyActive = activeElement;
                setActiveElement(null);

                try {
                    const targetWidth = 750;
                    const scale = targetWidth / previewElement.offsetWidth;

                    const canvas = await html2canvas(previewElement, {
                        useCORS: true,
                        allowTaint: true,
                        scale: scale,
                        backgroundColor: null,
                        // --- FIX: Add these options to prevent vertical shift ---
                        scrollY: -window.scrollY,
                        windowHeight: previewElement.scrollHeight
                    });

                    const link = document.createElement('a');
                    link.download = 'share-image-' + Date.now() + '.png';
                    link.href = canvas.toDataURL('image/png');
                    link.click();
                } catch (error) {
                    console.error("Error generating image with html2canvas:", error);
                    alert("图片生成失败。如果使用了网络图片,请确保它们允许跨域访问,或尝试使用自己上传的图片。");
                } finally {
                    // Restore UI state
                    previewElement.classList.remove('generating');
                    if (previouslyActive) {
                        setActiveElement(previouslyActive);
                    }
                    generateBtn.disabled = false;
                    generateBtn.textContent = '生成并下载图片';
                }
            }

            // --- Utility Functions ---
            const handleFileUpload = (e, imgElement) => {
                const file = e.target.files[0];
                if (file) {
                    const reader = new FileReader();
                    reader.onload = (event) => { 
                        imgElement.src = event.target.result; 
                        imgElement.crossOrigin = "anonymous";
                    };
                    reader.readAsDataURL(file);
                }
            };
            const rgbToHex = (rgb) => {
                if (!rgb || !rgb.startsWith('rgb')) return rgb;
                const result = rgb.match(/\d+/g);
                if (!result || result.length < 3) return '#000000';
                return "#" + ((1 << 24) + (+result[0] << 16) + (+result[1] << 8) + +result[2]).toString(16).slice(1).toUpperCase();
            };

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