diff --git a/src/components/spreadsheet/ContextMenu.vue b/src/components/spreadsheet/ContextMenu.vue index a6c6c73..309b3d7 100644 --- a/src/components/spreadsheet/ContextMenu.vue +++ b/src/components/spreadsheet/ContextMenu.vue @@ -1,7 +1,44 @@ \ 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 = `image` - } + if (cell.type === 'image') cellHtml = `image` 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 += `` } - jsonGrid.push(rowJson) text += rowValues.join('\t') + (r < maxR ? '\n' : '') html += '' } html += '
${cellHtml}
' - // *** 关键:将 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(() => {