初始化

This commit is contained in:
李琦
2026-01-22 15:37:39 +08:00
parent 16f533aa57
commit bec166eb55
14 changed files with 1060 additions and 81 deletions

View File

@@ -0,0 +1,29 @@
<script setup>
import { computed } from 'vue'
const props = defineProps(['data', 'width', 'height', 'r', 'c'])
const styleObject = computed(() => {
const s = props.data.style || {}
return {
width: props.width + 'px',
height: props.height + 'px',
fontWeight: s.bold ? 'bold' : 'normal',
fontStyle: s.italic ? 'italic' : 'normal',
textDecoration: s.underline ? 'underline' : 'none',
textAlign: s.align || 'left',
backgroundColor: s.bg || 'transparent',
color: s.color || 'inherit'
}
})
</script>
<template>
<div
class="border-r border-b border-gray-200 text-xs px-1 overflow-hidden whitespace-nowrap flex items-center select-none"
:style="styleObject"
>
<img v-if="data.type === 'image'" :src="data.value" class="max-w-full max-h-full object-contain pointer-events-none" />
<span v-else>{{ data.value }}</span>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { Image as ImageIcon } from 'lucide-vue-next'
const props = defineProps(['x', 'y'])
const emit = defineEmits(['action', 'close'])
const emitAction = (name) => {
emit('action', name)
emit('close')
}
</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' }"
@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>
<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>
<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" />
<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" @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>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
defineProps(['value'])
defineEmits(['update'])
</script>
<template>
<div class="h-8 bg-white border-b border-gray-300 flex items-center px-2 shrink-0">
<div class="w-8 text-center text-gray-400 font-serif italic font-bold select-none">fx</div>
<div class="w-px h-4 bg-gray-300 mx-1"></div>
<input
class="flex-1 h-full px-2 text-sm outline-none text-gray-700 placeholder-gray-300"
:value="value"
@input="$emit('update', $event.target.value)"
placeholder="输入值或公式"
>
</div>
</template>

View File

