-
复制Ctrl+C
+
+
+
-
-
粘贴Ctrl+V
+
+
-
+
-
+
插入图片...
+
+
+
+
+
+
-
在上方插入 1 行
-
在下方插入 1 行
-
-
删除所在行
-
清除内容
+
+
+
+
+ 删除所在行
+
+
+
+ 删除所在列
+
+
+
+ 清除内容
+
\ No newline at end of file
diff --git a/src/components/spreadsheet/GridEditor.vue b/src/components/spreadsheet/GridEditor.vue
index 11fb4be..6a3a037 100644
--- a/src/components/spreadsheet/GridEditor.vue
+++ b/src/components/spreadsheet/GridEditor.vue
@@ -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 = '
'
@@ -34,22 +37,17 @@ const performCopy = async () => {
const rowJson = []
const rowValues = []
html += ''
-
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, ">")
- if (cell.type === 'image') {
- cellHtml = `
`
- }
+ if (cell.type === 'image') cellHtml = `
`
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 += `${cellHtml} | `
}
-
jsonGrid.push(rowJson)
text += rowValues.join('\t') + (r < maxR ? '\n' : '')
html += '
'
}
html += '
'
- // *** 关键:将 JSON 数据嵌入到 HTML 中 ***
- // 我们使用 data-spreadsheet-json 属性包裹序列化后的数据
- // 这样在粘贴 HTML 时,我们可以提取这个属性来获得 100% 的还原度
+ // 序列化 JSON 数据并嵌入 HTML 中,实现高保真粘贴
const serializedData = JSON.stringify(jsonGrid)
const wrappedHtml = `
${html}
`
- 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(() => {