fix: 优化背景图滑动逻辑,确保居中显示、左右滑动完整,全内容可拖动到预览区

This commit is contained in:
Zhukj
2026-04-15 15:42:58 +08:00
parent e377aaf73c
commit c54050ff79
4 changed files with 313 additions and 93 deletions

View File

@@ -1,4 +1,4 @@
***项目要求***
*** 项目要求 ***
一款以手机端为主、电脑端兼容的简易邀请函在线制作工具,无需安装、无需后端,打开浏览器即可使用。用户可上传背景图、添加文字、拖动位置,最终一键生成并保存完整邀请函图片。
核心功能
1. 设备适配
@@ -7,12 +7,12 @@
背景图自动适应屏幕大小,不变形、不拉伸
2. 预览区域规范
预览区域固定 3:5 比例
容器居中显示,美观大方
容器在页面居中显示,美观大方
背景图自适应铺满容器
3. 背景图上传
支持本地图片上传
支持 jpg /png/jpeg 格式
自动居中、自适应保留原有比例
自动居左显示、自适应保留原有比例
4. 输入框功能
页面加载自动在右下角生成 2 个输入框,输入框为透明无边框设计
双击输入框 → 编辑文字
@@ -38,7 +38,19 @@
完成制作
## 要求记录
1. 我要求打开项目即显示 3:5 空白面板,背景图自动覆盖容器、超出部分自动裁剪,不拉伸、不变形
2. 我要求两个独立输入框,可双击编辑、可拖动,文字背景透明无边框
3. 我要求电脑端:右键预览区域可直接复制图片到剪贴板,移动端:长按预览区域可保存图片,操作简单,一键导出图片(复制 / 保存)
1. 打开项目即显示 3:5 空白面板,背景图自动覆盖容器、超出部分自动裁剪,不拉伸、不变形
2. 要求两个独立输入框,可双击编辑、可拖动,文字背景透明无边框,放置在右下角
3. 要求电脑端:右键预览区域可直接复制图片到剪贴板,移动端:长按预览区域可保存图片,操作简单,一键导出图片(复制 / 保存)
4. 预览的图片样式和保存的样式不一样,需要改成预览是什么样子,保存出来的图片内容就是什么样子
5. 要求鼠标右击或手机长按屏幕3:5预览区域时是保存图片的状态,点击其他区域不是保存状态
6. 要求图片和屏幕尺寸大小不一致时可在预览框中左右滑动图片调整位置,保持图片比例不变,点击空白处不选中任何输入框和图片状态,图片位置调整不影响文字编辑位置调整
7. 文本字体颜色为黑色,字体为 "微软雅黑",字体大小为 16px,输入框放置在右下角
8. 打开项目时预览区域在屏幕居中显示,选择图片后图片在预览区居左显示,将底部请点击上方「添加图片」按钮上传背景图文字移到背景图预览白板提示中
9. 背景图选择后居左显示,图片原比例保存不变,可以左右滑动调节位置不能小于预览区宽度,预览区与图片合并到同一个图层中,不独立存在,以图片左右为边缘不溢出不拉伸,保持图片比例不变
10. 输入框与背景图完全隔离:输入框的文字编辑、拖动、内容溢出,仅改变输入框自身的位置/大小,不带动背景图偏移,不撑大预览容器,背景图的滑动调整,不影响输入框的位置、编辑状态,操作互不干扰
## 需要注意的点
1. 图片要必须能左右滑动调整位置,保持图片比例不变,图片任何内容都可以被拖动到预览区域内任意位置

View File