@@ -0,0 +1,446 @@
<script setup>
import { ref, reactive, nextTick, watchEffect } from 'vue'
import { useSpreadsheetStore } from '@/stores/spreadsheet'
import Cell from './Cell.vue'
import SelectionOverlay from './SelectionOverlay.vue'
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 isDragging = ref(false)
const isFilling = ref(false)
const HEADER_WIDTH = 40
const HEADER_HEIGHT = 32
// --- 核心:高级复制/粘贴逻辑 ---
const performCopy = async () => {
try {
const { minR, maxR, minC, maxC } = store
// 1. 构建用于内部粘贴的完整数据结构 (2D Array of JSON)
const jsonGrid = []
// 2. 构建 HTML 和 Text
let text = ''
let html = '<table border="1">'
for (let r = minR; r <= maxR; r++) {
const rowJson = []
const rowValues = []
html += '<tr>'
for (let c = minC; c <= maxC; c++) {
const cell = store.getCell(r, c)
// 保存完整状态到 JSON Grid
rowJson.push(JSON.parse(JSON.stringify(cell))) // Deep copy
// 文本格式
let val = cell.value == null ? '' : String(cell.value)
rowValues.push(val)
// 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%;" />`
}
let styleStr = ''
if (cell.style) {
if (cell.style.bold) styleStr += 'font-weight:bold;'
if (cell.style.italic) styleStr += 'font-style:italic;'
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% 的还原度
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
})
])
console.log('Copied with Rich Format')
} catch (err) {
console.error('Failed to copy: ', err)
}
}
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)
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')
}
break
}
}
if (!pasted) {
// 3. 降级到纯文本
const text = await navigator.clipboard.readText()
pasteText(text)
console.log('Pasted Text')
}
} catch (err) {
console.error('Paste failed', err)
}
}
// 粘贴 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)
}
})
})
}
// 粘贴 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 })
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) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
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' })
}
}
})
})
}
const pasteText = (text) => {
if (!text) return
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')
cols.forEach((val, cIndex) => {
const targetR = startR + rIndex
const targetC = startC + cIndex
if (targetR < store.rows.length && targetC < store.cols.length) {
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' })
} else {
store.setCell(targetR, targetC, { value: val, type: 'text' })
}
}
})
})
}
// --- 右键上传图片逻辑 ---
const triggerHiddenFileInput = () => {
hiddenFileInput.value.click()
}
const handleContextMenuImageUpload = async (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
}
reader.readAsDataURL(file)
e.target.value = '' // reset
}
// --- 基础交互 ---
const getCellByPoint = (x, y) => {
if (!scroller.value) return { r: -1, c: -1 }
const contentEl = scroller.value.firstElementChild
if (!contentEl) return { r: -1, c: -1 }
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
while(c < store.cols.length && currentX + store.cols[c].width < relX) {
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
r++
}
return { r: Math.min(r, store.rows.length - 1), c: Math.min(c, store.cols.length - 1) }
}
const handleMouseDown = (e) => {
if (e.button !== 0) return
contextMenu.visible = false
const { r, c } = getCellByPoint(e.clientX, e.clientY)
if (r === -1 || c === -1) return
if (e.shiftKey) store.extendSelection(r, c)
else store.select(r, c)
isDragging.value = true
}
const handleMouseMove = (e) => {
if (isDragging.value || isFilling.value) {
const { r, c } = getCellByPoint(e.clientX, e.clientY)
if (r !== -1 && c !== -1) store.extendSelection(r, c)
}
}
const handleMouseUp = () => {
if (isDragging.value) {
isDragging.value = false
scroller.value?.parentElement?.focus()
}
if (isFilling.value) {
isFilling.value = false
const { r, c } = store.selection.end
store.autoFill(r, 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)
}
contextMenu.x = e.clientX
contextMenu.y = e.clientY
contextMenu.visible = true
}
const handleContextAction = (action) => {
const r = store.selection.start.r
const c = store.selection.start.c
switch(action) {
case 'copy': performCopy(); 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 'deleteRow': store.deleteRow(r); break;
case 'clear': store.forEachSelectedCell((row,col) => store.setCell(row,col,{value:''})); break;
}
}
const handleKeyDown = (e) => {
if (store.isEditing) return
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault(); performCopy(); return
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault(); performPaste(); return
}
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) e.preventDefault()
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' }))
}
else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
store.enableEditing()
store.editingValue = e.key
}
}
watchEffect(() => {
if (store.isEditing) {
nextTick(() => {
const el = document.querySelector('textarea.grid-editor-textarea')
if(el) el.focus()
})
}
})
</script>
<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">
<div class="relative bg-white m-8 shadow-sm select-none"
:style="{ width: store.totalWidth + 'px', height: store.totalHeight + 'px' }"
@contextmenu.prevent="handleContextMenu"
>
<!-- 列头 -->
<div class="sticky top-[-32px] left-0 right-0 z-20 flex h-8 bg-[#f4f5f7] border-b border-gray-300">
<div class="w-10 bg-[#f4f5f7] border-r border-gray-300 flex-shrink-0 cursor-pointer hover:bg-gray-200" @click="store.selectAll"></div>
<div v-for="(col, i) in store.cols" :key="i"
class="flex items-center justify-center border-r border-gray-300 text-xs text-gray-500 font-medium bg-[#f4f5f7] hover:bg-gray-200 relative"
:class="{'bg-green-100 text-green-600 font-bold': store.isColSelected(i)}"
:style="{ width: col.width + 'px', flexShrink: 0 }">
{{ col.label }}
</div>
</div>
<div class="flex">
<!-- 行头 -->
<div class="sticky left-0 z-10 flex flex-col w-10 bg-[#f4f5f7] border-r border-gray-300 flex-shrink-0">
<div v-for="(row, i) in store.rows" :key="i"
class="flex items-center justify-center border-b border-gray-200 text-xs text-gray-500 bg-[#f4f5f7] hover:bg-gray-200 relative"
:class="{'bg-green-100 text-green-600 font-bold': store.isRowSelected(i)}"
:style="{ height: row.height + 'px' }">
{{ row.index + 1 }}
</div>
</div>
<!-- 单元格区域 -->
<div class="relative"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp">
<div v-for="(row, r) in store.rows" :key="r" class="flex w-max">
<Cell
v-for="(col, c) in store.cols"
:key="c"
:r="r" :c="c"
:data="store.getCell(r, c)"
:width="col.width"
:height="row.height"
@dblclick="store.enableEditing"
/>
</div>
<SelectionOverlay
:selection="store.selection"
:is-editing="store.isEditing"
:layout-info="store.layoutInfo"
@fill-drag-start="isFilling = true"
/>
<textarea
v-if="store.isEditing"
v-model="store.editingValue"
class="grid-editor-textarea absolute z-30 text-xs px-1 border-2 border-green-500 shadow-xl outline-none resize-none bg-white leading-normal overflow-hidden"
:style="store.editorStyle"
@blur="store.finishEditing"
@keydown.enter.prevent="store.finishEditing"
></textarea>
</div>
</div>
</div>
</div>
<ContextMenu
v-if="contextMenu.visible"
:x="contextMenu.x"
:y="contextMenu.y"
@action="handleContextAction"
@close="contextMenu.visible = false"
/>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f5f5f5; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #ccc; border-radius: 5px; border: 2px solid #f5f5f5; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #999; }
</style>

