优化交互

This commit is contained in:
李琦
2026-01-22 16:00:59 +08:00
parent bec166eb55
commit 2c2ab1e4f0
5 changed files with 488 additions and 223 deletions

View File

@@ -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>

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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"
/>

View File

@@ -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>

View File

@@ -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
}
})

View File

@@ -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)
})
}
}