优化交互
This commit is contained in:
@@ -1,7 +1,44 @@
|
||||
<script setup>
|
||||
import { Image as ImageIcon } from 'lucide-vue-next'
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import {
|
||||
Scissors, Copy, ClipboardPaste, Image as ImageIcon,
|
||||
ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
||||
Trash2, X
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps(['x', 'y'])
|
||||
const emit = defineEmits(['action', 'close'])
|
||||
const menuRef = ref(null)
|
||||
|
||||
// 实际渲染坐标
|
||||
const adjustedX = ref(props.x)
|
||||
const adjustedY = ref(props.y)
|
||||
|
||||
// 计算位置,防止溢出
|
||||
const adjustPosition = async () => {
|
||||
adjustedX.value = props.x
|
||||
adjustedY.value = props.y
|
||||
|
||||
await nextTick()
|
||||
if (!menuRef.value) return
|
||||
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// 如果右侧溢出,向左弹出
|
||||
if (rect.right > viewportWidth) {
|
||||
adjustedX.value = props.x - rect.width
|
||||
}
|
||||
|
||||
// 如果底部溢出,向上弹出
|
||||
if (rect.bottom > viewportHeight) {
|
||||
adjustedY.value = props.y - rect.height
|
||||
}
|
||||
}
|
||||
|
||||
// 监听坐标变化重新计算
|
||||
watch(() => [props.x, props.y], adjustPosition, { immediate: true })
|
||||
|
||||
const emitAction = (name) => {
|
||||
emit('action', name)
|
||||
@@ -10,28 +47,73 @@ const emitAction = (name) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bg-white rounded shadow-xl border border-gray-200 py-1 w-48 text-xs text-gray-700 z-[9999]"
|
||||
:style="{ top: y + 'px', left: x + 'px' }"
|
||||
<div ref="menuRef"
|
||||
class="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-1.5 w-52 text-xs text-gray-700 z-[9999]"
|
||||
:style="{ top: adjustedY + 'px', left: adjustedX + 'px' }"
|
||||
@click.stop>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between" @click="emitAction('copy')">
|
||||
<span>复制</span><span class="text-gray-400">Ctrl+C</span>
|
||||
|
||||
<!-- 剪贴板操作 -->
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center" @click="emitAction('cut')">
|
||||
<div class="flex items-center gap-2">
|
||||
<Scissors class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>剪切</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-[10px]">Ctrl+X</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between" @click="emitAction('paste')">
|
||||
<span>粘贴</span><span class="text-gray-400">Ctrl+V</span>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center" @click="emitAction('copy')">
|
||||
<div class="flex items-center gap-2">
|
||||
<Copy class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>复制</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-[10px]">Ctrl+C</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center" @click="emitAction('paste')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ClipboardPaste class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>粘贴</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-[10px]">Ctrl+V</span>
|
||||
</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
|
||||
<!-- 新增:右键插入图片 -->
|
||||
<!-- 插入对象 -->
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertImage')">
|
||||
<ImageIcon class="w-3 h-3 text-gray-500" />
|
||||
<ImageIcon class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>插入图片...</span>
|
||||
</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
|
||||
<!-- 行列操作 -->
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertRowAbove')">
|
||||
<ArrowUp class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>在上方插入 1 行</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertRowBelow')">
|
||||
<ArrowDown class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>在下方插入 1 行</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertColLeft')">
|
||||
<ArrowLeft class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>在左侧插入 1 列</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" @click="emitAction('insertColRight')">
|
||||
<ArrowRight class="w-3.5 h-3.5 text-gray-500" />
|
||||
<span>在右侧插入 1 列</span>
|
||||
</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer" @click="emitAction('insertRowAbove')">在上方插入 1 行</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer" @click="emitAction('insertRowBelow')">在下方插入 1 行</div>
|
||||
<div class="h-px bg-gray-200 my-1"></div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-500" @click="emitAction('deleteRow')">删除所在行</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-500" @click="emitAction('clear')">清除内容</div>
|
||||
|
||||
<!-- 删除操作 -->
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-600 flex items-center gap-2" @click="emitAction('deleteRow')">
|
||||
<Trash2 class="w-3.5 h-3.5 text-red-500" />
|
||||
<span>删除所在行</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-600 flex items-center gap-2" @click="emitAction('deleteCol')">
|
||||
<Trash2 class="w-3.5 h-3.5 text-red-500" />
|
||||
<span>删除所在列</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer text-red-600 flex items-center gap-2" @click="emitAction('clear')">
|
||||
<X class="w-3.5 h-3.5 text-red-500" />
|
||||
<span>清除内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,25 +8,28 @@ import ContextMenu from './ContextMenu.vue'
|
||||
const store = useSpreadsheetStore()
|
||||
const scroller = ref(null)
|
||||
const contextMenu = reactive({ visible: false, x: 0, y: 0 })
|
||||
const hiddenFileInput = ref(null) // 隐藏的文件上传 input
|
||||
const hiddenFileInput = ref(null)
|
||||
|
||||
// 交互状态
|
||||
const isDragging = ref(false)
|
||||
const isFilling = ref(false)
|
||||
|
||||
const HEADER_WIDTH = 40
|
||||
const HEADER_HEIGHT = 32
|
||||
|
||||
// --- 核心:高级复制/粘贴逻辑 ---
|
||||
// --- 辅助方法:自动调整行高 ---
|
||||
const ensureRowHeightForImage = (rowIndex) => {
|
||||
const row = store.rows.find(r => r.index === rowIndex)
|
||||
if (row && row.height < 100) {
|
||||
row.height = 100
|
||||
}
|
||||
}
|
||||
|
||||
const performCopy = async () => {
|
||||
// --- 剪贴板逻辑 (复制/剪切/粘贴) ---
|
||||
|
||||
const performCopy = async (isCut = false) => {
|
||||
try {
|
||||
const { minR, maxR, minC, maxC } = store
|
||||
|
||||
// 1. 构建用于内部粘贴的完整数据结构 (2D Array of JSON)
|
||||
const jsonGrid = []
|
||||
|
||||
// 2. 构建 HTML 和 Text
|
||||
let text = ''
|
||||
let html = '<table border="1">'
|
||||
|
||||
@@ -34,22 +37,17 @@ const performCopy = async () => {
|
||||
const rowJson = []
|
||||
const rowValues = []
|
||||
html += '<tr>'
|
||||
|
||||
for (let c = minC; c <= maxC; c++) {
|
||||
const cell = store.getCell(r, c)
|
||||
// 深度拷贝单元格数据
|
||||
rowJson.push(JSON.parse(JSON.stringify(cell)))
|
||||
|
||||
// 保存完整状态到 JSON Grid
|
||||
rowJson.push(JSON.parse(JSON.stringify(cell))) // Deep copy
|
||||
|
||||
// 文本格式
|
||||
let val = cell.value == null ? '' : String(cell.value)
|
||||
rowValues.push(val)
|
||||
|
||||
// HTML 格式 (外部兼容)
|
||||
// 构建 HTML
|
||||
let cellHtml = val.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
if (cell.type === 'image') {
|
||||
cellHtml = `<img src="${val}" alt="image" style="max-width:100%;" />`
|
||||
}
|
||||
if (cell.type === 'image') cellHtml = `<img src="${val}" alt="image" style="max-width:100%;" />`
|
||||
|
||||
let styleStr = ''
|
||||
if (cell.style) {
|
||||
@@ -58,34 +56,33 @@ const performCopy = async () => {
|
||||
if (cell.style.bg) styleStr += `background-color:${cell.style.bg};`
|
||||
if (cell.style.align) styleStr += `text-align:${cell.style.align};`
|
||||
}
|
||||
|
||||
html += `<td style="${styleStr}">${cellHtml}</td>`
|
||||
}
|
||||
|
||||
jsonGrid.push(rowJson)
|
||||
text += rowValues.join('\t') + (r < maxR ? '\n' : '')
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</table>'
|
||||
|
||||
// *** 关键:将 JSON 数据嵌入到 HTML 中 ***
|
||||
// 我们使用 data-spreadsheet-json 属性包裹序列化后的数据
|
||||
// 这样在粘贴 HTML 时,我们可以提取这个属性来获得 100% 的还原度
|
||||
// 序列化 JSON 数据并嵌入 HTML 中,实现高保真粘贴
|
||||
const serializedData = JSON.stringify(jsonGrid)
|
||||
const wrappedHtml = `<div data-spreadsheet-json='${serializedData}'>${html}</div>`
|
||||
|
||||
const textBlob = new Blob([text], { type: 'text/plain' })
|
||||
const htmlBlob = new Blob([wrappedHtml], { type: 'text/html' })
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': textBlob,
|
||||
'text/html': htmlBlob
|
||||
'text/plain': new Blob([text], { type: 'text/plain' }),
|
||||
'text/html': new Blob([wrappedHtml], { type: 'text/html' })
|
||||
})
|
||||
])
|
||||
console.log('Copied with Rich Format')
|
||||
|
||||
// 如果是剪切,只标记状态(UI变虚线),不立即删除
|
||||
if (isCut) {
|
||||
store.setCutSelection()
|
||||
} else {
|
||||
store.clearCutSelection() // 普通复制会取消剪切状态
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err)
|
||||
console.error('剪贴板操作失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,83 +90,78 @@ const performPaste = async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
let pasted = false
|
||||
|
||||
for (const item of items) {
|
||||
if (item.types.includes('text/html')) {
|
||||
const blob = await item.getType('text/html')
|
||||
const html = await blob.text()
|
||||
|
||||
// 1. 尝试提取内部 JSON 数据
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const wrapper = doc.querySelector('[data-spreadsheet-json]')
|
||||
|
||||
if (wrapper) {
|
||||
// 命中!这是我们自己的数据,完美还原
|
||||
try {
|
||||
const jsonGrid = JSON.parse(wrapper.dataset.spreadsheetJson)
|
||||
pasteJsonGrid(jsonGrid)
|
||||
// 优先尝试高保真 JSON 粘贴
|
||||
pasteJsonGrid(JSON.parse(wrapper.dataset.spreadsheetJson))
|
||||
pasted = true
|
||||
console.log('Pasted High-Fidelity JSON')
|
||||
} catch (e) {
|
||||
console.error('Failed to parse internal JSON', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pasted) {
|
||||
// 2. 没有 JSON,走普通 HTML 解析 (保留图片链接等)
|
||||
pasteHtml(html)
|
||||
pasted = true
|
||||
console.log('Pasted HTML')
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!pasted) { pasteHtml(html); pasted = true; }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pasted) {
|
||||
// 3. 降级到纯文本
|
||||
const text = await navigator.clipboard.readText()
|
||||
pasteText(text)
|
||||
console.log('Pasted Text')
|
||||
}
|
||||
|
||||
// 粘贴完成后,检查是否有剪切状态。如果有,执行真正的剪切(清除原数据)。
|
||||
if (store.cutSelection) {
|
||||
store.executeCut()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Paste failed', err)
|
||||
// 降级逻辑:直接读取文本
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) pasteText(text)
|
||||
if (store.cutSelection) store.executeCut()
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 粘贴 JSON Grid (高保真)
|
||||
const pasteJsonGrid = (grid) => {
|
||||
const startR = store.selection.start.r
|
||||
const startC = store.selection.start.c
|
||||
|
||||
grid.forEach((row, rIndex) => {
|
||||
row.forEach((cellData, cIndex) => {
|
||||
const targetR = startR + rIndex
|
||||
const targetC = startC + cIndex
|
||||
if (targetR < store.rows.length && targetC < store.cols.length) {
|
||||
// 直接覆盖整个 Cell 对象 (包括 style, type, value)
|
||||
store.setCell(targetR, targetC, cellData)
|
||||
// 如果是图片,自动撑开行高
|
||||
if (cellData.type === 'image') {
|
||||
ensureRowHeightForImage(targetR)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 粘贴 HTML (普通)
|
||||
const pasteHtml = (html) => {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const trs = doc.querySelectorAll('tr')
|
||||
|
||||
if (trs.length === 0) {
|
||||
const img = doc.querySelector('img')
|
||||
if(img && img.src) store.setCell(store.selection.start.r, store.selection.start.c, { type:'image', value: img.src })
|
||||
if(img && img.src) {
|
||||
store.setCell(store.selection.start.r, store.selection.start.c, { type:'image', value: img.src })
|
||||
ensureRowHeightForImage(store.selection.start.r)
|
||||
}
|
||||
else pasteText(doc.body.textContent)
|
||||
return
|
||||
}
|
||||
|
||||
const startR = store.selection.start.r
|
||||
const startC = store.selection.start.c
|
||||
|
||||
trs.forEach((tr, rIndex) => {
|
||||
const tds = tr.querySelectorAll('td, th')
|
||||
tds.forEach((td, cIndex) => {
|
||||
@@ -179,11 +171,9 @@ const pasteHtml = (html) => {
|
||||
const img = td.querySelector('img')
|
||||
if (img && img.src) {
|
||||
store.setCell(targetR, targetC, { value: img.src, type: 'image' })
|
||||
} else {
|
||||
// 这里我们只恢复文本,不恢复样式(因为外部 HTML 样式太复杂)
|
||||
// 如果需要,可以解析 td.style
|
||||
store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
|
||||
ensureRowHeightForImage(targetR)
|
||||
}
|
||||
else store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -194,7 +184,6 @@ const pasteText = (text) => {
|
||||
const rows = text.split(/\r\n|\n|\r/)
|
||||
const startR = store.selection.start.r
|
||||
const startC = store.selection.start.c
|
||||
|
||||
rows.forEach((rowStr, rIndex) => {
|
||||
if (rIndex === rows.length - 1 && rowStr === '') return
|
||||
const cols = rowStr.split('\t')
|
||||
@@ -205,6 +194,7 @@ const pasteText = (text) => {
|
||||
const cleanVal = val.trim()
|
||||
if (cleanVal.startsWith('data:image/') || /\.(jpeg|jpg|gif|png|webp)/i.test(cleanVal)) {
|
||||
store.setCell(targetR, targetC, { value: cleanVal, type: 'image' })
|
||||
ensureRowHeightForImage(targetR)
|
||||
} else {
|
||||
store.setCell(targetR, targetC, { value: val, type: 'text' })
|
||||
}
|
||||
@@ -213,34 +203,23 @@ const pasteText = (text) => {
|
||||
})
|
||||
}
|
||||
|
||||
// --- 右键上传图片逻辑 ---
|
||||
|
||||
const triggerHiddenFileInput = () => {
|
||||
hiddenFileInput.value.click()
|
||||
}
|
||||
|
||||
const handleContextMenuImageUpload = async (e) => {
|
||||
// --- 图片上传 ---
|
||||
const triggerHiddenFileInput = () => hiddenFileInput.value.click()
|
||||
const handleContextMenuImageUpload = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (evt) => {
|
||||
const base64 = evt.target.result
|
||||
// 插入到当前选中单元格
|
||||
store.setCell(store.selection.start.r, store.selection.start.c, {
|
||||
type: 'image',
|
||||
value: base64
|
||||
})
|
||||
// 自动调整当前行高以便预览
|
||||
const row = store.rows.find(r => r.index === store.selection.start.r)
|
||||
if (row) row.height = 100
|
||||
const r = store.selection.start.r
|
||||
store.setCell(r, store.selection.start.c, { type: 'image', value: base64 })
|
||||
ensureRowHeightForImage(r)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = '' // reset
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// --- 基础交互 ---
|
||||
|
||||
// --- 鼠标交互 ---
|
||||
const getCellByPoint = (x, y) => {
|
||||
if (!scroller.value) return { r: -1, c: -1 }
|
||||
const contentEl = scroller.value.firstElementChild
|
||||
@@ -248,7 +227,6 @@ const getCellByPoint = (x, y) => {
|
||||
const rect = contentEl.getBoundingClientRect()
|
||||
const relX = x - rect.left - HEADER_WIDTH
|
||||
const relY = y - rect.top - HEADER_HEIGHT
|
||||
|
||||
if (relX < 0 || relY < 0) return { r: -1, c: -1 }
|
||||
|
||||
let c = 0, currentX = 0
|
||||
@@ -256,7 +234,6 @@ const getCellByPoint = (x, y) => {
|
||||
currentX += store.cols[c].width
|
||||
c++
|
||||
}
|
||||
|
||||
let r = 0, currentY = 0
|
||||
while(r < store.rows.length && currentY + store.rows[r].height < relY) {
|
||||
currentY += store.rows[r].height
|
||||
@@ -290,15 +267,18 @@ const handleMouseUp = () => {
|
||||
}
|
||||
if (isFilling.value) {
|
||||
isFilling.value = false
|
||||
const { r, c } = store.selection.end
|
||||
store.autoFill(r, c)
|
||||
store.autoFill(store.selection.end.r, store.selection.end.c)
|
||||
}
|
||||
}
|
||||
|
||||
// 右键处理
|
||||
const handleContextMenu = (e) => {
|
||||
const { r, c } = getCellByPoint(e.clientX, e.clientY)
|
||||
if (r !== -1 && c !== -1) {
|
||||
if (!store.isRowSelected(r) || !store.isColSelected(c)) store.select(r, c)
|
||||
// 如果点击在选区外,则更新选中;选区内则保留选中状态(方便对选区操作)
|
||||
if (!store.isRowSelected(r) || !store.isColSelected(c)) {
|
||||
store.select(r, c)
|
||||
}
|
||||
}
|
||||
contextMenu.x = e.clientX
|
||||
contextMenu.y = e.clientY
|
||||
@@ -310,37 +290,72 @@ const handleContextAction = (action) => {
|
||||
const c = store.selection.start.c
|
||||
switch(action) {
|
||||
case 'copy': performCopy(); break;
|
||||
case 'cut': performCopy(true); break; // 触发剪切
|
||||
case 'paste': performPaste(); break;
|
||||
case 'insertImage': triggerHiddenFileInput(); break;
|
||||
case 'insertRowAbove': store.insertRow(r, 1); break;
|
||||
case 'insertRowBelow': store.insertRow(r + 1, 1); break;
|
||||
case 'insertColLeft': store.insertCol(c, 1); break;
|
||||
case 'insertColRight': store.insertCol(c + 1, 1); break;
|
||||
case 'deleteRow': store.deleteRow(r); break;
|
||||
case 'clear': store.forEachSelectedCell((row,col) => store.setCell(row,col,{value:''})); break;
|
||||
case 'deleteCol': store.deleteCol(c); break;
|
||||
case 'clear': store.clearSelection(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷键处理
|
||||
const handleKeyDown = (e) => {
|
||||
if (store.isEditing) return
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault(); performCopy(); return
|
||||
const { r, c } = store.selection.start
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
// 1. 系统级操作
|
||||
if (isCtrl) {
|
||||
if (key === 'c') { e.preventDefault(); performCopy(); return }
|
||||
if (key === 'x') { e.preventDefault(); performCopy(true); return } // 剪切
|
||||
if (key === 'v') { e.preventDefault(); performPaste(); return }
|
||||
if (key === 'z') { e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return }
|
||||
if (key === 'y') { e.preventDefault(); store.redo(); return }
|
||||
if (key === 'a') { e.preventDefault(); store.selectAll(); return }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault(); performPaste(); return
|
||||
|
||||
// 2. 导航
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.shiftKey ? (c > 0 && store.select(r, c - 1)) : (c < store.cols.length - 1 && store.select(r, c + 1))
|
||||
return
|
||||
}
|
||||
|
||||
// Esc 取消剪切状态
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
store.clearCutSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) e.preventDefault()
|
||||
if (e.key === 'ArrowUp' && r > 0) store.select(r - 1, c)
|
||||
else if (e.key === 'ArrowDown') store.select(r + 1, c)
|
||||
else if (e.key === 'ArrowLeft' && c > 0) store.select(r, c - 1)
|
||||
else if (e.key === 'ArrowRight') store.select(r, c + 1)
|
||||
|
||||
const current = store.selection.start
|
||||
if (e.key === 'ArrowUp' && current.r > 0) store.select(current.r - 1, current.c)
|
||||
else if (e.key === 'ArrowDown') store.select(current.r + 1, current.c)
|
||||
else if (e.key === 'ArrowLeft' && current.c > 0) store.select(current.r, current.c - 1)
|
||||
else if (e.key === 'ArrowRight') store.select(current.r, current.c + 1)
|
||||
else if (e.key === 'Enter') store.select(current.r + 1, current.c)
|
||||
else if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.forEachSelectedCell((r, c) => store.setCell(r, c, { value: '', type: 'text' }))
|
||||
// 3. 回车行为
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.shiftKey ? (r > 0 && store.select(r - 1, c)) : store.select(r + 1, c)
|
||||
}
|
||||
else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
|
||||
|
||||
// 4. 编辑
|
||||
else if (e.key === 'F2') {
|
||||
e.preventDefault(); store.enableEditing();
|
||||
}
|
||||
else if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.clearSelection()
|
||||
}
|
||||
// 普通输入
|
||||
else if (e.key.length === 1 && !isCtrl && !e.altKey) {
|
||||
e.preventDefault()
|
||||
store.enableEditing()
|
||||
store.editingValue = e.key
|
||||
@@ -359,7 +374,6 @@ watchEffect(() => {
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full outline-none" tabindex="0" @keydown="handleKeyDown">
|
||||
<!-- 隐藏的文件输入,用于右键上传 -->
|
||||
<input type="file" ref="hiddenFileInput" class="hidden" accept="image/*" @change="handleContextMenuImageUpload">
|
||||
|
||||
<div class="w-full h-full overflow-auto bg-[#f4f5f7] relative custom-scrollbar" ref="scroller">
|
||||
@@ -409,8 +423,9 @@ watchEffect(() => {
|
||||
|
||||
<SelectionOverlay
|
||||
:selection="store.selection"
|
||||
:is-editing="store.isEditing"
|
||||
:cut-layout-info="store.cutLayoutInfo"
|
||||
:layout-info="store.layoutInfo"
|
||||
:is-editing="store.isEditing"
|
||||
@fill-drag-start="isFilling = true"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
<script setup>
|
||||
import { computed, ref, reactive } from 'vue'
|
||||
|
||||
const props = defineProps(['selection', 'layoutInfo', 'isEditing'])
|
||||
const props = defineProps(['selection', 'layoutInfo', 'cutLayoutInfo', 'isEditing'])
|
||||
defineEmits(['fill-drag-start'])
|
||||
|
||||
const fillPreview = ref(false)
|
||||
const fillPreviewRect = reactive({ w:0, h:0 })
|
||||
const style = computed(() => {
|
||||
if (!props.layoutInfo) return {}
|
||||
return {
|
||||
top: props.layoutInfo.top + 'px',
|
||||
left: props.layoutInfo.left + 'px',
|
||||
width: props.layoutInfo.width + 'px',
|
||||
height: props.layoutInfo.height + 'px',
|
||||
}
|
||||
})
|
||||
|
||||
const style = computed(() => ({
|
||||
top: props.layoutInfo.top + 'px',
|
||||
left: props.layoutInfo.left + 'px',
|
||||
width: props.layoutInfo.width + 'px',
|
||||
height: props.layoutInfo.height + 'px',
|
||||
}))
|
||||
const cutStyle = computed(() => {
|
||||
if (!props.cutLayoutInfo) return {}
|
||||
return {
|
||||
top: props.cutLayoutInfo.top + 'px',
|
||||
left: props.cutLayoutInfo.left + 'px',
|
||||
width: props.cutLayoutInfo.width + 'px',
|
||||
height: props.cutLayoutInfo.height + 'px',
|
||||
display: 'block'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none z-20 absolute border-2 border-green-500 bg-green-500/10 transition-all duration-75"
|
||||
:style="style">
|
||||
<div v-if="!isEditing"
|
||||
class="pointer-events-auto cursor-crosshair absolute w-2 h-2 bg-green-500 border border-white -bottom-1.5 -right-1.5 z-50"
|
||||
@mousedown.stop="$emit('fill-drag-start', $event)"
|
||||
></div>
|
||||
<div>
|
||||
<!-- Cut Selection (Dashed Animation) -->
|
||||
<div v-if="cutLayoutInfo"
|
||||
class="pointer-events-none z-10 absolute border-2 border-dashed border-gray-400 animate-pulse bg-gray-100/30"
|
||||
:style="cutStyle">
|
||||
</div>
|
||||
|
||||
<!-- Current Selection (Solid Green) -->
|
||||
<div class="pointer-events-none z-20 absolute border-2 border-green-500 bg-green-500/10 transition-all duration-75"
|
||||
:style="style">
|
||||
<div v-if="!isEditing"
|
||||
class="pointer-events-auto cursor-crosshair absolute w-2 h-2 bg-green-500 border border-white -bottom-1.5 -right-1.5 z-50"
|
||||
@mousedown.stop="$emit('fill-drag-start', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,17 +4,21 @@ import { db } from '@/utils/db'
|
||||
|
||||
export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
// --- State ---
|
||||
// 初始化 100 行 26 列 (默认空)
|
||||
const rows = ref(Array.from({ length: 100 }, (_, i) => ({ index: i, height: 25 })))
|
||||
const cols = ref(Array.from({ length: 26 }, (_, i) => ({ label: String.fromCharCode(65 + i), width: 100 })))
|
||||
|
||||
// 核心数据:Map<"r_c", CellJSON>
|
||||
const cells = reactive({})
|
||||
|
||||
const selection = reactive({ start: { r: 1, c: 1 }, end: { r: 1, c: 1 } })
|
||||
// New: Track cut selection (source of the cut)
|
||||
const cutSelection = ref(null)
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editingValue = ref('')
|
||||
const loading = ref(true) // 加载状态
|
||||
const loading = ref(true)
|
||||
|
||||
// --- History State (Undo/Redo) ---
|
||||
const history = ref([])
|
||||
const historyIndex = ref(-1)
|
||||
const isUndoing = ref(false)
|
||||
|
||||
// --- Helpers ---
|
||||
const getKey = (r, c) => `${r}_${c}`
|
||||
@@ -22,16 +26,18 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
// --- Persistence ---
|
||||
const SHEET_KEY = 'default_sheet_v1'
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await db.get(SHEET_KEY)
|
||||
if (data) {
|
||||
// 恢复数据
|
||||
for (const key in cells) delete cells[key]
|
||||
Object.assign(cells, data.cells)
|
||||
if (data.rows) rows.value = data.rows
|
||||
if (data.cols) cols.value = data.cols
|
||||
saveSnapshot()
|
||||
} else {
|
||||
saveSnapshot()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load DB', e)
|
||||
@@ -40,15 +46,70 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动保存 (防抖建议在生产环境加,这里直接监听)
|
||||
watch([cells, rows, cols], () => {
|
||||
if (!loading.value) {
|
||||
db.set(SHEET_KEY, {
|
||||
cells,
|
||||
rows: rows.value,
|
||||
cols: cols.value
|
||||
})
|
||||
// --- Snapshot Logic ---
|
||||
const saveSnapshot = (opType = 'mutation') => {
|
||||
if (isUndoing.value) return
|
||||
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1)
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
cells: JSON.parse(JSON.stringify(cells)),
|
||||
rows: JSON.parse(JSON.stringify(rows.value)),
|
||||
cols: JSON.parse(JSON.stringify(cols.value))
|
||||
}
|
||||
|
||||
history.value.push(snapshot)
|
||||
historyIndex.value++
|
||||
|
||||
if (history.value.length > 50) {
|
||||
history.value.shift()
|
||||
historyIndex.value--
|
||||
}
|
||||
|
||||
// Record operation to IndexedDB Log
|
||||
db.addLog(opType, {
|
||||
selection: JSON.parse(JSON.stringify(selection)),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
const restoreSnapshot = (snapshot) => {
|
||||
for (const key in cells) delete cells[key]
|
||||
Object.assign(cells, snapshot.cells)
|
||||
rows.value = snapshot.rows
|
||||
cols.value = snapshot.cols
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
if (historyIndex.value > 0) {
|
||||
isUndoing.value = true
|
||||
historyIndex.value--
|
||||
const prevSnapshot = history.value[historyIndex.value]
|
||||
restoreSnapshot(prevSnapshot)
|
||||
isUndoing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const redo = () => {
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
isUndoing.value = true
|
||||
historyIndex.value++
|
||||
const nextSnapshot = history.value[historyIndex.value]
|
||||
restoreSnapshot(nextSnapshot)
|
||||
isUndoing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let snapshotTimeout = null
|
||||
watch([cells, rows, cols], () => {
|
||||
if (loading.value || isUndoing.value) return
|
||||
clearTimeout(snapshotTimeout)
|
||||
snapshotTimeout = setTimeout(() => {
|
||||
saveSnapshot('auto_save')
|
||||
db.set(SHEET_KEY, { cells, rows: rows.value, cols: cols.value })
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
// --- Getters ---
|
||||
@@ -60,14 +121,8 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
const totalWidth = computed(() => cols.value.reduce((acc, c) => acc + c.width, 0))
|
||||
const totalHeight = computed(() => rows.value.reduce((acc, r) => acc + r.height, 0))
|
||||
|
||||
const getCell = (r, c) => {
|
||||
return cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
|
||||
}
|
||||
|
||||
const currentStyle = computed(() => {
|
||||
const cell = getCell(selection.start.r, selection.start.c)
|
||||
return cell.style || {}
|
||||
})
|
||||
const getCell = (r, c) => cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
|
||||
const currentStyle = computed(() => getCell(selection.start.r, selection.start.c).style || {})
|
||||
|
||||
const activeCellFormula = computed(() => {
|
||||
if (isEditing.value) return editingValue.value
|
||||
@@ -75,38 +130,46 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
return String(cell.value || '')
|
||||
})
|
||||
|
||||
// 计算选区覆盖层位置
|
||||
// Calculate layout info for selection overlay
|
||||
const layoutInfo = computed(() => {
|
||||
const getRowTop = (r) => rows.value.slice(0, r).reduce((sum, row) => sum + row.height, 0)
|
||||
const getColLeft = (c) => cols.value.slice(0, c).reduce((sum, col) => sum + col.width, 0)
|
||||
|
||||
const top = getRowTop(minR.value)
|
||||
const left = getColLeft(minC.value)
|
||||
|
||||
let height = 0
|
||||
for(let i=minR.value; i<=maxR.value; i++) height += rows.value[i].height
|
||||
|
||||
let width = 0
|
||||
for(let i=minC.value; i<=maxC.value; i++) width += cols.value[i].width
|
||||
|
||||
let height = 0; for(let i=minR.value; i<=maxR.value; i++) height += rows.value[i].height
|
||||
let width = 0; for(let i=minC.value; i<=maxC.value; i++) width += cols.value[i].width
|
||||
return { top, left, width, height }
|
||||
})
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
const startR = selection.start.r
|
||||
const startC = selection.start.c
|
||||
const lInfo = layoutInfo.value // 复用计算结果的起始位置,但不包含宽高累加
|
||||
// Calculate layout info for CUT overlay (new)
|
||||
const cutLayoutInfo = computed(() => {
|
||||
if (!cutSelection.value) return null
|
||||
const r1 = Math.min(cutSelection.value.start.r, cutSelection.value.end.r)
|
||||
const r2 = Math.max(cutSelection.value.start.r, cutSelection.value.end.r)
|
||||
const c1 = Math.min(cutSelection.value.start.c, cutSelection.value.end.c)
|
||||
const c2 = Math.max(cutSelection.value.start.c, cutSelection.value.end.c)
|
||||
|
||||
// 重新计算起始点的绝对位置(为了精确性建议封装通用方法,这里简化复用)
|
||||
let top = 0; for(let i=0; i<startR; i++) top += rows.value[i].height
|
||||
let left = 0; for(let i=0; i<startC; i++) left += cols.value[i].width
|
||||
|
||||
const w = cols.value[startC].width
|
||||
const h = rows.value[startR].height
|
||||
const style = getCell(startR, startC).style || {}
|
||||
const getRowTop = (r) => rows.value.slice(0, r).reduce((sum, row) => sum + row.height, 0)
|
||||
const getColLeft = (c) => cols.value.slice(0, c).reduce((sum, col) => sum + col.width, 0)
|
||||
|
||||
return {
|
||||
top: top + 'px', left: left + 'px',
|
||||
top: getRowTop(r1),
|
||||
left: getColLeft(c1),
|
||||
width: cols.value.slice(c1, c2 + 1).reduce((acc, c) => acc + c.width, 0),
|
||||
height: rows.value.slice(r1, r2 + 1).reduce((acc, r) => acc + r.height, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
let sTop = 0; for(let i=0; i<selection.start.r; i++) sTop += rows.value[i].height
|
||||
let sLeft = 0; for(let i=0; i<selection.start.c; i++) sLeft += cols.value[i].width
|
||||
|
||||
const w = cols.value[selection.start.c].width
|
||||
const h = rows.value[selection.start.r].height
|
||||
const style = getCell(selection.start.r, selection.start.c).style || {}
|
||||
|
||||
return {
|
||||
top: sTop + 'px', left: sLeft + 'px',
|
||||
minWidth: w + 'px', minHeight: h + 'px',
|
||||
textAlign: style.align || 'left',
|
||||
fontWeight: style.bold ? 'bold' : 'normal',
|
||||
@@ -118,14 +181,8 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
// --- Actions ---
|
||||
function setCell(r, c, partialData) {
|
||||
const key = getKey(r, c)
|
||||
if (!cells[key]) {
|
||||
cells[key] = { type: 'text', value: '', style: {} }
|
||||
}
|
||||
|
||||
// 深度合并逻辑 (这里做简单合并,生产环境建议用 lodash.merge)
|
||||
if (partialData.style) {
|
||||
cells[key].style = { ...cells[key].style, ...partialData.style }
|
||||
}
|
||||
if (!cells[key]) cells[key] = { type: 'text', value: '', style: {} }
|
||||
if (partialData.style) cells[key].style = { ...cells[key].style, ...partialData.style }
|
||||
if (partialData.value !== undefined) cells[key].value = partialData.value
|
||||
if (partialData.type !== undefined) cells[key].type = partialData.type
|
||||
}
|
||||
@@ -135,12 +192,10 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
selection.start = { r, c }
|
||||
selection.end = { r, c }
|
||||
}
|
||||
|
||||
function extendSelection(r, c) {
|
||||
if (isEditing.value) return
|
||||
selection.end = { r, c }
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selection.start = { r: 0, c: 0 }
|
||||
selection.end = { r: rows.value.length - 1, c: cols.value.length - 1 }
|
||||
@@ -151,13 +206,11 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
const cell = getCell(selection.start.r, selection.start.c)
|
||||
editingValue.value = String(cell.value || '')
|
||||
}
|
||||
|
||||
function finishEditing() {
|
||||
if (!isEditing.value) return
|
||||
setCell(selection.start.r, selection.start.c, { value: editingValue.value })
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function updateActiveCell(val) {
|
||||
setCell(selection.start.r, selection.start.c, { value: val })
|
||||
}
|
||||
@@ -172,54 +225,101 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
|
||||
function toggleStyle(prop) {
|
||||
const current = !!currentStyle.value[prop]
|
||||
forEachSelectedCell((r, c) => {
|
||||
setCell(r, c, { style: { [prop]: !current } })
|
||||
})
|
||||
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: !current } }))
|
||||
}
|
||||
function setStyle(prop, value) {
|
||||
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: value } }))
|
||||
}
|
||||
|
||||
function setStyle(prop, value) {
|
||||
forEachSelectedCell((r, c) => {
|
||||
setCell(r, c, { style: { [prop]: value } })
|
||||
// --- Data Shifting ---
|
||||
const shiftCells = (type, index, count, direction) => {
|
||||
const newCells = {}
|
||||
Object.entries(cells).forEach(([key, cell]) => {
|
||||
const [r, c] = key.split('_').map(Number)
|
||||
let newR = r, newC = c, shouldDelete = false
|
||||
if (type === 'row') {
|
||||
if (direction === 1) { if (r >= index) newR = r + count }
|
||||
else { if (r >= index && r < index + count) shouldDelete = true; else if (r >= index + count) newR = r - count }
|
||||
} else {
|
||||
if (direction === 1) { if (c >= index) newC = c + count }
|
||||
else { if (c >= index && c < index + count) shouldDelete = true; else if (c >= index + count) newC = c - count }
|
||||
}
|
||||
if (!shouldDelete) newCells[`${newR}_${newC}`] = cell
|
||||
})
|
||||
for (const key in cells) delete cells[key]
|
||||
Object.assign(cells, newCells)
|
||||
}
|
||||
|
||||
function insertRow(index, count = 1) {
|
||||
// 实际项目中需要处理数据的下移(修改 cells 的 key),这里仅演示 UI 层的插入
|
||||
for(let k=0; k<count; k++) {
|
||||
rows.value.splice(index, 0, { index: -1, height: 25 })
|
||||
}
|
||||
saveSnapshot('insert_row')
|
||||
for(let k=0; k<count; k++) rows.value.splice(index, 0, { index: -1, height: 25 })
|
||||
rows.value.forEach((r, i) => r.index = i)
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
shiftCells('row', index, count, 1)
|
||||
}
|
||||
|
||||
function deleteRow(index, count = 1) {
|
||||
saveSnapshot('delete_row')
|
||||
rows.value.splice(index, count)
|
||||
rows.value.forEach((r, i) => r.index = i)
|
||||
shiftCells('row', index, count, -1)
|
||||
}
|
||||
|
||||
function insertCol(index, count = 1) {
|
||||
for(let k=0; k<count; k++) {
|
||||
cols.value.splice(index, 0, { label: 'New', width: 100 })
|
||||
}
|
||||
// Re-label
|
||||
cols.value.forEach((col, i) => {
|
||||
col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26))
|
||||
})
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
saveSnapshot('insert_col')
|
||||
for(let k=0; k<count; k++) cols.value.splice(index, 0, { label: 'New', width: 100 })
|
||||
cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26)))
|
||||
shiftCells('col', index, count, 1)
|
||||
}
|
||||
|
||||
function deleteRow(index) {
|
||||
rows.value.splice(index, 1)
|
||||
rows.value.forEach((r, i) => r.index = i)
|
||||
// TODO: 实现 Data Shift 逻辑
|
||||
function deleteCol(index, count = 1) {
|
||||
saveSnapshot('delete_col')
|
||||
cols.value.splice(index, count)
|
||||
cols.value.forEach((col, i) => col.label = i < 26 ? String.fromCharCode(65 + i) : 'A' + String.fromCharCode(65 + (i%26)))
|
||||
shiftCells('col', index, count, -1)
|
||||
}
|
||||
|
||||
// --- Cut/Paste/Clear ---
|
||||
|
||||
function setCutSelection() {
|
||||
// Deep copy current selection to be the cut source
|
||||
cutSelection.value = JSON.parse(JSON.stringify(selection))
|
||||
}
|
||||
|
||||
function clearCutSelection() {
|
||||
cutSelection.value = null
|
||||
}
|
||||
|
||||
// Used by "Delete" key or "Clear" menu
|
||||
function clearSelection() {
|
||||
forEachSelectedCell((r, c) => setCell(r, c, { value: '', type: 'text' }))
|
||||
}
|
||||
|
||||
// Execute the "Cut" action: Clear the source cells (Called AFTER paste)
|
||||
function executeCut() {
|
||||
if (!cutSelection.value) return
|
||||
|
||||
const r1 = Math.min(cutSelection.value.start.r, cutSelection.value.end.r)
|
||||
const r2 = Math.max(cutSelection.value.start.r, cutSelection.value.end.r)
|
||||
const c1 = Math.min(cutSelection.value.start.c, cutSelection.value.end.c)
|
||||
const c2 = Math.max(cutSelection.value.start.c, cutSelection.value.end.c)
|
||||
|
||||
for (let r = r1; r <= r2; r++) {
|
||||
for (let c = c1; c <= c2; c++) {
|
||||
setCell(r, c, { value: '', type: 'text' })
|
||||
}
|
||||
}
|
||||
cutSelection.value = null
|
||||
}
|
||||
|
||||
function autoFill(targetEndR, targetEndC) {
|
||||
saveSnapshot('autofill')
|
||||
const srcR = selection.start.r
|
||||
const srcC = selection.start.c
|
||||
const template = getCell(srcR, srcC)
|
||||
|
||||
for(let r=minR.value; r<=targetEndR; r++) {
|
||||
for(let c=minC.value; c<=targetEndC; c++) {
|
||||
if (r <= maxR.value && c <= maxC.value) continue
|
||||
// 深度拷贝数据
|
||||
const newData = JSON.parse(JSON.stringify(template))
|
||||
setCell(r, c, newData)
|
||||
setCell(r, c, JSON.parse(JSON.stringify(template)))
|
||||
}
|
||||
}
|
||||
selection.end = { r: targetEndR, c: targetEndC }
|
||||
@@ -229,14 +329,17 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
function isRowSelected(i) { return i >= minR.value && i <= maxR.value }
|
||||
|
||||
return {
|
||||
rows, cols, cells, selection, isEditing, editingValue, loading,
|
||||
rows, cols, cells, selection, cutSelection, // State
|
||||
isEditing, editingValue, loading,
|
||||
minR, maxR, minC, maxC, totalWidth, totalHeight,
|
||||
getCell, currentStyle, activeCellFormula, layoutInfo, editorStyle,
|
||||
getCell, currentStyle, activeCellFormula, layoutInfo, cutLayoutInfo, editorStyle,
|
||||
isColSelected, isRowSelected,
|
||||
loadData,
|
||||
select, extendSelection, selectAll,
|
||||
loadData, select, extendSelection, selectAll,
|
||||
enableEditing, finishEditing, updateActiveCell, setCell,
|
||||
toggleStyle, setStyle,
|
||||
insertRow, insertCol, deleteRow, autoFill, forEachSelectedCell
|
||||
insertRow, insertCol, deleteRow, deleteCol,
|
||||
setCutSelection, clearCutSelection, executeCut, clearSelection,
|
||||
autoFill, forEachSelectedCell,
|
||||
undo, redo
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// 简单的 IndexedDB 封装
|
||||
const DB_NAME = 'SpreadsheetDB'
|
||||
const STORE_NAME = 'sheets'
|
||||
const DB_VERSION = 1
|
||||
const LOG_STORE_NAME = 'op_logs' // Table for operation records
|
||||
const DB_VERSION = 2 // Increment version to trigger upgrade
|
||||
|
||||
const openDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -9,9 +9,15 @@ const openDB = () => {
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result
|
||||
// Main sheet store
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
// Operation log store
|
||||
if (!db.objectStoreNames.contains(LOG_STORE_NAME)) {
|
||||
const store = db.createObjectStore(LOG_STORE_NAME, { keyPath: 'id', autoIncrement: true })
|
||||
store.createIndex('timestamp', 'timestamp')
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
@@ -36,11 +42,50 @@ export const db = {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
// 使用 JSON.parse(JSON.stringify()) 去除 Proxy 对象,确保存储的是纯数据
|
||||
const rawValue = JSON.parse(JSON.stringify(value))
|
||||
const request = store.put(rawValue, key)
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
},
|
||||
|
||||
// New: Add operation record
|
||||
async addLog(type, details) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(LOG_STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(LOG_STORE_NAME)
|
||||
const record = {
|
||||
type,
|
||||
details: JSON.parse(JSON.stringify(details)), // Ensure plain object
|
||||
timestamp: Date.now()
|
||||
}
|
||||
const request = store.add(record)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
},
|
||||
|
||||
// New: Get recent logs (for restoring history if needed)
|
||||
async getLogs(limit = 50) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(LOG_STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(LOG_STORE_NAME)
|
||||
const index = store.index('timestamp')
|
||||
const request = index.openCursor(null, 'prev') // Newest first
|
||||
const results = []
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result
|
||||
if (cursor && results.length < limit) {
|
||||
results.push(cursor.value)
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(results.reverse())
|
||||
}
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user