View File

@@ -0,0 +1,26 @@
<script setup>
import { computed, ref, reactive } from 'vue'
const props = defineProps(['selection', 'layoutInfo', 'isEditing'])
defineEmits(['fill-drag-start'])
const fillPreview = ref(false)
const fillPreviewRect = reactive({ w:0, h:0 })
const style = computed(() => ({
top: props.layoutInfo.top + 'px',
left: props.layoutInfo.left + 'px',
width: props.layoutInfo.width + 'px',
height: props.layoutInfo.height + 'px',
}))
</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>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { Menu, Plus } from 'lucide-vue-next'
</script>
<template>
<div class="h-7 bg-[#fbfbfb] border-t border-gray-300 flex items-center justify-between px-3 text-[11px] text-gray-500 select-none shrink-0">
<div class="flex items-center gap-4">
<div class="px-2 py-0.5 bg-white border border-gray-200 rounded shadow-sm flex items-center gap-1 text-green-600 font-bold cursor-pointer">
<Menu class="w-3 h-3" /> Sheet1
</div>
<div class="hover:bg-gray-200 px-2 py-0.5 rounded cursor-pointer transition">Sheet2</div>
<Plus class="w-3 h-3 cursor-pointer hover:bg-gray-200 rounded" />
</div>
<div>就绪</div>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref } from 'vue'
import {
Undo2, Redo2, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Image as ImageIcon, Plus
} from 'lucide-vue-next'
const props = defineProps(['currentStyle'])
const emit = defineEmits(['action'])
const fileInput = ref(null)
const triggerImageUpload = () => fileInput.value.click()
// 支持多图上传的处理逻辑
const handleImageUpload = async (e) => {
const files = Array.from(e.target.files)
if (files.length === 0) return
// 并发读取所有图片
const readers = files.map(file => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (evt) => resolve(evt.target.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
})
try {
const images = await Promise.all(readers)
// 触发批量插入事件
emit('action', 'insertImages', images)
} catch (error) {
console.error("图片读取失败", error)
}
// 重置 input 以便能重复上传同一文件
e.target.value = ''
}
const btnClass = "p-1.5 rounded hover:bg-gray-200 transition text-gray-600 flex items-center justify-center cursor-pointer"
const activeClass = "bg-gray-300 text-black"
</script>
<template>
<div class="h-10 bg-[#fbfbfb] border-b border-gray-300 flex items-center px-2 gap-2 select-none shrink-0">
<div class="flex gap-1 pr-2 border-r border-gray-300">
<button :class="btnClass" title="撤销"><Undo2 class="w-4 h-4" /></button>
<button :class="btnClass" title="重做"><Redo2 class="w-4 h-4" /></button>
</div>
<div class="flex gap-1 pr-2 border-r border-gray-300">
<button :class="[btnClass, currentStyle.bold ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'bold')">
<Bold class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.italic ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'italic')">
<Italic class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.underline ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'underline')">
<Underline class="w-4 h-4" />
</button>
<div class="w-px h-4 bg-gray-300 mx-1 self-center"></div>
<button :class="[btnClass, currentStyle.align === 'left' ? activeClass : '']" @click="$emit('action', 'setAlign', 'left')">
<AlignLeft class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.align === 'center' ? activeClass : '']" @click="$emit('action', 'setAlign', 'center')">
<AlignCenter class="w-4 h-4" />
</button>
<button :class="[btnClass, currentStyle.align === 'right' ? activeClass : '']" @click="$emit('action', 'setAlign', 'right')">
<AlignRight class="w-4 h-4" />
</button>
</div>
<div class="flex gap-1 pr-2 border-r border-gray-300">
<button :class="btnClass" title="插入图片" @click="triggerImageUpload">
<ImageIcon class="w-4 h-4" />
</button>
<!-- 关键修改添加 multiple 属性支持多选 -->
<input type="file" ref="fileInput" class="hidden" accept="image/*" multiple @change="handleImageUpload">
</div>
<div class="flex gap-1">
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700" @click="$emit('action', 'addRow')">
<Plus class="w-3 h-3" />
</button>
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700" @click="$emit('action', 'addCol')">
<Plus class="w-3 h-3" />
</button>
</div>
</div>
</template>