@@ -66,12 +66,24 @@ html, body {
position: relative;
}
/* 空提示 - 移到预览白板中 */
.empty-tip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #999;
font-size: 14px;
text-align: center;
pointer-events: none;
z-index: 5;
}
/* 预览区域固定 3:5 比例,打开即显示空白面板 */
.preview {
overflow: hidden;
}
.image-container {
display: flex;
position: relative;
@@ -81,6 +93,9 @@ html, body {
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
background: #fff;
overflow: hidden;
touch-action: pan-x;
margin: 0 auto;
contain: layout style paint;
}
.image-container.empty {
@@ -88,13 +103,40 @@ html, body {
border: 2px dashed #ddd;
}
/* 背景图自适应铺满容器,超出部分裁剪 */
#bgImage {
display: block;
/* 背景图层 - 用于滑动调整位置,溢出隐藏 */
.preview-bg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
overflow: hidden;
}
/* 输入框图层 - 与背景图层隔离 */
.preview-input-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* 输入框可交互 */
.preview-input-layer .text-input {
pointer-events: auto;
}
/* 背景图 - 保持比例,可滑动调整位置 */
#bgImage {
display: block;
position: absolute;
height: 100%;
width: auto;
max-width: none;
left: 50%;
transform: translateX(-50%);
-webkit-user-drag: none;
}
@@ -102,14 +144,15 @@ html, body {
display: none;
}
/* 文字输入框 - 透明无边框设计 */
/* 文字输入框 - 透明无边框设计,黑色微软雅黑16px */
.text-input {
position: absolute;
min-width: 80px;
min-height: 32px;
padding: 4px 8px;
font-size: 18px;
color: #333;
font-size: 16px;
color: #000;
font-family: "Microsoft YaHei", "微软雅黑", sans-serif;
background: transparent;
border: 2px solid transparent;
border-radius: 4px;
@@ -120,6 +163,11 @@ html, body {
-webkit-user-select: none;
user-select: none;
transition: border-color 0.2s;
z-index: 10;
/* 确保输入框内容超出时不影响父容器和其他元素 */
overflow: visible;
max-width: none;
box-sizing: border-box;
}
/* 选中状态(单击/长按) - 仅显示虚线边框 */

View File

@@ -16,13 +16,20 @@
<!-- 图片展示区 -->
<div class="canvas-area" id="canvasArea">
<div class="empty-tip" id="emptyTip">请点击上方「添加图片」按钮上传背景图</div>
<div class="image-container empty" id="imageContainer">
<img id="bgImage" src="" alt="背景图">
<!-- 文字输入框1 -->
<div class="text-input" id="input1" contenteditable="false" style="left: 60%; top: 75%;">姓名</div>
<!-- 文字输入框2 -->
<div class="text-input" id="input2" contenteditable="false" style="left: 60%; top: 85%;">编号</div>
<div class="preview image-container empty" id="imageContainer">
<!-- 提示文字移到预览白板中 -->
<div class="empty-tip" id="emptyTip">请点击上方「添加图片」按钮上传背景图</div>
<!-- 背景图层 -->
<div class="preview-bg-layer" id="bgLayer">
<img id="bgImage" src="" alt="背景图">
</div>
<!-- 输入框图层 -->
<div class="preview-input-layer">
<!-- 文字输入框1 - 右下角 -->
<div class="text-input" id="input1" contenteditable="false" style="left: 5%; top: 80%;">姓名:</div>
<!-- 文字输入框2 - 右下角 -->
<div class="text-input" id="input2" contenteditable="false" style="left: 5%; top: 90%;">编号:</div>
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@
const addImageBtn = document.getElementById('addImageBtn');
const saveBtn = document.getElementById('saveBtn');
const imageContainer = document.getElementById('imageContainer');
const bgLayer = document.getElementById('bgLayer');
const bgImage = document.getElementById('bgImage');
const emptyTip = document.getElementById('emptyTip');
const input1 = document.getElementById('input1');
@@ -17,6 +18,14 @@
let editingInput = null;
let longPressTimer = null;
// 图片滑动调整位置相关变量
let imageOffsetX = 0;
let isDraggingImage = false;
let imageStartX = 0;
let imageStartOffsetX = 0;
let minOffsetX = 0;
let maxOffsetX = 0;
// 初始化
init();
@@ -42,17 +51,33 @@
initInput(input1);
initInput(input2);
// 点击外部取消选中
// 初始化图片滑动
initImageDrag();
// 点击空白区域取消选中
document.addEventListener('click', (e) => {
if (!e.target.closest('.text-input') && !e.target.closest('.image-container')) {
// 只有点击真正的空白区域(不是输入框,不是图片容器)才取消选中
const isTextInput = e.target.closest('.text-input');
const isImageContainer = e.target.closest('.image-container');
const isToolbar = e.target.closest('.toolbar');
if (!isTextInput && !isImageContainer && !isToolbar) {
deselectAll();
}
});
// 初始化长按保存
// 点击容器空白处取消选中
imageContainer.addEventListener('click', (e) => {
// 如果点击的是容器本身或背景图层,取消输入框选中
if (e.target === imageContainer || e.target === bgLayer || e.target === bgImage) {
deselectAll();
}
});
// 初始化长按保存(只在3:5预览区域)
initLongPressSave();
// 初始化右键菜单
// 初始化右键菜单(只在3:5预览区域)
initContextMenu();
// 显示长按提示
@@ -74,15 +99,158 @@
bgImage.src = img.src;
imageContainer.classList.remove('empty');
emptyTip.classList.add('hidden');
// 计算图片尺寸和边界
calculateImageBounds();
// 背景图选择后默认居中显示
imageOffsetX = 0;
updateImagePosition();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// 计算图片滑动边界
function calculateImageBounds() {
if (!currentImage) return;
const containerWidth = imageContainer.clientWidth;
const containerHeight = imageContainer.clientHeight;
const imgRatio = currentImage.naturalWidth / currentImage.naturalHeight;
const containerRatio = containerWidth / containerHeight;
let imgWidth, imgHeight;
if (imgRatio > containerRatio) {
// 图片更宽,以高度为基准计算宽度
imgHeight = containerHeight;
imgWidth = imgHeight * imgRatio;
} else {
// 图片更高或等比,以宽度为基准
imgWidth = containerWidth;
imgHeight = imgWidth / imgRatio;
}
// 计算滑动边界:严格以图片边缘为边界,不能露出白板
// 滑动公式:imageOffsetX = imageStartOffsetX + deltaX
// 手指向右滑动(deltaX>0) → imageOffsetX增大 → 图片右移,显示右侧
// 手指向左滑动(deltaX<0) → imageOffsetX减小 → 图片左移,显示左侧
// 初始位置居中:imageOffsetX = 0,此时 baseLeft 已使图片居中
if (imgWidth > containerWidth) {
// 图片宽度大于容器,可以滑动
// minOffsetX = -(imgWidth - containerWidth) / 2:向左滑到极限(显示左侧)
// maxOffsetX = (imgWidth - containerWidth) / 2:向右滑到极限(显示右侧)
const halfRange = (imgWidth - containerWidth) / 2;
minOffsetX = -halfRange;
maxOffsetX = halfRange;
} else {
// 图片宽度小于等于容器,居中显示,不能滑动
minOffsetX = 0;
maxOffsetX = 0;
}
}
// 更新图片位置
function updateImagePosition() {
if (!currentImage) return;
// 限制滑动范围,确保不露出白板
imageOffsetX = Math.max(minOffsetX, Math.min(maxOffsetX, imageOffsetX));
const containerWidth = imageContainer.clientWidth;
const containerHeight = imageContainer.clientHeight;
const imgRatio = currentImage.naturalWidth / currentImage.naturalHeight;
const containerRatio = containerWidth / containerHeight;
let imgWidth, imgHeight;
if (imgRatio > containerRatio) {
imgHeight = containerHeight;
imgWidth = imgHeight * imgRatio;
} else {
imgWidth = containerWidth;
imgHeight = imgWidth / imgRatio;
}
// 应用位置
const baseLeft = (containerWidth - imgWidth) / 2;
bgImage.style.width = imgWidth + 'px';
bgImage.style.height = imgHeight + 'px';
bgImage.style.left = (baseLeft + imageOffsetX) + 'px';
bgImage.style.transform = 'none';
}
// 初始化图片滑动调整位置
function initImageDrag() {
// 鼠标按下
imageContainer.addEventListener('mousedown', handleImageStart);
imageContainer.addEventListener('touchstart', handleImageStart, { passive: false });
// 鼠标移动
document.addEventListener('mousemove', handleImageMove);
document.addEventListener('touchmove', handleImageMove, { passive: false });
// 鼠标释放
document.addEventListener('mouseup', handleImageEnd);
document.addEventListener('touchend', handleImageEnd);
function handleImageStart(e) {
// 如果点击的是输入框,不处理图片滑动
if (e.target.closest('.text-input')) {
return;
}
// 如果没有图片,不处理
if (!currentImage) {
return;
}
const isTouch = e.type === 'touchstart';
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
// 只有点击图片本身或背景图层时才启动图片滑动
const isImageOrBgLayer = e.target === bgImage ||
e.target === bgLayer ||
e.target === imageContainer;
if (!isImageOrBgLayer) {
return;
}
isDraggingImage = true;
imageStartX = clientX;
imageStartOffsetX = imageOffsetX;
if (isTouch) {
e.preventDefault();
}
}
function handleImageMove(e) {
if (!isDraggingImage) return;
const isTouch = e.type === 'touchmove';
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
const deltaX = clientX - imageStartX;
// 反转拖拽:手指向右滑动(deltaX>0) → imageOffsetX增大 → 图片右移,显示右侧
// 手指向左滑动(deltaX<0) → imageOffsetX减小 → 图片左移,显示左侧
imageOffsetX = imageStartOffsetX + deltaX;
updateImagePosition();
if (isTouch) {
e.preventDefault();
}
}
function handleImageEnd() {
isDraggingImage = false;
}
}
// 初始化输入框 - 每个输入框独立管理自己的拖拽状态
function initInput(element) {
// 每个输入框独立的状态变量
let isMouseDown = false;
let isDragging = false;
let dragTimer = null;
@@ -101,7 +269,6 @@
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
// 标记鼠标/手指按下
isMouseDown = true;
clickStartTime = Date.now();
clickStartX = clientX;
@@ -122,7 +289,7 @@
startLeft = element.offsetLeft;
startTop = element.offsetTop;
// 延迟判断是否为拖拽(150ms后判定为拖拽)
// 延迟判断是否为拖拽
dragTimer = setTimeout(() => {
if (isMouseDown) {
isDragging = true;
@@ -133,14 +300,16 @@
if (isTouch) {
e.preventDefault();
}
// 阻止事件冒泡,避免触发图片滑动
e.stopPropagation();
}
// 鼠标/触摸移动 - 绑定到元素上
// 鼠标/触摸移动
element.addEventListener('mousemove', handleMove);
element.addEventListener('touchmove', handleMove, { passive: false });
function handleMove(e) {
// 必须按住鼠标/手指才能拖动
if (!isMouseDown) return;
if (!selectedInput || editingInput === element) return;
@@ -148,7 +317,6 @@
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
// 判断是否移动
const moveDistance = Math.sqrt(
Math.pow(clientX - clickStartX, 2) +
Math.pow(clientY - clickStartY, 2)
@@ -175,13 +343,18 @@
e.preventDefault();
}
}
// 阻止事件冒泡和默认行为,确保输入框拖动完全独立,不影响背景图
e.stopPropagation();
if (isTouch) {
e.preventDefault();
}
}
// 鼠标/触摸释放 - 绑定到元素上
// 鼠标/触摸释放
element.addEventListener('mouseup', handleEnd);
element.addEventListener('touchend', handleEnd);
// 鼠标离开元素时也结束拖拽
element.addEventListener('mouseleave', (e) => {
if (isMouseDown) {
handleEnd(e);
@@ -189,30 +362,22 @@
});
function handleEnd(e) {
// 标记鼠标/手指释放
isMouseDown = false;
// 清除拖拽定时器
if (dragTimer) {
clearTimeout(dragTimer);
dragTimer = null;
}
const clickDuration = Date.now() - clickStartTime;
// 如果是快速点击且未移动,判定为单击(仅选中,不编辑)
if (!hasMoved && clickDuration < 200 && !isDragging) {
// 单击仅选中,不进入编辑模式
if (e && e.preventDefault) {
e.preventDefault();
}
}
// 立即结束拖拽状态
if (isDragging) {
isDragging = false;
element.classList.remove('dragging');
}
// 阻止事件冒泡
if (e) {
e.stopPropagation();
}
}
// 双击进入编辑模式
@@ -226,7 +391,6 @@
element.addEventListener('touchend', (e) => {
const currentTime = Date.now();
if (currentTime - lastTapTime < 300) {
// 双击检测
e.preventDefault();
e.stopPropagation();
startEdit(element);
@@ -237,17 +401,14 @@
// 选中输入框
function selectInput(element) {
// 如果有其他输入框在编辑,先退出编辑
if (editingInput && editingInput !== element) {
endEdit(editingInput);
}
// 取消之前的选中
if (selectedInput && selectedInput !== element) {
selectedInput.classList.remove('selected');
}
// 选中当前输入框
selectedInput = element;
element.classList.add('selected');
element.classList.remove('editing');
@@ -276,7 +437,6 @@
sel.removeAllRanges();
sel.addRange(range);
// 移动端显示键盘
if ('virtualKeyboard' in navigator) {
navigator.virtualKeyboard.show();
}
@@ -301,32 +461,31 @@
}
}
// 初始化长按保存
// 初始化长按保存(只在3:5预览区域)
function initLongPressSave() {
canvasArea.addEventListener('touchstart', (e) => {
// 只在图片容器上触发长按保存
imageContainer.addEventListener('touchstart', (e) => {
// 如果触摸的是输入框,不触发长按保存
if (e.target.closest('.text-input')) {
return;
}
longPressTimer = setTimeout(() => {
// 震动反馈
if (navigator.vibrate) {
navigator.vibrate(50);
}
// 保存图片
saveImage();
}, 800);
}, { passive: true });
canvasArea.addEventListener('touchend', () => {
imageContainer.addEventListener('touchend', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
canvasArea.addEventListener('touchmove', () => {
imageContainer.addEventListener('touchmove', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
@@ -334,10 +493,10 @@
});
}
// 初始化右键菜单
// 初始化右键菜单(只在3:5预览区域)
function initContextMenu() {
// 禁用默认右键菜单
canvasArea.addEventListener('contextmenu', (e) => {
// 只在图片容器上禁用默认右键菜单并显示自定义菜单
imageContainer.addEventListener('contextmenu', (e) => {
// 如果右键点击的是输入框,显示默认菜单
if (e.target.closest('.text-input')) {
return;
@@ -363,7 +522,6 @@
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// 复制到剪贴板选项
const copyItem = document.createElement('div');
copyItem.className = 'context-menu-item';
copyItem.textContent = '复制图片';
@@ -372,7 +530,6 @@
hideContextMenu();
});
// 保存图片选项
const saveItem = document.createElement('div');
saveItem.className = 'context-menu-item';
saveItem.textContent = '保存图片';
@@ -402,9 +559,7 @@
}
try {
// 取消所有选中和编辑状态
deselectAll();
const canvas = await generateCanvas();
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
@@ -424,11 +579,9 @@
const canvas = exportCanvas;
const ctx = canvas.getContext('2d');
// 获取容器实际尺寸
const containerWidth = imageContainer.clientWidth;
const containerHeight = imageContainer.clientHeight;
// 设置画布尺寸与容器一致
canvas.width = containerWidth;
canvas.height = containerHeight;
@@ -436,60 +589,53 @@
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 如果有背景图,绘制背景图(保持cover效果)
// 绘制背景图(考虑滑动偏移)
if (currentImage) {
drawImageCover(ctx, currentImage, 0, 0, containerWidth, containerHeight);
drawImageWithOffset(ctx, currentImage, containerWidth, containerHeight);
}
// 绘制文字(保持与预览完全一致的位置和样式)
drawTextOnCanvas(ctx, input1, containerWidth, containerHeight);
drawTextOnCanvas(ctx, input2, containerWidth, containerHeight);
// 绘制文字
drawTextOnCanvas(ctx, input1);
drawTextOnCanvas(ctx, input2);
return canvas;
}
// 绘制图片保持cover效果(与CSS object-fit: cover一致)
function drawImageCover(ctx, img, x, y, width, height) {
// 绘制图片(考虑滑动偏移)
function drawImageWithOffset(ctx, img, containerWidth, containerHeight) {
const imgRatio = img.naturalWidth / img.naturalHeight;
const containerRatio = width / height;
const containerRatio = containerWidth / containerHeight;
let drawWidth, drawHeight, drawX, drawY;
let imgWidth, imgHeight;
if (imgRatio > containerRatio) {
// 图片更宽,以高度为基准
drawHeight = height;
drawWidth = height * imgRatio;
drawX = x + (width - drawWidth) / 2;
drawY = y;
imgHeight = containerHeight;
imgWidth = imgHeight * imgRatio;
} else {
// 图片更高,以宽度为基准
drawWidth = width;
drawHeight = width / imgRatio;
drawX = x;
drawY = y + (height - drawHeight) / 2;
imgWidth = containerWidth;
imgHeight = imgWidth / imgRatio;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
const baseLeft = (containerWidth - imgWidth) / 2;
const drawX = baseLeft + imageOffsetX;
ctx.drawImage(img, drawX, 0, imgWidth, imgHeight);
}
// 在画布上绘制文字(保持与预览完全一致)
function drawTextOnCanvas(ctx, inputElement, containerWidth, containerHeight) {
// 在画布上绘制文字
function drawTextOnCanvas(ctx, inputElement) {
const text = inputElement.textContent;
if (!text) return;
const style = window.getComputedStyle(inputElement);
// 获取输入框位置(相对于容器)
const inputLeft = inputElement.offsetLeft;
const inputTop = inputElement.offsetTop;
// 设置字体样式(与预览完全一致)
const fontSize = parseInt(style.fontSize);
ctx.font = `${style.fontWeight} ${fontSize}px ${style.fontFamily}`;
ctx.fillStyle = style.color;
ctx.textBaseline = 'top';
// 绘制文字(保持与预览完全一致的位置)
ctx.fillText(text, inputLeft, inputTop);
}
@@ -503,7 +649,6 @@
try {
const canvas = await generateCanvas();
// 下载图片
const link = document.createElement('a');
link.download = '邀请函_' + Date.now() + '.png';
link.href = canvas.toDataURL('image/png');
@@ -513,4 +658,12 @@
alert('保存失败,请重试');
}
}
// 窗口大小改变时更新图片位置和边界
window.addEventListener('resize', () => {
if (currentImage) {
calculateImageBounds();
updateImagePosition();
}
});
})();