492 lines
13 KiB
Vue
492 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, useTemplateRef, onUnmounted, watch } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
import { showConfirmDialog } from 'vant';
|
||
|
||
// question 属性
|
||
const element = defineModel<question>('element', { default: {} });
|
||
// 属性框是否激活
|
||
const active = defineModel<boolean>('active', { default: false });
|
||
// 题目索引
|
||
const index = defineModel<number | string>('index', { default: 0 });
|
||
// 答案
|
||
const answer = defineModel<string>('answer', { default: '' });
|
||
// 错误信息
|
||
const errorMessage = defineModel<string>('errorMessage', { default: '' });
|
||
|
||
const isPreview = defineModel('isPreview', { default: false });
|
||
const signatureCanvas = useTemplateRef('signatureCanvas');
|
||
|
||
const canvasWidth = ref(window.innerWidth * 0.8);
|
||
const canvasHeight = computed(() => canvasWidth.value / 1);
|
||
const isEraser = ref(false);
|
||
|
||
let ctx: CanvasRenderingContext2D;
|
||
let isDrawing = false;
|
||
const undoStack = ref<ImageData[]>([]);
|
||
const currentStep = ref(-1);
|
||
|
||
// 保存当前状态
|
||
const saveState = () => {
|
||
if (!ctx || !signatureCanvas.value) return;
|
||
const imageData = ctx.getImageData(
|
||
0,
|
||
0,
|
||
signatureCanvas.value.width,
|
||
signatureCanvas.value.height
|
||
);
|
||
currentStep.value += 1;
|
||
// 移除当前步骤之后的所有状态(处理在撤销后又进行了新的绘制的情况)
|
||
undoStack.value.splice(currentStep.value);
|
||
undoStack.value.push(imageData);
|
||
};
|
||
|
||
// 设置画笔样式
|
||
const setPenStyle = () => {
|
||
if (!ctx) return;
|
||
if (isEraser.value) {
|
||
ctx.globalCompositeOperation = 'destination-out';
|
||
ctx.lineWidth = 20;
|
||
} else {
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
ctx.lineWidth = 2;
|
||
}
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
};
|
||
|
||
// 切换画笔/橡皮擦
|
||
const togglePen = () => {
|
||
isEraser.value = !isEraser.value;
|
||
setPenStyle();
|
||
};
|
||
|
||
const route = useRoute();
|
||
|
||
onMounted(() => {
|
||
// 设置页面刷新标记
|
||
sessionStorage.setItem('is_page_refresh', '1');
|
||
|
||
if (!signatureCanvas.value) return;
|
||
|
||
// 获取 canvas 上下文
|
||
ctx = signatureCanvas.value.getContext('2d')!;
|
||
if (!ctx) return;
|
||
setPenStyle();
|
||
|
||
// 保存初始空白状态
|
||
saveState();
|
||
|
||
// 触摸开始,开始绘制适用于移动设备
|
||
signatureCanvas.value?.addEventListener('touchstart', (e) => {
|
||
// if (!active.value) {
|
||
// return;
|
||
// }
|
||
// 防止页面滚动
|
||
e.preventDefault();
|
||
isDrawing = true;
|
||
const rect = signatureCanvas.value!.getBoundingClientRect();
|
||
const touch = e.touches[0];
|
||
ctx.beginPath();
|
||
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('touchmove', (e) => {
|
||
e.preventDefault();
|
||
if (!isDrawing) return;
|
||
const rect = signatureCanvas.value!.getBoundingClientRect();
|
||
const touch = e.touches[0];
|
||
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||
ctx.stroke();
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('touchend', () => {
|
||
if (isDrawing) {
|
||
saveState();
|
||
}
|
||
isDrawing = false;
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('touchcancel', () => {
|
||
isDrawing = false;
|
||
});
|
||
|
||
// 鼠标事件处理
|
||
signatureCanvas.value?.addEventListener('mousedown', (e) => {
|
||
isDrawing = true;
|
||
const rect = signatureCanvas.value!.getBoundingClientRect();
|
||
ctx.beginPath();
|
||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('mousemove', (e) => {
|
||
if (!isDrawing) return;
|
||
const rect = signatureCanvas.value!.getBoundingClientRect();
|
||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
|
||
ctx.stroke();
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('mouseup', () => {
|
||
if (isDrawing) {
|
||
saveState();
|
||
}
|
||
isDrawing = false;
|
||
});
|
||
|
||
signatureCanvas.value?.addEventListener('mouseleave', () => {
|
||
if (isDrawing) {
|
||
saveState();
|
||
}
|
||
isDrawing = false;
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 清除画布
|
||
*/
|
||
const clearCanvas = () => {
|
||
if (!ctx || !signatureCanvas.value) return;
|
||
ctx.clearRect(0, 0, signatureCanvas.value.width, signatureCanvas.value.height);
|
||
saveState();
|
||
};
|
||
|
||
/**
|
||
* 保存画布
|
||
* @param type {'dataUrl' | 'blob'} 保存类型
|
||
* @return {string | Blob | undefined | null}
|
||
*/
|
||
const saveCanvas = (type: 'dataUrl' | 'blob'): string | Blob | undefined | null => {
|
||
let img: string | Blob | undefined | null = undefined;
|
||
if (!ctx || !signatureCanvas.value) return;
|
||
if (type === 'blob') {
|
||
signatureCanvas.value.toBlob(
|
||
(blob) => {
|
||
img = blob;
|
||
},
|
||
'image/png',
|
||
{ quality: 0.9 }
|
||
);
|
||
return img;
|
||
}
|
||
|
||
if (type === 'dataUrl') return signatureCanvas.value.toDataURL('image/png');
|
||
};
|
||
|
||
/**
|
||
* 撤销
|
||
*/
|
||
const undo = () => {
|
||
if (!ctx || !signatureCanvas.value || currentStep.value <= 0) return;
|
||
currentStep.value -= 1;
|
||
const imageData = undoStack.value[currentStep.value];
|
||
if (imageData) {
|
||
ctx.putImageData(imageData, 0, 0);
|
||
}
|
||
};
|
||
|
||
const emit = defineEmits(['update:element']);
|
||
const emitValue = () => {
|
||
emit('update:element', element.value);
|
||
};
|
||
|
||
let aIndex = 1;
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async function handleUploadImg() {
|
||
// const file = new File([saveCanvas('blob')!], 'sign.png', { type: 'image/png' });
|
||
// const res = await CommonApi.cosUpload(file);
|
||
// console.log(`sign upload url`, res);
|
||
// // 传递答案
|
||
// answer.value = res;
|
||
showConfirmDialog({
|
||
title: '提示',
|
||
message: '上传成功',
|
||
showCancelButton: false
|
||
}).then(() => {
|
||
aIndex++;
|
||
answer.value = 'this is test' + aIndex;
|
||
});
|
||
|
||
saveCanvasState();
|
||
}
|
||
|
||
/**
|
||
* 保存canvas 状态
|
||
* 当组件注销时需要保存状态,存到本地indexDB ,然后等待下次组件加载时恢复
|
||
* 但在页面刷新时不保存
|
||
*/
|
||
function saveCanvasState() {
|
||
if (!ctx || !signatureCanvas.value) return;
|
||
const imageData = ctx.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||
undoStack.value.push(imageData);
|
||
currentStep.value = undoStack.value.length - 1;
|
||
|
||
// 检查是否是页面刷新或关闭
|
||
const isPageRefresh = !sessionStorage.getItem('is_page_refresh');
|
||
|
||
// 如果是页面刷新,则不保存状态
|
||
if (isPageRefresh) {
|
||
console.log('页面刷新,不保存画布状态');
|
||
// 清除之前保存的状态
|
||
if (window.indexedDB) {
|
||
try {
|
||
const request = window.indexedDB.open('canvasState', 1);
|
||
request.onsuccess = (e: Event) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
const tx = db.transaction('canvasState', 'readwrite');
|
||
const store = tx.objectStore('canvasState');
|
||
store.delete(element.value.id || 'default_id');
|
||
tx.oncomplete = () => {
|
||
db.close();
|
||
};
|
||
};
|
||
} catch (err) {
|
||
console.error('Error clearing canvas state:', err);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 没有 indexDB, 不保存
|
||
if (!window.indexedDB) return;
|
||
|
||
// 将 canvas 转换为 base64 字符串
|
||
const canvasDataUrl = signatureCanvas.value.toDataURL('image/png');
|
||
|
||
// 保存到 indexDB
|
||
const request = window.indexedDB.open('canvasState', 1);
|
||
|
||
// 当数据库首次创建或版本升级时创建对象存储
|
||
request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
if (!db.objectStoreNames.contains('canvasState')) {
|
||
db.createObjectStore('canvasState');
|
||
}
|
||
};
|
||
|
||
request.onerror = (e: Event) => {
|
||
console.error('Failed to open indexDB', e);
|
||
};
|
||
|
||
request.onsuccess = (e: Event) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
try {
|
||
const tx = db.transaction('canvasState', 'readwrite');
|
||
const store = tx.objectStore('canvasState');
|
||
// 只存储 base64 字符串,而不是 ImageData 对象
|
||
store.put(
|
||
{ canvasDataUrl, currentStep: currentStep.value },
|
||
element.value.id || 'default_id'
|
||
);
|
||
tx.oncomplete = () => {
|
||
db.close();
|
||
};
|
||
} catch (err) {
|
||
console.error('Error saving canvas state:', err);
|
||
}
|
||
};
|
||
}
|
||
onUnmounted(() => {
|
||
// 当组件注销时,保存状态
|
||
saveCanvasState();
|
||
});
|
||
|
||
/**
|
||
* 恢复canvas 状态
|
||
* 当组件加载时需要从localStorage恢复状态
|
||
*/
|
||
function restoreCanvasState() {
|
||
// 没有 indexDB, 不恢复
|
||
if (!window.indexedDB) return;
|
||
if (!ctx || !signatureCanvas.value) return;
|
||
|
||
const request = window.indexedDB.open('canvasState', 1);
|
||
|
||
// 当数据库首次创建或版本升级时创建对象存储
|
||
request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
if (!db.objectStoreNames.contains('canvasState')) {
|
||
db.createObjectStore('canvasState');
|
||
}
|
||
};
|
||
|
||
request.onerror = (e: Event) => {
|
||
console.error('Failed to open indexDB', e);
|
||
};
|
||
|
||
request.onsuccess = (e: Event) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
try {
|
||
const tx = db.transaction('canvasState', 'readonly');
|
||
const store = tx.objectStore('canvasState');
|
||
const getRequest = store.get(element.value.id || 'default_id');
|
||
|
||
getRequest.onsuccess = (e: Event) => {
|
||
const savedState = (e.target as IDBRequest).result;
|
||
if (savedState && savedState.canvasDataUrl) {
|
||
// 从 base64 字符串恢复 canvas
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||
ctx.drawImage(img, 0, 0);
|
||
// 恢复后获取当前 ImageData 并存入 undoStack
|
||
const imageData = ctx.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||
undoStack.value = [imageData];
|
||
currentStep.value = 0;
|
||
};
|
||
img.src = savedState.canvasDataUrl;
|
||
}
|
||
db.close();
|
||
};
|
||
|
||
getRequest.onerror = (e: Event) => {
|
||
console.error('Error retrieving canvas state:', e);
|
||
db.close();
|
||
};
|
||
} catch (err) {
|
||
console.error('Error restoring canvas state:', err);
|
||
}
|
||
};
|
||
}
|
||
onMounted(() => {
|
||
// 当组件加载时,恢复状态
|
||
restoreCanvasState();
|
||
});
|
||
|
||
/**
|
||
* 清除画布状态
|
||
*/
|
||
function clearCanvasState() {
|
||
if (window.indexedDB) {
|
||
try {
|
||
const request = window.indexedDB.open('canvasState', 1);
|
||
request.onsuccess = (e: Event) => {
|
||
const db = (e.target as IDBOpenDBRequest).result;
|
||
const tx = db.transaction('canvasState', 'readwrite');
|
||
const store = tx.objectStore('canvasState');
|
||
store.delete(element.value.id || 'default_id');
|
||
tx.oncomplete = () => {
|
||
db.close();
|
||
};
|
||
};
|
||
} catch (err) {
|
||
console.error('Error clearing canvas state:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 监听页面卸载事件,用于检测页面刷新
|
||
window.addEventListener('beforeunload', () => {
|
||
// 清除页面刷新标记
|
||
sessionStorage.removeItem('is_page_refresh');
|
||
// 页面刷新时清除画布状态
|
||
clearCanvasState();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<van-field
|
||
:label="element.stem"
|
||
:required="element.config.is_required === 1"
|
||
label-align="top"
|
||
:border="false"
|
||
readonly
|
||
>
|
||
<template #left-icon> {{ isPreview ? element.title : index + 1 }}. </template>
|
||
<template #label>
|
||
<contenteditable
|
||
v-model="element.stem"
|
||
className="contenteditable-label"
|
||
:active="active"
|
||
@blur="emitValue"
|
||
:errorMessage="errorMessage"
|
||
></contenteditable>
|
||
</template>
|
||
<template #input>
|
||
<div class="sign-question">
|
||
<canvas
|
||
ref="signatureCanvas"
|
||
:width="canvasWidth"
|
||
:height="canvasHeight"
|
||
style="border: 1px dashed #ccc; border-radius: 4px"
|
||
>
|
||
</canvas>
|
||
<div class="sign-text" :class="{ show: true }">
|
||
<span
|
||
class="icon mobilefont mobilefont-qingkong"
|
||
title="清空"
|
||
@click="clearCanvas"
|
||
></span>
|
||
<span class="icon mobilefont mobilefont-chexiao" @click="undo"></span>
|
||
<span
|
||
class="icon mobilefont"
|
||
:class="isEraser ? 'mobilefont-huabi' : 'mobilefont-rubber'"
|
||
@click="togglePen"
|
||
></span>
|
||
<span class="icon mobilefont mobilefont-shangchuan" @click="handleUploadImg"></span>
|
||
</div>
|
||
<div class="sign-tips">请在空白区域书写您的签名</div>
|
||
</div>
|
||
</template>
|
||
</van-field>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.sign-question {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 10px 0;
|
||
|
||
// canvas {
|
||
// width: 100%;
|
||
// touch-action: none;
|
||
// }
|
||
|
||
.sign-text {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
width: 100%;
|
||
color: #999;
|
||
font-size: 14px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.3s;
|
||
transform: translate(-50%, -50%);
|
||
|
||
&.show {
|
||
opacity: 0.8;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
span {
|
||
padding: 5px 10px;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
color: #666;
|
||
}
|
||
}
|
||
}
|
||
|
||
.sign-tips {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: max-content;
|
||
color: #cdcdcd;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
}
|
||
</style>
|