fetch: 同步远程仓库最新版本信息
This commit is contained in:
@@ -104,6 +104,49 @@ html, body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑模式:粘在视口顶部始终可见 */
|
||||||
|
.editor-toolbar.editing-mode {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端:允许横向滚动 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-toolbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.editor-toolbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.toolbar-group {
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toolbar-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.toolbar-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.toolbar-label span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.roller-picker {
|
||||||
|
min-width: 50px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.editor-toolbar.active {
|
.editor-toolbar.active {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -381,7 +424,7 @@ html, body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: "Microsoft YaHei", "微软雅黑", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -403,6 +446,19 @@ html, body {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 锁定状态 */
|
||||||
|
.text-input.locked {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input.locked.selected {
|
||||||
|
outline: 2px dashed rgba(255, 193, 7, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input.locked .text-delete-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 空输入框显示 placeholder */
|
/* 空输入框显示 placeholder */
|
||||||
.text-input.show-placeholder::before {
|
.text-input.show-placeholder::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
@@ -513,3 +569,23 @@ html, body {
|
|||||||
.guide-line.visible {
|
.guide-line.visible {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 1px 网格线 */
|
||||||
|
.grid-lines {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
display: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-lines.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|||||||
19
index.html
19
index.html
@@ -20,6 +20,8 @@
|
|||||||
<div class="editor-toolbar" id="editorToolbar">
|
<div class="editor-toolbar" id="editorToolbar">
|
||||||
<!-- 文本编辑工具 -->
|
<!-- 文本编辑工具 -->
|
||||||
<div class="toolbar-group text-tools" id="textTools">
|
<div class="toolbar-group text-tools" id="textTools">
|
||||||
|
<button class="toolbar-btn" id="lockBtn" title="锁定位置">🔒</button>
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
<button class="toolbar-btn" id="boldBtn" title="加粗">B</button>
|
<button class="toolbar-btn" id="boldBtn" title="加粗">B</button>
|
||||||
<button class="toolbar-btn italic" id="italicBtn" title="斜体">I</button>
|
<button class="toolbar-btn italic" id="italicBtn" title="斜体">I</button>
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
@@ -31,10 +33,11 @@
|
|||||||
<label class="toolbar-label">
|
<label class="toolbar-label">
|
||||||
<span>字体</span>
|
<span>字体</span>
|
||||||
<select id="fontPicker">
|
<select id="fontPicker">
|
||||||
<option value="Microsoft YaHei">雅黑</option>
|
<option value="-apple-system, BlinkMacSystemFont, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans CJK SC, sans-serif">系统默认</option>
|
||||||
<option value="SimHei">黑体</option>
|
<option value="PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif">苹方/雅黑</option>
|
||||||
<option value="SimSun">宋体</option>
|
<option value="Hiragino Sans GB, Microsoft YaHei, Noto Sans CJK SC, sans-serif">冬青黑体</option>
|
||||||
<option value="Arial">Arial</option>
|
<option value="SimHei, Microsoft YaHei, Noto Sans CJK SC, sans-serif">黑体</option>
|
||||||
|
<option value="SimSun, Songti SC, serif">宋体</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
@@ -80,17 +83,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 层叠图图层 -->
|
<!-- 层叠图图层 -->
|
||||||
<div class="overlay-layer" id="overlayLayer"></div>
|
<div class="overlay-layer" id="overlayLayer"></div>
|
||||||
|
<!-- 1px 网格线 -->
|
||||||
|
<div class="grid-lines" id="gridLines"></div>
|
||||||
<!-- 拖拽辅助线 -->
|
<!-- 拖拽辅助线 -->
|
||||||
<div class="guide-line guide-horizontal" id="guideH"></div>
|
<div class="guide-line guide-horizontal" id="guideH"></div>
|
||||||
<div class="guide-line guide-vertical" id="guideV"></div>
|
<div class="guide-line guide-vertical" id="guideV"></div>
|
||||||
<!-- 输入框图层 -->
|
<!-- 输入框图层 -->
|
||||||
<div class="preview-input-layer" id="previewInputLayer">
|
<div class="preview-input-layer" id="previewInputLayer">
|
||||||
<!-- 文字输入框1 - 企业 -->
|
<!-- 文字输入框1 - 企业 -->
|
||||||
<div class="text-input hidden" id="input1" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 232.945px;"></div>
|
<div class="text-input hidden" id="input1" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 234.445px;"></div>
|
||||||
<!-- 文字输入框2 - 姓名 -->
|
<!-- 文字输入框2 - 姓名 -->
|
||||||
<div class="text-input hidden" id="input2" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 275.555px;"></div>
|
<div class="text-input hidden" id="input2" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 276.355px;"></div>
|
||||||
<!-- 文字输入框3 - 编号 -->
|
<!-- 文字输入框3 - 编号 -->
|
||||||
<div class="text-input hidden" id="input3" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 317.548px;"></div>
|
<div class="text-input hidden" id="input3" contenteditable="false" data-placeholder="请输入" style="left: 98px; top: 319.048px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 提示文字(最上层,层叠图之下) -->
|
<!-- 提示文字(最上层,层叠图之下) -->
|
||||||
<div class="empty-tip" id="emptyTip">请点击上方「添加图片」按钮上传背景图</div>
|
<div class="empty-tip" id="emptyTip">请点击上方「添加图片」按钮上传背景图</div>
|
||||||
|
|||||||
686
js/script.js
686
js/script.js
@@ -27,6 +27,7 @@
|
|||||||
const bgOpacityValue = document.getElementById('bgOpacityValue');
|
const bgOpacityValue = document.getElementById('bgOpacityValue');
|
||||||
const boldBtn = document.getElementById('boldBtn');
|
const boldBtn = document.getElementById('boldBtn');
|
||||||
const italicBtn = document.getElementById('italicBtn');
|
const italicBtn = document.getElementById('italicBtn');
|
||||||
|
const lockBtn = document.getElementById('lockBtn');
|
||||||
const colorPicker = document.getElementById('colorPicker');
|
const colorPicker = document.getElementById('colorPicker');
|
||||||
const fontPicker = document.getElementById('fontPicker');
|
const fontPicker = document.getElementById('fontPicker');
|
||||||
const fontSizePicker = document.getElementById('fontSizePicker');
|
const fontSizePicker = document.getElementById('fontSizePicker');
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
document.body.appendChild(overlayInput);
|
document.body.appendChild(overlayInput);
|
||||||
const guideH = document.getElementById('guideH');
|
const guideH = document.getElementById('guideH');
|
||||||
const guideV = document.getElementById('guideV');
|
const guideV = document.getElementById('guideV');
|
||||||
|
const gridLines = document.getElementById('gridLines');
|
||||||
|
|
||||||
let currentImage = null;
|
let currentImage = null;
|
||||||
let bgImageScale = 1;
|
let bgImageScale = 1;
|
||||||
@@ -196,7 +198,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
imageContainer.addEventListener('click', handleContainerClick);
|
imageContainer.addEventListener('click', handleContainerClick);
|
||||||
imageContainer.addEventListener('touchend', handleContainerClick);
|
// 移动端:touchend 时如果正在编辑文本,不处理容器点击
|
||||||
|
imageContainer.addEventListener('touchend', (e) => {
|
||||||
|
// 如果触摸目标是文本输入框或正在编辑,不触发容器点击
|
||||||
|
if (e.target.closest('.text-input')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果正在编辑文本,不触发容器点击(避免隐藏工具栏)
|
||||||
|
if (editingInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleContainerClick(e);
|
||||||
|
});
|
||||||
|
|
||||||
// 初始化长按保存(只在3:5预览区域)
|
// 初始化长按保存(只在3:5预览区域)
|
||||||
initLongPressSave();
|
initLongPressSave();
|
||||||
@@ -207,6 +220,9 @@
|
|||||||
// 初始化文本编辑器工具栏
|
// 初始化文本编辑器工具栏
|
||||||
initTextEditorToolbar();
|
initTextEditorToolbar();
|
||||||
|
|
||||||
|
// 初始化键盘微调功能
|
||||||
|
initKeyboardNudge();
|
||||||
|
|
||||||
// 加载默认背景图
|
// 加载默认背景图
|
||||||
loadDefaultBackground();
|
loadDefaultBackground();
|
||||||
|
|
||||||
@@ -450,26 +466,20 @@
|
|||||||
const dx = clientX - startX;
|
const dx = clientX - startX;
|
||||||
const dy = clientY - startY;
|
const dy = clientY - startY;
|
||||||
|
|
||||||
const moveDistance = Math.sqrt(dx * dx + dy * dy);
|
// 移除移动距离限制,实现 1px 精确移动
|
||||||
if (moveDistance > 5) {
|
|
||||||
let newLeft = startLeft + dx;
|
let newLeft = startLeft + dx;
|
||||||
let newTop = startTop + dy;
|
let newTop = startTop + dy;
|
||||||
|
|
||||||
// 使用吸附功能
|
// 不启用吸附,只限制边界,但显示辅助线
|
||||||
const snapped = getSnapPosition(overlayEl, newLeft, newTop);
|
const snapped = getSnapPosition(overlayEl, newLeft, newTop, false);
|
||||||
newLeft = snapped.left;
|
newLeft = snapped.left;
|
||||||
newTop = snapped.top;
|
newTop = snapped.top;
|
||||||
|
|
||||||
// 显示对齐辅助线
|
// 显示对齐辅助线
|
||||||
if (snapped.showGuide) {
|
|
||||||
showAlignmentGuides(overlayEl);
|
showAlignmentGuides(overlayEl);
|
||||||
} else {
|
|
||||||
hideGuides();
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayEl.style.left = newLeft + 'px';
|
overlayEl.style.left = newLeft + 'px';
|
||||||
overlayEl.style.top = newTop + 'px';
|
overlayEl.style.top = newTop + 'px';
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -962,6 +972,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果已锁定,禁止拖拽
|
||||||
|
if (element.classList.contains('locked')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isMouseDown) return;
|
if (!isMouseDown) return;
|
||||||
if (!selectedInput) return;
|
if (!selectedInput) return;
|
||||||
|
|
||||||
@@ -974,7 +989,8 @@
|
|||||||
Math.pow(clientY - clickStartY, 2)
|
Math.pow(clientY - clickStartY, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (moveDistance > 5) {
|
// 只要有移动就开始拖拽,实现 1px 精确移动
|
||||||
|
if (moveDistance > 0) {
|
||||||
hasMoved = true;
|
hasMoved = true;
|
||||||
if (dragTimer) {
|
if (dragTimer) {
|
||||||
clearTimeout(dragTimer);
|
clearTimeout(dragTimer);
|
||||||
@@ -992,17 +1008,13 @@
|
|||||||
const newLeft = startLeft + dx;
|
const newLeft = startLeft + dx;
|
||||||
const newTop = startTop + dy;
|
const newTop = startTop + dy;
|
||||||
|
|
||||||
// 检测对齐并吸附
|
// 不启用吸附,只限制边界,但显示辅助线
|
||||||
const snapped = getSnapPosition(element, newLeft, newTop);
|
const snapped = getSnapPosition(element, newLeft, newTop, false);
|
||||||
element.style.left = snapped.left + 'px';
|
element.style.left = snapped.left + 'px';
|
||||||
element.style.top = snapped.top + 'px';
|
element.style.top = snapped.top + 'px';
|
||||||
|
|
||||||
// 显示对齐辅助线
|
// 显示对齐辅助线
|
||||||
if (snapped.showGuide) {
|
|
||||||
showAlignmentGuides(element);
|
showAlignmentGuides(element);
|
||||||
} else {
|
|
||||||
hideGuides();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 阻止事件冒泡,避免触发图片滑动
|
// 阻止事件冒泡,避免触发图片滑动
|
||||||
@@ -1039,7 +1051,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双击进入编辑模式(电脑端和移动端通用)
|
// 双击进入编辑模式(电脑端)
|
||||||
element.addEventListener('dblclick', (e) => {
|
element.addEventListener('dblclick', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
startEdit(element);
|
startEdit(element);
|
||||||
@@ -1047,22 +1059,17 @@
|
|||||||
setTimeout(() => { element.dataset.justEdited = 'false'; }, 200);
|
setTimeout(() => { element.dataset.justEdited = 'false'; }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 单击选中显示删除按钮
|
// 单击进入编辑模式(同时支持桌面和移动端)
|
||||||
element.addEventListener('click', (e) => {
|
element.addEventListener('click', (e) => {
|
||||||
if (hasMoved) return;
|
if (hasMoved) return;
|
||||||
if (element.dataset.justEdited === 'true') return;
|
if (element.dataset.justEdited === 'true') return;
|
||||||
element.classList.add('selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 触摸双击检测(移动端双击进入编辑)
|
// 如果已经在编辑中,不重复进入
|
||||||
let lastTapTime = 0;
|
if (editingInput === element) return;
|
||||||
element.addEventListener('touchend', (e) => {
|
|
||||||
const currentTime = Date.now();
|
// 选中并立即进入编辑模式
|
||||||
if (currentTime - lastTapTime < 300) {
|
selectInput(element);
|
||||||
e.stopPropagation();
|
|
||||||
startEdit(element);
|
startEdit(element);
|
||||||
}
|
|
||||||
lastTapTime = currentTime;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,9 +1121,26 @@
|
|||||||
imageTools.classList.remove('active');
|
imageTools.classList.remove('active');
|
||||||
bgTools.classList.remove('active');
|
bgTools.classList.remove('active');
|
||||||
|
|
||||||
|
// 移动端/编辑模式:固定在顶部始终可见
|
||||||
|
if (window.innerWidth <= 768 || 'ontouchstart' in window) {
|
||||||
|
editorToolbar.classList.add('editing-mode');
|
||||||
|
// 滚动使工具栏和编辑元素可见
|
||||||
|
setTimeout(() => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const toolbarHeight = editorToolbar.offsetHeight || 50;
|
||||||
|
// 如果文本被工具栏遮挡,滚动页面
|
||||||
|
if (rect.top < toolbarHeight + 10) {
|
||||||
|
window.scrollBy({ top: rect.top - toolbarHeight - 20, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, 300); // 等待键盘弹出
|
||||||
|
}
|
||||||
|
|
||||||
// 更新工具栏状态
|
// 更新工具栏状态
|
||||||
updateToolbarState(element);
|
updateToolbarState(element);
|
||||||
|
|
||||||
|
// 立即 focus 以触发键盘(必须在用户交互的同步上下文中)
|
||||||
|
element.focus();
|
||||||
|
|
||||||
// 如果没有选中文本,则定位光标到末尾
|
// 如果没有选中文本,则定位光标到末尾
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@@ -1127,9 +1151,9 @@
|
|||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
element.focus();
|
}, 0);
|
||||||
}, 10);
|
|
||||||
|
|
||||||
|
// 使用 Virtual Keyboard API(如果支持)
|
||||||
if ('virtualKeyboard' in navigator) {
|
if ('virtualKeyboard' in navigator) {
|
||||||
navigator.virtualKeyboard.show();
|
navigator.virtualKeyboard.show();
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1165,9 @@
|
|||||||
element.classList.add('selected');
|
element.classList.add('selected');
|
||||||
element.contentEditable = 'false';
|
element.contentEditable = 'false';
|
||||||
|
|
||||||
|
// 移除移动端编辑模式样式
|
||||||
|
editorToolbar.classList.remove('editing-mode');
|
||||||
|
|
||||||
// 检查是否需要显示 placeholder
|
// 检查是否需要显示 placeholder
|
||||||
const textContent = Array.from(element.childNodes)
|
const textContent = Array.from(element.childNodes)
|
||||||
.filter(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('text-delete-btn')))
|
.filter(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('text-delete-btn')))
|
||||||
@@ -1178,13 +1205,25 @@
|
|||||||
// 更新颜色选择器
|
// 更新颜色选择器
|
||||||
colorPicker.value = rgbToHex(color);
|
colorPicker.value = rgbToHex(color);
|
||||||
|
|
||||||
// 更新字体选择器
|
// 更新字体选择器 - 匹配字体栈中的主要字体
|
||||||
|
let matched = false;
|
||||||
for (let option of fontPicker.options) {
|
for (let option of fontPicker.options) {
|
||||||
if (option.value === fontFamily || fontFamily.includes(option.value)) {
|
const optionFonts = option.value.toLowerCase().split(',').map(f => f.trim());
|
||||||
|
const currentFonts = fontFamily.toLowerCase().split(',').map(f => f.trim());
|
||||||
|
// 检查是否有主要字体匹配
|
||||||
|
if (optionFonts.some(of => currentFonts.includes(of))) {
|
||||||
fontPicker.value = option.value;
|
fontPicker.value = option.value;
|
||||||
|
matched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 如果没有匹配,默认选择第一个(系统默认)
|
||||||
|
if (!matched && fontPicker.options.length > 0) {
|
||||||
|
fontPicker.value = fontPicker.options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新锁定按钮状态
|
||||||
|
lockBtn.classList.toggle('active', element.classList.contains('locked'));
|
||||||
|
|
||||||
// 更新字号选择器
|
// 更新字号选择器
|
||||||
const fontSizeValueEl = fontSizePicker.querySelector('.roller-value');
|
const fontSizeValueEl = fontSizePicker.querySelector('.roller-value');
|
||||||
@@ -1195,8 +1234,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取吸附后的位置
|
// 获取吸附后的位置(可选吸附,不强制)
|
||||||
function getSnapPosition(draggedEl, newLeft, newTop) {
|
function getSnapPosition(draggedEl, newLeft, newTop, enableSnap = false) {
|
||||||
const container = imageContainer;
|
const container = imageContainer;
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -1217,11 +1256,26 @@
|
|||||||
const cCenterX = cWidth / 2;
|
const cCenterX = cWidth / 2;
|
||||||
const cCenterY = cHeight / 2;
|
const cCenterY = cHeight / 2;
|
||||||
|
|
||||||
const threshold = 5;
|
// 默认返回原始位置(不吸附)
|
||||||
let snapLeft = newLeft;
|
let snapLeft = newLeft;
|
||||||
let snapTop = newTop;
|
let snapTop = newTop;
|
||||||
let showGuide = false;
|
let showGuide = false;
|
||||||
|
|
||||||
|
// 如果不启用吸附,只限制边界并返回
|
||||||
|
if (!enableSnap) {
|
||||||
|
// 边界限制
|
||||||
|
snapLeft = Math.max(0, Math.min(snapLeft, cWidth - elWidth));
|
||||||
|
snapTop = Math.max(0, Math.min(snapTop, cHeight - elHeight));
|
||||||
|
return { left: snapLeft, top: snapTop, showGuide };
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = 5;
|
||||||
|
|
||||||
|
// 显示网格线
|
||||||
|
if (gridLines) {
|
||||||
|
gridLines.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
// 水平垂直分别检测
|
// 水平垂直分别检测
|
||||||
let snapX = false;
|
let snapX = false;
|
||||||
let snapY = false;
|
let snapY = false;
|
||||||
@@ -1257,7 +1311,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检测其他元素对齐(输入框和层叠图)
|
// 检测其他元素对齐(输入框和层叠图)
|
||||||
const otherInputs = [input1, input2].filter(el => el && el !== draggedEl);
|
const otherInputs = [input1, input2, input3].filter(el => el && el !== draggedEl);
|
||||||
const otherOverlays = Array.from(overlayLayer.querySelectorAll('.overlay-image'))
|
const otherOverlays = Array.from(overlayLayer.querySelectorAll('.overlay-image'))
|
||||||
.filter(el => el !== draggedEl);
|
.filter(el => el !== draggedEl);
|
||||||
|
|
||||||
@@ -1380,7 +1434,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用吸附
|
// 应用吸附(仅在启用吸附时)
|
||||||
if (snapX && snapXPos !== null) {
|
if (snapX && snapXPos !== null) {
|
||||||
snapLeft = snapXPos;
|
snapLeft = snapXPos;
|
||||||
showGuide = true;
|
showGuide = true;
|
||||||
@@ -1420,7 +1474,7 @@
|
|||||||
const elCenterX = elLeft + elWidth / 2;
|
const elCenterX = elLeft + elWidth / 2;
|
||||||
const elCenterY = elTop + elHeight / 2;
|
const elCenterY = elTop + elHeight / 2;
|
||||||
|
|
||||||
const threshold = 5;
|
const threshold = 1;
|
||||||
let guideHPos = null;
|
let guideHPos = null;
|
||||||
let guideVPos = null;
|
let guideVPos = null;
|
||||||
|
|
||||||
@@ -1571,6 +1625,9 @@
|
|||||||
function hideGuides() {
|
function hideGuides() {
|
||||||
guideH.classList.remove('visible');
|
guideH.classList.remove('visible');
|
||||||
guideV.classList.remove('visible');
|
guideV.classList.remove('visible');
|
||||||
|
if (gridLines) {
|
||||||
|
gridLines.classList.remove('visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消所有选中
|
// 取消所有选中
|
||||||
@@ -1641,16 +1698,17 @@
|
|||||||
function initTextEditorToolbar() {
|
function initTextEditorToolbar() {
|
||||||
// 加粗按钮
|
// 加粗按钮
|
||||||
boldBtn.addEventListener('click', () => {
|
boldBtn.addEventListener('click', () => {
|
||||||
if (!editingInput) return;
|
const targetInput = editingInput || selectedInput;
|
||||||
|
if (!targetInput) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||||
document.execCommand('bold', false, null);
|
document.execCommand('bold', false, null);
|
||||||
} else {
|
} else {
|
||||||
const isBold = boldBtn.classList.contains('active');
|
const isBold = boldBtn.classList.contains('active');
|
||||||
if (isBold) {
|
if (isBold) {
|
||||||
editingInput.style.fontWeight = 'normal';
|
targetInput.style.fontWeight = 'normal';
|
||||||
} else {
|
} else {
|
||||||
editingInput.style.fontWeight = 'bold';
|
targetInput.style.fontWeight = 'bold';
|
||||||
}
|
}
|
||||||
boldBtn.classList.toggle('active');
|
boldBtn.classList.toggle('active');
|
||||||
}
|
}
|
||||||
@@ -1658,36 +1716,53 @@
|
|||||||
|
|
||||||
// 斜体按钮
|
// 斜体按钮
|
||||||
italicBtn.addEventListener('click', () => {
|
italicBtn.addEventListener('click', () => {
|
||||||
if (!editingInput) return;
|
const targetInput = editingInput || selectedInput;
|
||||||
|
if (!targetInput) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||||
document.execCommand('italic', false, null);
|
document.execCommand('italic', false, null);
|
||||||
} else {
|
} else {
|
||||||
const isItalic = italicBtn.classList.contains('active');
|
const isItalic = italicBtn.classList.contains('active');
|
||||||
if (isItalic) {
|
if (isItalic) {
|
||||||
editingInput.style.fontStyle = 'normal';
|
targetInput.style.fontStyle = 'normal';
|
||||||
} else {
|
} else {
|
||||||
editingInput.style.fontStyle = 'italic';
|
targetInput.style.fontStyle = 'italic';
|
||||||
}
|
}
|
||||||
italicBtn.classList.toggle('active');
|
italicBtn.classList.toggle('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 锁定按钮
|
||||||
|
lockBtn.addEventListener('click', () => {
|
||||||
|
const targetInput = editingInput || selectedInput;
|
||||||
|
if (!targetInput) return;
|
||||||
|
|
||||||
|
const isLocked = targetInput.classList.contains('locked');
|
||||||
|
if (isLocked) {
|
||||||
|
targetInput.classList.remove('locked');
|
||||||
|
} else {
|
||||||
|
targetInput.classList.add('locked');
|
||||||
|
}
|
||||||
|
lockBtn.classList.toggle('active', !isLocked);
|
||||||
|
});
|
||||||
|
|
||||||
// 颜色选择器
|
// 颜色选择器
|
||||||
colorPicker.addEventListener('input', () => {
|
colorPicker.addEventListener('input', () => {
|
||||||
if (!editingInput) return;
|
const targetInput = editingInput || selectedInput;
|
||||||
|
if (!targetInput) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||||||
document.execCommand('foreColor', false, colorPicker.value);
|
document.execCommand('foreColor', false, colorPicker.value);
|
||||||
} else {
|
} else {
|
||||||
editingInput.style.color = colorPicker.value;
|
targetInput.style.color = colorPicker.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 字体选择器
|
// 字体选择器
|
||||||
fontPicker.addEventListener('change', () => {
|
fontPicker.addEventListener('change', () => {
|
||||||
if (!editingInput) return;
|
const targetInput = editingInput || selectedInput;
|
||||||
editingInput.style.fontFamily = fontPicker.value + ', sans-serif';
|
if (!targetInput) return;
|
||||||
|
targetInput.style.fontFamily = fontPicker.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 字号选择器 - 密码锁式滚轮
|
// 字号选择器 - 密码锁式滚轮
|
||||||
@@ -1738,6 +1813,52 @@
|
|||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化键盘微调功能(方向键 1px 移动)
|
||||||
|
function initKeyboardNudge() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// 如果正在编辑文本,不处理方向键
|
||||||
|
if (editingInput) return;
|
||||||
|
|
||||||
|
// 获取当前选中的元素
|
||||||
|
const targetEl = selectedInput || selectedOverlay;
|
||||||
|
if (!targetEl) return;
|
||||||
|
|
||||||
|
// 如果已锁定,禁止键盘微调
|
||||||
|
if (targetEl.classList.contains('locked')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止默认行为(页面滚动)
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLeft = parseInt(targetEl.style.left) || targetEl.offsetLeft;
|
||||||
|
let currentTop = parseInt(targetEl.style.top) || targetEl.offsetTop;
|
||||||
|
|
||||||
|
switch(e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
currentTop -= 1;
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
currentTop += 1;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
currentLeft -= 1;
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
currentLeft += 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用新位置(不启用吸附)
|
||||||
|
targetEl.style.left = currentLeft + 'px';
|
||||||
|
targetEl.style.top = currentTop + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 显示右键菜单
|
// 显示右键菜单
|
||||||
function showContextMenu(x, y) {
|
function showContextMenu(x, y) {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
@@ -1778,10 +1899,49 @@
|
|||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 blob 显示预览
|
||||||
|
function showPreviewFromBlob(blob) {
|
||||||
|
console.log('预览图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||||||
|
const dataUrl = URL.createObjectURL(blob);
|
||||||
|
showPreviewModal(dataUrl, blob);
|
||||||
|
}
|
||||||
|
|
||||||
// 显示预览
|
// 显示预览
|
||||||
async function showPreview() {
|
async function showPreview() {
|
||||||
const canvas = await generateCanvas();
|
const canvas = await generateCanvas();
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
console.log('预览画布尺寸:', canvas.width, 'x', canvas.height);
|
||||||
|
|
||||||
|
// 尝试使用 toDataURL 获取更高质量的图片
|
||||||
|
let dataUrl;
|
||||||
|
let blob;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先尝试 toDataURL
|
||||||
|
dataUrl = canvas.toDataURL('image/png');
|
||||||
|
console.log('预览 toDataURL 成功,dataUrl 长度:', dataUrl.length);
|
||||||
|
|
||||||
|
// 将 dataUrl 转换为 blob
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
blob = await response.blob();
|
||||||
|
console.log('预览 dataUrl 转 blob 成功,blob 尺寸:', blob.size, 'bytes');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('预览 toDataURL 失败,使用 toBlob:', err);
|
||||||
|
// 如果 toDataURL 失败,使用 toBlob
|
||||||
|
blob = await new Promise(resolve => {
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
console.log('预览 toBlob 回调 - blob 尺寸:', b.size, 'bytes');
|
||||||
|
resolve(b);
|
||||||
|
}, 'image/png', 1.0);
|
||||||
|
});
|
||||||
|
dataUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('预览图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||||||
|
showPreviewModal(dataUrl, blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示预览弹窗
|
||||||
|
function showPreviewModal(dataUrl, blob) {
|
||||||
|
|
||||||
const tipOverlay = document.createElement('div');
|
const tipOverlay = document.createElement('div');
|
||||||
tipOverlay.id = 'previewTip';
|
tipOverlay.id = 'previewTip';
|
||||||
@@ -1801,7 +1961,7 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const tipText = document.createElement('p');
|
const tipText = document.createElement('p');
|
||||||
tipText.textContent = '长按图片保存到相册';
|
tipText.innerHTML = '长按图片保存到相册<br><span style="font-size:14px;opacity:0.8">(预览已缩放,实际保存为高清原图)</span>';
|
||||||
tipText.style.cssText = `
|
tipText.style.cssText = `
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -1841,7 +2001,12 @@
|
|||||||
`;
|
`;
|
||||||
saveBtn.onclick = async () => {
|
saveBtn.onclick = async () => {
|
||||||
tipOverlay.remove();
|
tipOverlay.remove();
|
||||||
await saveImageToGallery(dataUrl);
|
await saveImageToGallery(dataUrl, blob);
|
||||||
|
// 重新隐藏 canvas
|
||||||
|
exportCanvas.setAttribute('hidden', '');
|
||||||
|
exportCanvas.style.position = '';
|
||||||
|
exportCanvas.style.left = '';
|
||||||
|
exportCanvas.style.top = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
@@ -1855,7 +2020,14 @@
|
|||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
closeBtn.onclick = () => tipOverlay.remove();
|
closeBtn.onclick = () => {
|
||||||
|
tipOverlay.remove();
|
||||||
|
// 重新隐藏 canvas
|
||||||
|
exportCanvas.setAttribute('hidden', '');
|
||||||
|
exportCanvas.style.position = '';
|
||||||
|
exportCanvas.style.left = '';
|
||||||
|
exportCanvas.style.top = '';
|
||||||
|
};
|
||||||
|
|
||||||
btnContainer.appendChild(saveBtn);
|
btnContainer.appendChild(saveBtn);
|
||||||
btnContainer.appendChild(closeBtn);
|
btnContainer.appendChild(closeBtn);
|
||||||
@@ -1866,13 +2038,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 保存图片到相册
|
// 保存图片到相册
|
||||||
async function saveImageToGallery(dataUrl) {
|
async function saveImageToGallery(dataUrl, blob) {
|
||||||
const filename = '邀请函_' + Date.now() + '.png';
|
const filename = '邀请函_' + Date.now() + '.png';
|
||||||
|
|
||||||
|
// 如果没有传入 blob,尝试从 window._lastExportBlob 获取
|
||||||
|
if (!blob && window._lastExportBlob) {
|
||||||
|
blob = window._lastExportBlob;
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试使用 File System Access API (Android/Chrome)
|
// 尝试使用 File System Access API (Android/Chrome)
|
||||||
if ('showSaveFilePicker' in window) {
|
if ('showSaveFilePicker' in window) {
|
||||||
try {
|
try {
|
||||||
const blob = await (await fetch(dataUrl)).blob();
|
|
||||||
const handle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
suggestedName: filename,
|
suggestedName: filename,
|
||||||
types: [{
|
types: [{
|
||||||
@@ -1893,7 +2069,6 @@
|
|||||||
// 尝试使用 Web Share API (iOS Safari)
|
// 尝试使用 Web Share API (iOS Safari)
|
||||||
if (navigator.share && navigator.canShare) {
|
if (navigator.share && navigator.canShare) {
|
||||||
try {
|
try {
|
||||||
const blob = await (await fetch(dataUrl)).blob();
|
|
||||||
const file = new File([blob], filename, { type: 'image/png' });
|
const file = new File([blob], filename, { type: 'image/png' });
|
||||||
|
|
||||||
if (navigator.canShare({ files: [file] })) {
|
if (navigator.canShare({ files: [file] })) {
|
||||||
@@ -1952,30 +2127,125 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成画布(所见即所得 - 使用显示尺寸)
|
// 检测是否在微信内置浏览器
|
||||||
async function generateCanvas() {
|
function isWechat() {
|
||||||
const canvas = exportCanvas;
|
return /MicroMessenger/i.test(navigator.userAgent);
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const container = imageContainer;
|
|
||||||
|
|
||||||
const width = container.clientWidth;
|
|
||||||
const height = container.clientHeight;
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (currentImage) {
|
|
||||||
drawBgImageOnCanvas(ctx, currentImage, width, height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await drawOverlayImages(ctx);
|
// 生成画布(高清无损,保持与显示容器相同的宽高比)
|
||||||
|
async function generateCanvas() {
|
||||||
|
const canvas = exportCanvas;
|
||||||
|
const container = imageContainer;
|
||||||
|
|
||||||
|
// 获取显示容器的实际尺寸和宽高比
|
||||||
|
const displayWidth = container.clientWidth;
|
||||||
|
const displayHeight = container.clientHeight;
|
||||||
|
const displayRatio = displayWidth / displayHeight;
|
||||||
|
|
||||||
|
// 根据设备像素比决定输出分辨率,移动端至少 2x
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const minScale = Math.max(dpr, 2); // 至少 2x
|
||||||
|
|
||||||
|
// 先计算目标尺寸
|
||||||
|
let TARGET_WIDTH = Math.round(displayWidth * minScale);
|
||||||
|
let TARGET_HEIGHT = Math.round(displayHeight * minScale);
|
||||||
|
|
||||||
|
// 微信特殊处理:微信内置浏览器 canvas 限制更严格
|
||||||
|
// 实测微信中 canvas 最大尺寸约为 4096,但超过 2048 可能有问题
|
||||||
|
const MAX_CANVAS_SIZE = isWechat() ? 2048 : 4096;
|
||||||
|
|
||||||
|
// 如果超出限制,按宽高比等比例缩放
|
||||||
|
if (TARGET_HEIGHT > MAX_CANVAS_SIZE || TARGET_WIDTH > MAX_CANVAS_SIZE) {
|
||||||
|
const maxDimension = Math.max(TARGET_WIDTH, TARGET_HEIGHT);
|
||||||
|
const scaleFactor = MAX_CANVAS_SIZE / maxDimension;
|
||||||
|
TARGET_WIDTH = Math.round(TARGET_WIDTH * scaleFactor);
|
||||||
|
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * scaleFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保最小分辨率,同时保持宽高比
|
||||||
|
const minWidth = 1080;
|
||||||
|
const minHeight = 1920;
|
||||||
|
if (TARGET_WIDTH < minWidth || TARGET_HEIGHT < minHeight) {
|
||||||
|
const widthScale = minWidth / TARGET_WIDTH;
|
||||||
|
const heightScale = minHeight / TARGET_HEIGHT;
|
||||||
|
const scaleFactor = Math.max(widthScale, heightScale);
|
||||||
|
TARGET_WIDTH = Math.round(TARGET_WIDTH * scaleFactor);
|
||||||
|
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * scaleFactor);
|
||||||
|
|
||||||
|
// 再次检查是否超出限制
|
||||||
|
if (TARGET_HEIGHT > MAX_CANVAS_SIZE || TARGET_WIDTH > MAX_CANVAS_SIZE) {
|
||||||
|
const maxDimension = Math.max(TARGET_WIDTH, TARGET_HEIGHT);
|
||||||
|
const limitScale = MAX_CANVAS_SIZE / maxDimension;
|
||||||
|
TARGET_WIDTH = Math.round(TARGET_WIDTH * limitScale);
|
||||||
|
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * limitScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('目标画布尺寸:', TARGET_WIDTH, 'x', TARGET_HEIGHT, 'dpr:', dpr, '微信:', isWechat());
|
||||||
|
console.log('显示容器尺寸:', displayWidth, 'x', displayHeight);
|
||||||
|
|
||||||
|
// 临时显示 canvas 以避免浏览器对隐藏 canvas 的尺寸限制
|
||||||
|
canvas.removeAttribute('hidden');
|
||||||
|
canvas.style.position = 'fixed';
|
||||||
|
canvas.style.left = '-9999px';
|
||||||
|
canvas.style.top = '-9999px';
|
||||||
|
|
||||||
|
// 设置画布为高清分辨率
|
||||||
|
canvas.width = TARGET_WIDTH;
|
||||||
|
canvas.height = TARGET_HEIGHT;
|
||||||
|
|
||||||
|
// 微信中:验证 canvas 尺寸是否设置成功
|
||||||
|
if (isWechat() && (canvas.width !== TARGET_WIDTH || canvas.height !== TARGET_HEIGHT)) {
|
||||||
|
console.warn('Canvas 尺寸设置失败,使用实际尺寸:', canvas.width, 'x', canvas.height);
|
||||||
|
TARGET_WIDTH = canvas.width;
|
||||||
|
TARGET_HEIGHT = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('实际画布尺寸:', canvas.width, 'x', canvas.height);
|
||||||
|
|
||||||
|
// 获取 2d 上下文(必须在设置尺寸之后)
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// 启用最高质量渲染
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// 白色背景
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, TARGET_WIDTH, TARGET_HEIGHT);
|
||||||
|
|
||||||
|
// 计算从显示尺寸到目标分辨率的缩放比例
|
||||||
|
// 使用统一的缩放比例,保持宽高比,确保所见即所得
|
||||||
|
// 注意:必须在 canvas 尺寸验证之后计算 scale
|
||||||
|
const scale = TARGET_WIDTH / displayWidth;
|
||||||
|
console.log('统一缩放比例:', scale, '画布宽高比:', TARGET_WIDTH/TARGET_HEIGHT, '容器宽高比:', displayRatio);
|
||||||
|
|
||||||
|
if (currentImage) {
|
||||||
|
console.log('背景图原始尺寸:', currentImage.naturalWidth, 'x', currentImage.naturalHeight);
|
||||||
|
drawBgImageOnCanvasHD(ctx, currentImage, TARGET_WIDTH, TARGET_HEIGHT, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
await drawOverlayImagesHD(ctx, scale);
|
||||||
|
|
||||||
|
// 等待所有字体加载完成
|
||||||
|
const fontPromises = [];
|
||||||
const textInputs = container.querySelectorAll('.preview-input-layer .text-input');
|
const textInputs = container.querySelectorAll('.preview-input-layer .text-input');
|
||||||
textInputs.forEach(textInput => {
|
textInputs.forEach(textInput => {
|
||||||
drawTextOnCanvas(ctx, textInput);
|
const style = window.getComputedStyle(textInput);
|
||||||
|
const fontFamily = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
|
||||||
|
if (fontFamily && document.fonts) {
|
||||||
|
fontPromises.push(document.fonts.load('16px ' + fontFamily).catch(() => {}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 最多等待 500ms
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(fontPromises),
|
||||||
|
new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
]);
|
||||||
|
|
||||||
|
textInputs.forEach(textInput => {
|
||||||
|
drawTextOnCanvasHD(ctx, textInput, scale);
|
||||||
});
|
});
|
||||||
|
|
||||||
return canvas;
|
return canvas;
|
||||||
@@ -2013,6 +2283,58 @@
|
|||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绘制背景图(高清版本)
|
||||||
|
function drawBgImageOnCanvasHD(ctx, img, canvasWidth, canvasHeight, scale) {
|
||||||
|
// 先按显示容器的尺寸计算(与 updateBgImage 完全一致)
|
||||||
|
const displayContainer = imageContainer;
|
||||||
|
const displayWidth = displayContainer.clientWidth;
|
||||||
|
const displayHeight = displayContainer.clientHeight;
|
||||||
|
const imgRatio = img.naturalWidth / img.naturalHeight;
|
||||||
|
const displayRatio = displayWidth / displayHeight;
|
||||||
|
|
||||||
|
let imgWidth, imgHeight;
|
||||||
|
|
||||||
|
if (imgRatio > displayRatio) {
|
||||||
|
imgHeight = displayHeight;
|
||||||
|
imgWidth = imgHeight * imgRatio;
|
||||||
|
} else {
|
||||||
|
imgWidth = displayWidth;
|
||||||
|
imgHeight = imgWidth / imgRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用缩放(与编辑时的 bgImageScale 一致)
|
||||||
|
const scaledWidth = imgWidth * bgImageScale;
|
||||||
|
const scaledHeight = imgHeight * bgImageScale;
|
||||||
|
|
||||||
|
// 计算位置(与 updateBgImage 函数完全一致)
|
||||||
|
const baseLeft = (displayWidth - scaledWidth) / 2;
|
||||||
|
const displayDrawX = baseLeft + imageOffsetX;
|
||||||
|
const displayDrawY = (displayHeight - scaledHeight) / 2;
|
||||||
|
|
||||||
|
// 将显示坐标转换为 canvas 坐标
|
||||||
|
const drawX = displayDrawX * scale;
|
||||||
|
const drawY = displayDrawY * scale;
|
||||||
|
const canvasScaledWidth = scaledWidth * scale;
|
||||||
|
const canvasScaledHeight = scaledHeight * scale;
|
||||||
|
|
||||||
|
ctx.globalAlpha = bgImageOpacity;
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
console.log('背景图绘制:', {
|
||||||
|
canvas: canvasWidth + 'x' + canvasHeight,
|
||||||
|
scale: scale,
|
||||||
|
displayDrawX: displayDrawX,
|
||||||
|
displayDrawY: displayDrawY,
|
||||||
|
drawX: drawX,
|
||||||
|
drawY: drawY,
|
||||||
|
canvasScaledWidth: canvasScaledWidth,
|
||||||
|
canvasScaledHeight: canvasScaledHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.drawImage(img, drawX, drawY, canvasScaledWidth, canvasScaledHeight);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// 绘制层叠图
|
// 绘制层叠图
|
||||||
function drawOverlayImages(ctx) {
|
function drawOverlayImages(ctx) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -2059,6 +2381,61 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绘制层叠图(高清版本)
|
||||||
|
function drawOverlayImagesHD(ctx, scale) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||||||
|
if (overlayImages.length === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadedCount = 0;
|
||||||
|
const totalImages = overlayImages.length;
|
||||||
|
|
||||||
|
function checkComplete() {
|
||||||
|
loadedCount++;
|
||||||
|
if (loadedCount >= totalImages) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayImages.forEach(overlayEl => {
|
||||||
|
const imgEl = overlayEl.querySelector('img');
|
||||||
|
if (!imgEl) {
|
||||||
|
checkComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 offsetLeft/offsetTop 获取相对于 overlayLayer 的位置,并应用统一缩放
|
||||||
|
const left = overlayEl.offsetLeft * scale;
|
||||||
|
const top = overlayEl.offsetTop * scale;
|
||||||
|
const width = overlayEl.offsetWidth * scale;
|
||||||
|
const height = overlayEl.offsetHeight * scale;
|
||||||
|
|
||||||
|
ctx.globalAlpha = overlayEl.style.opacity || 1;
|
||||||
|
|
||||||
|
// 直接使用已加载的图片元素,避免重新加载导致质量下降
|
||||||
|
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
||||||
|
ctx.drawImage(imgEl, left, top, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
checkComplete();
|
||||||
|
} else {
|
||||||
|
// 如果图片未加载完成,等待加载
|
||||||
|
imgEl.onload = () => {
|
||||||
|
ctx.drawImage(imgEl, left, top, width, height);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
checkComplete();
|
||||||
|
};
|
||||||
|
imgEl.onerror = () => {
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
checkComplete();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 在画布上绘制文字
|
// 在画布上绘制文字
|
||||||
function drawTextOnCanvas(ctx, inputElement) {
|
function drawTextOnCanvas(ctx, inputElement) {
|
||||||
const deleteBtn = inputElement.querySelector('.text-delete-btn');
|
const deleteBtn = inputElement.querySelector('.text-delete-btn');
|
||||||
@@ -2088,6 +2465,87 @@
|
|||||||
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在画布上绘制文字(高清版本)
|
||||||
|
function drawTextOnCanvasHD(ctx, inputElement, scale) {
|
||||||
|
const deleteBtn = inputElement.querySelector('.text-delete-btn');
|
||||||
|
let text = inputElement.textContent;
|
||||||
|
if (deleteBtn) {
|
||||||
|
text = text.replace(deleteBtn.textContent, '');
|
||||||
|
}
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
// 使用 offsetLeft/offsetTop 获取相对于 preview-input-layer 的位置,并应用统一缩放
|
||||||
|
const inputLeft = inputElement.offsetLeft * scale;
|
||||||
|
const inputTop = inputElement.offsetTop * scale;
|
||||||
|
|
||||||
|
// 强制刷新样式,确保获取最新计算值
|
||||||
|
inputElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(inputElement);
|
||||||
|
const originalFontSize = parseInt(style.fontSize);
|
||||||
|
const fontSize = originalFontSize * scale; // 字体大小也按比例缩放
|
||||||
|
|
||||||
|
// lineHeight 可能是像素值或数字(倍数),需要正确处理
|
||||||
|
let lineHeight = parseFloat(style.lineHeight);
|
||||||
|
// 如果 lineHeight 是数字(小于 10 认为是倍数),转换为像素值
|
||||||
|
if (lineHeight && lineHeight < 10) {
|
||||||
|
lineHeight = lineHeight * originalFontSize * scale;
|
||||||
|
} else if (lineHeight) {
|
||||||
|
// 已经是像素值,按比例缩放
|
||||||
|
lineHeight = lineHeight * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理字体粗细 - 统一转换为标准格式
|
||||||
|
let fontWeight = style.fontWeight;
|
||||||
|
// 数值转换为关键字
|
||||||
|
if (fontWeight === '400' || fontWeight === 'normal') fontWeight = 'normal';
|
||||||
|
else if (fontWeight === '700' || fontWeight === 'bold') fontWeight = 'bold';
|
||||||
|
|
||||||
|
// 获取斜体样式
|
||||||
|
let fontStyle = style.fontStyle;
|
||||||
|
if (fontStyle === 'oblique') fontStyle = 'italic';
|
||||||
|
else if (fontStyle !== 'italic') fontStyle = 'normal';
|
||||||
|
|
||||||
|
// 清理字体名称中的引号
|
||||||
|
let fontFamily = style.fontFamily.replace(/['"]/g, '');
|
||||||
|
// 确保字体堆栈有效
|
||||||
|
if (!fontFamily.includes('sans-serif') && !fontFamily.includes('serif') && !fontFamily.includes('monospace')) {
|
||||||
|
fontFamily += ', sans-serif';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用高清字体渲染 - 按标准顺序:style weight size family
|
||||||
|
const fontString = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||||
|
ctx.font = fontString;
|
||||||
|
ctx.fillStyle = style.color;
|
||||||
|
|
||||||
|
// 调试:输出实际应用的字体样式
|
||||||
|
console.log('文本绘制样式:', {
|
||||||
|
text: text.substring(0, 10) + (text.length > 10 ? '...' : ''),
|
||||||
|
fontString: fontString,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
fontStyle: fontStyle,
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
originalFontFamily: style.fontFamily,
|
||||||
|
color: style.color,
|
||||||
|
actualFont: ctx.font,
|
||||||
|
isWechat: isWechat()
|
||||||
|
});
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
|
||||||
|
// 微信中:开启文字抗锯齿
|
||||||
|
if (isWechat()) {
|
||||||
|
ctx.textRendering = 'optimizeLegibility';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算文本垂直偏移量,使其与 DOM 渲染一致
|
||||||
|
// 使用缩放后的 lineHeight 计算
|
||||||
|
const actualLineHeight = lineHeight || fontSize * 1.4;
|
||||||
|
const textOffsetY = (actualLineHeight - fontSize) / 2 + fontSize * 0.85;
|
||||||
|
|
||||||
|
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存图片
|
// 保存图片
|
||||||
async function saveImage() {
|
async function saveImage() {
|
||||||
if (!currentImage) {
|
if (!currentImage) {
|
||||||
@@ -2097,7 +2555,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const canvas = await generateCanvas();
|
const canvas = await generateCanvas();
|
||||||
showMobileSaveTip();
|
await showMobileSaveTip(canvas);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('保存失败:', err);
|
console.error('保存失败:', err);
|
||||||
alert('保存失败,请重试');
|
alert('保存失败,请重试');
|
||||||
@@ -2105,11 +2563,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示移动端保存选项
|
// 显示移动端保存选项
|
||||||
function showMobileSaveTip() {
|
async function showMobileSaveTip(canvas) {
|
||||||
const canvas = exportCanvas;
|
console.log('导出画布尺寸:', canvas.width, 'x', canvas.height);
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
|
||||||
|
// 在 toBlob 之前再次确认 canvas 尺寸
|
||||||
|
console.log('toBlob 前 canvas 尺寸:', canvas.width, 'x', canvas.height);
|
||||||
|
|
||||||
|
// 微信中使用 toBlob 更稳定,且质量参数 1.0 确保无损
|
||||||
|
let dataUrl;
|
||||||
|
let blob;
|
||||||
|
|
||||||
|
// 微信中优先使用 toBlob,避免 toDataURL 的内存问题
|
||||||
|
if (isWechat()) {
|
||||||
|
try {
|
||||||
|
blob = await new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
if (b) {
|
||||||
|
console.log('微信 toBlob 成功,blob 尺寸:', b.size, 'bytes');
|
||||||
|
resolve(b);
|
||||||
|
} else {
|
||||||
|
reject(new Error('toBlob 返回 null'));
|
||||||
|
}
|
||||||
|
}, 'image/png', 1.0); // 1.0 = 最高质量无损 PNG
|
||||||
|
});
|
||||||
|
dataUrl = URL.createObjectURL(blob);
|
||||||
|
} catch (err) {
|
||||||
|
console.log('微信 toBlob 失败,回退到 toDataURL:', err);
|
||||||
|
dataUrl = canvas.toDataURL('image/png');
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
blob = await response.blob();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非微信环境,先尝试 toDataURL
|
||||||
|
try {
|
||||||
|
dataUrl = canvas.toDataURL('image/png');
|
||||||
|
console.log('toDataURL 成功,dataUrl 长度:', dataUrl.length);
|
||||||
|
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
blob = await response.blob();
|
||||||
|
console.log('dataUrl 转 blob 成功,blob 尺寸:', blob.size, 'bytes');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('toDataURL 失败,使用 toBlob:', err);
|
||||||
|
blob = await new Promise(resolve => {
|
||||||
|
canvas.toBlob((b) => {
|
||||||
|
console.log('toBlob 回调 - blob 尺寸:', b.size, 'bytes');
|
||||||
|
resolve(b);
|
||||||
|
}, 'image/png', 1.0);
|
||||||
|
});
|
||||||
|
dataUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('导出图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||||||
const filename = '邀请函_' + Date.now() + '.png';
|
const filename = '邀请函_' + Date.now() + '.png';
|
||||||
|
|
||||||
|
// 保存 blob 供后续使用
|
||||||
|
window._lastExportBlob = blob;
|
||||||
|
|
||||||
const tipOverlay = document.createElement('div');
|
const tipOverlay = document.createElement('div');
|
||||||
tipOverlay.id = 'mobileSaveTip';
|
tipOverlay.id = 'mobileSaveTip';
|
||||||
tipOverlay.style.cssText = `
|
tipOverlay.style.cssText = `
|
||||||
@@ -2159,7 +2669,7 @@
|
|||||||
`;
|
`;
|
||||||
saveBtn.onclick = async () => {
|
saveBtn.onclick = async () => {
|
||||||
tipOverlay.remove();
|
tipOverlay.remove();
|
||||||
await saveImageToGallery(dataUrl);
|
await saveImageToGallery(dataUrl, blob);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
const cancelBtn = document.createElement('button');
|
||||||
@@ -2174,11 +2684,23 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
cancelBtn.onclick = () => tipOverlay.remove();
|
cancelBtn.onclick = () => {
|
||||||
|
tipOverlay.remove();
|
||||||
|
// 重新隐藏 canvas
|
||||||
|
exportCanvas.setAttribute('hidden', '');
|
||||||
|
exportCanvas.style.position = '';
|
||||||
|
exportCanvas.style.left = '';
|
||||||
|
exportCanvas.style.top = '';
|
||||||
|
};
|
||||||
|
|
||||||
tipOverlay.addEventListener('click', (e) => {
|
tipOverlay.addEventListener('click', (e) => {
|
||||||
if (e.target === tipOverlay) {
|
if (e.target === tipOverlay) {
|
||||||
tipOverlay.remove();
|
tipOverlay.remove();
|
||||||
|
// 重新隐藏 canvas
|
||||||
|
exportCanvas.setAttribute('hidden', '');
|
||||||
|
exportCanvas.style.position = '';
|
||||||
|
exportCanvas.style.left = '';
|
||||||
|
exportCanvas.style.top = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2204,7 +2726,7 @@
|
|||||||
`;
|
`;
|
||||||
previewBtn.onclick = () => {
|
previewBtn.onclick = () => {
|
||||||
tipOverlay.remove();
|
tipOverlay.remove();
|
||||||
showPreview();
|
showPreviewFromBlob(blob);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 插入到标题后面
|
// 插入到标题后面
|
||||||
|
|||||||
Reference in New Issue
Block a user