优化交互
This commit is contained in:
@@ -1,29 +1,47 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
|
||||
const props = defineProps(['data', 'width', 'height', 'r', 'c'])
|
||||
const store = useSpreadsheetStore()
|
||||
|
||||
// 获取计算后的显示值(处理公式)
|
||||
const displayValue = computed(() => {
|
||||
return store.getEvaluatedValue(props.r, props.c)
|
||||
})
|
||||
|
||||
const styleObject = computed(() => {
|
||||
const s = props.data.style || {}
|
||||
|
||||
// 将 align 映射到 flex 布局属性
|
||||
let justify = 'flex-start'
|
||||
if (s.align === 'center') justify = 'center'
|
||||
if (s.align === 'right') justify = 'flex-end'
|
||||
|
||||
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'
|
||||
color: s.color || 'inherit',
|
||||
fontFamily: s.fontFamily || 'Arial',
|
||||
fontSize: (s.fontSize || 11) + 'px',
|
||||
justifyContent: justify, // 关键:Flex 布局下使用 justify 控制水平对齐
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-b border-gray-200 text-xs px-1 overflow-hidden whitespace-nowrap flex items-center select-none"
|
||||
class="border-r border-b border-gray-200 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>
|
||||
|
||||
<!-- 文本/公式结果 -->
|
||||
<span v-else>{{ displayValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
168
src/components/spreadsheet/ColorPicker.vue
Normal file
168
src/components/spreadsheet/ColorPicker.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup>
|
||||
import { ref, nextTick, onUnmounted } from 'vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
|
||||
const props = defineProps(['iconComponent', 'color', 'type']) // type: 'text' | 'fill'
|
||||
const emit = defineEmits(['change'])
|
||||
const store = useSpreadsheetStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const customColorInput = ref(null) // 新增:自定义颜色输入的引用
|
||||
const dropdownStyle = ref({ top: '0px', left: '0px' })
|
||||
|
||||
// 推荐颜色列表
|
||||
const recommendedColors = [
|
||||
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
|
||||
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
|
||||
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
|
||||
'#dd7e6b', '#ea9999', '#f9cb9c', '#ffe599', '#b6d7a8', '#a2c4c9', '#a4c2f4', '#9fc5e8', '#b4a7d6', '#d5a6bd',
|
||||
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
|
||||
'#a61c00', '#cc0000', '#e69138', '#f1c232', '#6aa84f', '#45818e', '#3c78d8', '#3d85c6', '#674ea7', '#a64d79',
|
||||
]
|
||||
|
||||
// 更新下拉菜单位置
|
||||
const updatePosition = () => {
|
||||
if (triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed', // 使用 fixed 定位,相对于视口
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${rect.left}px`,
|
||||
zIndex: 99999 // 确保层级最高
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
// 添加全局点击事件监听,用于点击外部关闭
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
} else {
|
||||
removeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
const selectColor = (c) => {
|
||||
emit('change', c)
|
||||
// 保持面板打开以便调整颜色,不自动关闭
|
||||
// close()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
// 新增:触发自定义颜色选择
|
||||
const triggerCustomColor = () => {
|
||||
customColorInput.value?.click()
|
||||
}
|
||||
|
||||
// 新增:处理自定义颜色变更
|
||||
const handleCustomColorChange = (e) => {
|
||||
selectColor(e.target.value)
|
||||
}
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
// 如果点击在触发按钮或下拉菜单内部,则不关闭
|
||||
if (triggerRef.value && triggerRef.value.contains(e.target)) return
|
||||
if (dropdownRef.value && dropdownRef.value.contains(e.target)) return
|
||||
close()
|
||||
}
|
||||
|
||||
const removeListeners = () => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
removeListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- 触发按钮 -->
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="p-1.5 rounded hover:bg-gray-200 transition text-gray-700 flex items-center justify-center cursor-pointer relative"
|
||||
@click.stop="toggle"
|
||||
:title="type === 'text' ? '字体颜色' : '单元格填充'"
|
||||
>
|
||||
<component :is="iconComponent" class="w-4 h-4" :style="{ color: type === 'text' ? (color || '#000') : '#4b5563' }" />
|
||||
<div class="absolute bottom-1 w-3 h-0.5" :style="{ backgroundColor: type === 'text' ? (color || '#000') : (color || 'transparent') }"></div>
|
||||
</button>
|
||||
|
||||
<!-- 下拉面板 (使用 Teleport 传送到 body) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
class="bg-white border border-gray-300 shadow-2xl rounded p-3 w-64"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div v-if="store.colorHistory.length > 0">
|
||||
<div class="text-xs text-gray-500 mb-1">最近使用</div>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<div
|
||||
v-for="c in store.colorHistory"
|
||||
:key="c"
|
||||
class="w-5 h-5 rounded-sm border border-gray-200 cursor-pointer hover:scale-110 transition-transform shadow-sm"
|
||||
:style="{ backgroundColor: c }"
|
||||
@click="selectColor(c)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="h-px bg-gray-200 my-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐颜色 -->
|
||||
<div class="text-xs text-gray-500 mb-1">推荐颜色</div>
|
||||
<div class="grid grid-cols-10 gap-1">
|
||||
<div
|
||||
v-for="c in recommendedColors"
|
||||
:key="c"
|
||||
class="w-5 h-5 rounded-sm border border-gray-200 cursor-pointer hover:scale-110 transition-transform shadow-sm"
|
||||
:style="{ backgroundColor: c }"
|
||||
@click="selectColor(c)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 更多颜色 & 重置 -->
|
||||
<div class="mt-2 pt-2 border-t border-gray-200 space-y-1">
|
||||
<!-- 自定义颜色入口 -->
|
||||
<button
|
||||
class="w-full py-1.5 px-2 text-xs text-left hover:bg-gray-100 rounded text-gray-700 flex items-center justify-between"
|
||||
@click="triggerCustomColor"
|
||||
>
|
||||
<span>更多颜色...</span>
|
||||
<!-- 彩色圆圈示意图 -->
|
||||
<div class="w-4 h-4 rounded-full border border-gray-300 bg-gradient-to-br from-red-500 via-green-500 to-blue-500"></div>
|
||||
</button>
|
||||
|
||||
<!-- 隐藏的原生 Color Input -->
|
||||
<!-- 将 @input 改为 @change,确保只有在确认选择后才更新,避免产生大量历史记录 -->
|
||||
<input
|
||||
type="color"
|
||||
ref="customColorInput"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
@change="handleCustomColorChange"
|
||||
>
|
||||
|
||||
<button class="w-full py-1.5 px-2 text-xs text-left hover:bg-gray-100 rounded text-gray-600" @click="selectColor(null)">
|
||||
重置颜色
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +1,197 @@
|
||||
<script setup>
|
||||
defineProps(['value'])
|
||||
defineEmits(['update'])
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
import FormulaSuggestion from './FormulaSuggestion.vue'
|
||||
|
||||
const props = defineProps(['value'])
|
||||
const emit = defineEmits(['update'])
|
||||
const store = useSpreadsheetStore()
|
||||
|
||||
const inputRef = ref(null)
|
||||
const suggestionRef = ref(null)
|
||||
|
||||
// Local state to prevent cursor jumping and manage input
|
||||
const localValue = ref(props.value || '')
|
||||
|
||||
// Sync local value with props (Store -> Component)
|
||||
watch(() => props.value, (newVal) => {
|
||||
// Only update if different to avoid cursor reset issues
|
||||
if (localValue.value !== newVal) {
|
||||
localValue.value = newVal || ''
|
||||
}
|
||||
})
|
||||
|
||||
// --- 公式提示状态 ---
|
||||
const showSuggestions = ref(false)
|
||||
const suggestionSearch = ref('')
|
||||
const suggestionRect = ref({ top: 0, left: 0, height: 0 })
|
||||
|
||||
// Helper to update store based on current mode
|
||||
const updateStore = (val) => {
|
||||
if (store.isEditing) {
|
||||
// If already in edit mode (triggered from Grid), update editingValue
|
||||
store.editingValue = val
|
||||
} else {
|
||||
// If in Formula Bar mode, update cell directly (avoids showing Grid textarea)
|
||||
store.updateActiveCell(val)
|
||||
}
|
||||
}
|
||||
|
||||
// --- fx 按钮逻辑 ---
|
||||
const handleFxClick = () => {
|
||||
// If not editing, ensure we are selecting the cell content logic
|
||||
// We do NOT call store.enableEditing() to keep grid clean
|
||||
|
||||
let currentVal = localValue.value
|
||||
// 如果当前为空,自动填入 "="
|
||||
if (!currentVal) {
|
||||
currentVal = '='
|
||||
updateStore(currentVal)
|
||||
localValue.value = currentVal
|
||||
}
|
||||
|
||||
// 聚焦并触发建议
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
// 光标移到最后
|
||||
const len = currentVal.length
|
||||
inputRef.value.setSelectionRange(len, len)
|
||||
// 立即触发建议检查
|
||||
updateSuggestionState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- 建议逻辑 (与 GridEditor 类似) ---
|
||||
const updateSuggestionState = () => {
|
||||
if (!inputRef.value) return
|
||||
|
||||
const val = localValue.value
|
||||
const cursor = inputRef.value.selectionStart
|
||||
|
||||
// 必须以 = 开头
|
||||
if (!val.startsWith('=')) {
|
||||
showSuggestions.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const textBeforeCursor = val.slice(0, cursor)
|
||||
// 提取光标前的单词作为搜索词
|
||||
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
|
||||
|
||||
if (match) {
|
||||
suggestionSearch.value = match[1]
|
||||
const rect = inputRef.value.getBoundingClientRect()
|
||||
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
|
||||
showSuggestions.value = true
|
||||
} else if (val.trim() === '=') {
|
||||
// 只有一个 = 号时显示所有
|
||||
suggestionSearch.value = ''
|
||||
const rect = inputRef.value.getBoundingClientRect()
|
||||
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
|
||||
showSuggestions.value = true
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestionSelect = (funcName) => {
|
||||
const val = localValue.value
|
||||
const cursor = inputRef.value.selectionStart
|
||||
const textBeforeCursor = val.slice(0, cursor)
|
||||
const textAfterCursor = val.slice(cursor)
|
||||
|
||||
let newTextBefore = ''
|
||||
if (textBeforeCursor.trim() === '=') {
|
||||
newTextBefore = `=${funcName}(`
|
||||
} else {
|
||||
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
|
||||
if (match) {
|
||||
newTextBefore = textBeforeCursor.slice(0, match.index) + funcName + '('
|
||||
} else {
|
||||
newTextBefore = textBeforeCursor + funcName + '('
|
||||
}
|
||||
}
|
||||
|
||||
const newVal = newTextBefore + textAfterCursor
|
||||
localValue.value = newVal
|
||||
updateStore(newVal)
|
||||
|
||||
showSuggestions.value = false
|
||||
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
const newCursorPos = newTextBefore.length
|
||||
inputRef.value.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 键盘交互
|
||||
const handleKeyDown = (e) => {
|
||||
// 1. 如果建议框显示,优先导航建议框
|
||||
if (showSuggestions.value && suggestionRef.value) {
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); suggestionRef.value.navigate('up'); return }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); suggestionRef.value.navigate('down'); return }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); suggestionRef.value.navigate('enter'); return }
|
||||
if (e.key === 'Escape') { e.preventDefault(); showSuggestions.value = false; return }
|
||||
}
|
||||
|
||||
// 2. 公式栏的回车通常意味着“确认输入”
|
||||
if (e.key === 'Enter') {
|
||||
store.finishEditing()
|
||||
// Optional: Move selection down like Excel
|
||||
// store.select(store.selection.start.r + 1, store.selection.start.c)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听输入
|
||||
const handleInput = (e) => {
|
||||
localValue.value = e.target.value
|
||||
updateStore(e.target.value)
|
||||
updateSuggestionState()
|
||||
}
|
||||
</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="h-8 bg-white border-b border-gray-300 flex items-center px-2 shrink-0 relative z-50">
|
||||
<!-- fx 按钮 -->
|
||||
<div
|
||||
class="w-8 text-center text-gray-400 font-serif italic font-bold select-none cursor-pointer hover:text-green-600 hover:bg-gray-100 rounded"
|
||||
title="插入函数"
|
||||
@click="handleFxClick"
|
||||
>
|
||||
fx
|
||||
</div>
|
||||
<div class="w-px h-4 bg-gray-300 mx-1"></div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<!-- Use :value prop and @input for manual control to allow localValue sync without cursor jumps -->
|
||||
<input
|
||||
ref="inputRef"
|
||||
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)"
|
||||
:value="localValue"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeyDown"
|
||||
@blur="() => {
|
||||
// 延迟 blur 以便点击建议项
|
||||
setTimeout(() => {
|
||||
if(!showSuggestions) { /* store.finishEditing() */ }
|
||||
}, 150)
|
||||
}"
|
||||
placeholder="输入值或公式"
|
||||
>
|
||||
|
||||
<!-- 公式提示组件 -->
|
||||
<FormulaSuggestion
|
||||
v-if="showSuggestions"
|
||||
ref="suggestionRef"
|
||||
:search="suggestionSearch"
|
||||
:rect="suggestionRect"
|
||||
@select="handleSuggestionSelect"
|
||||
@close="showSuggestions = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
115
src/components/spreadsheet/FormulaSuggestion.vue
Normal file
115
src/components/spreadsheet/FormulaSuggestion.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { FUNCTION_REGISTRY } from '@/utils/formula'
|
||||
|
||||
const props = defineProps({
|
||||
search: { type: String, default: '' },
|
||||
rect: { type: Object, default: () => ({ top: 0, left: 0, height: 0 }) } // 输入框的位置信息
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'close'])
|
||||
|
||||
const selectedIndex = ref(0)
|
||||
const listRef = ref(null)
|
||||
|
||||
// 将注册表转换为数组
|
||||
const allFunctions = Object.keys(FUNCTION_REGISTRY).map(key => ({
|
||||
name: key,
|
||||
...FUNCTION_REGISTRY[key]
|
||||
}))
|
||||
|
||||
// 过滤列表
|
||||
const filteredList = computed(() => {
|
||||
const s = props.search.toUpperCase()
|
||||
// 如果搜索词为空,显示所有;否则模糊匹配
|
||||
return allFunctions.filter(f => f.name.includes(s)).sort((a, b) => {
|
||||
// 简单的排序优化:以搜索词开头的排前面
|
||||
const aStarts = a.name.startsWith(s)
|
||||
const bStarts = b.name.startsWith(s)
|
||||
if (aStarts && !bStarts) return -1
|
||||
if (!aStarts && bStarts) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const currentItem = computed(() => filteredList.value[selectedIndex.value])
|
||||
|
||||
// 监听搜索词变化,重置选中项
|
||||
watch(() => props.search, () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
// 暴露给父组件的方法:处理键盘导航
|
||||
const navigate = (direction) => {
|
||||
if (direction === 'up') {
|
||||
selectedIndex.value = Math.max(0, selectedIndex.value - 1)
|
||||
scrollToItem()
|
||||
} else if (direction === 'down') {
|
||||
selectedIndex.value = Math.min(filteredList.value.length - 1, selectedIndex.value + 1)
|
||||
scrollToItem()
|
||||
} else if (direction === 'enter' || direction === 'tab') {
|
||||
if (currentItem.value) {
|
||||
emit('select', currentItem.value.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToItem = () => {
|
||||
if (!listRef.value) return
|
||||
const item = listRef.value.children[selectedIndex.value]
|
||||
if (item) {
|
||||
item.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
// 计算位置:显示在输入框下方
|
||||
const style = computed(() => {
|
||||
// 简单的定位,实际项目中可能需要检测视口底部溢出
|
||||
return {
|
||||
top: (props.rect.top + props.rect.height + 4) + 'px',
|
||||
left: props.rect.left + 'px',
|
||||
position: 'fixed',
|
||||
zIndex: 99999
|
||||
}
|
||||
})
|
||||
|
||||
// 监听键盘事件的代理
|
||||
const handleGlobalKeydown = (e) => {
|
||||
// 这里不直接监听,而是依靠 GridEditor 转发,
|
||||
// 因为输入框焦点在 GridEditor,只有它能捕获 e.preventDefault()
|
||||
}
|
||||
|
||||
defineExpose({ navigate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="filteredList.length > 0" :style="style" class="flex flex-row items-start gap-2 font-sans">
|
||||
|
||||
<!-- 函数列表 -->
|
||||
<div class="bg-white border border-gray-300 shadow-xl rounded-md overflow-hidden w-48 max-h-64 flex flex-col">
|
||||
<ul ref="listRef" class="overflow-y-auto flex-1 py-1">
|
||||
<li
|
||||
v-for="(func, index) in filteredList"
|
||||
:key="func.name"
|
||||
class="px-3 py-1.5 cursor-pointer text-xs flex items-center justify-between group"
|
||||
:class="{'bg-blue-100 text-blue-700': index === selectedIndex, 'hover:bg-gray-100': index !== selectedIndex}"
|
||||
@click="emit('select', func.name)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<span class="font-bold mr-2">{{ func.name }}</span>
|
||||
<span class="text-[10px] text-gray-400 truncate hidden group-hover:block" :class="{'!text-blue-500 !block': index === selectedIndex}">{{ func.description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 函数说明提示框 (当前选中的详细信息) -->
|
||||
<div v-if="currentItem" class="bg-yellow-50 border border-yellow-200 text-yellow-900 shadow-lg rounded p-3 w-64 text-xs">
|
||||
<div class="font-bold mb-1">{{ currentItem.name }}</div>
|
||||
<div class="mb-2 text-gray-600">{{ currentItem.description }}</div>
|
||||
<div class="font-mono bg-white/50 p-1 rounded border border-yellow-100 text-[10px]">{{ currentItem.syntax }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick, watchEffect } from 'vue'
|
||||
import { ref, reactive, nextTick, watchEffect, computed } from 'vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
import Cell from './Cell.vue'
|
||||
import SelectionOverlay from './SelectionOverlay.vue'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import FormulaSuggestion from './FormulaSuggestion.vue'
|
||||
|
||||
import { copyToClipboard, pasteFromClipboard } from '@/utils/clipboard'
|
||||
import { readSingleFileAsDataURL } from '@/utils/file'
|
||||
import { ensureRowHeightForImage } from '@/utils/common'
|
||||
import { resolveCellPosition } from '@/utils/layout'
|
||||
import { handleGridKeyDown } from '@/utils/keyboard'
|
||||
|
||||
const store = useSpreadsheetStore()
|
||||
const scroller = ref(null)
|
||||
const contextMenu = reactive({ visible: false, x: 0, y: 0 })
|
||||
const hiddenFileInput = ref(null)
|
||||
const editorRef = ref(null)
|
||||
|
||||
// --- 公式提示状态 ---
|
||||
const showSuggestions = ref(false)
|
||||
const suggestionSearch = ref('')
|
||||
const suggestionRect = ref({ top: 0, left: 0, height: 0 })
|
||||
const suggestionRef = ref(null)
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isFilling = ref(false)
|
||||
@@ -16,210 +30,106 @@ 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 (isCut = false) => {
|
||||
try {
|
||||
const { minR, maxR, minC, maxC } = store
|
||||
const jsonGrid = []
|
||||
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)
|
||||
// 深度拷贝单元格数据
|
||||
rowJson.push(JSON.parse(JSON.stringify(cell)))
|
||||
|
||||
let val = cell.value == null ? '' : String(cell.value)
|
||||
rowValues.push(val)
|
||||
|
||||
// 构建 HTML
|
||||
let cellHtml = val.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
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 中,实现高保真粘贴
|
||||
const serializedData = JSON.stringify(jsonGrid)
|
||||
const wrappedHtml = `<div data-spreadsheet-json='${serializedData}'>${html}</div>`
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': new Blob([text], { type: 'text/plain' }),
|
||||
'text/html': new Blob([wrappedHtml], { type: 'text/html' })
|
||||
})
|
||||
])
|
||||
|
||||
// 如果是剪切,只标记状态(UI变虚线),不立即删除
|
||||
if (isCut) {
|
||||
store.setCutSelection()
|
||||
} else {
|
||||
store.clearCutSelection() // 普通复制会取消剪切状态
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('剪贴板操作失败', 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()
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const wrapper = doc.querySelector('[data-spreadsheet-json]')
|
||||
|
||||
if (wrapper) {
|
||||
try {
|
||||
// 优先尝试高保真 JSON 粘贴
|
||||
pasteJsonGrid(JSON.parse(wrapper.dataset.spreadsheetJson))
|
||||
pasted = true
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!pasted) { pasteHtml(html); pasted = true; }
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!pasted) {
|
||||
const text = await navigator.clipboard.readText()
|
||||
pasteText(text)
|
||||
}
|
||||
|
||||
// 粘贴完成后,检查是否有剪切状态。如果有,执行真正的剪切(清除原数据)。
|
||||
if (store.cutSelection) {
|
||||
store.executeCut()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// 降级逻辑:直接读取文本
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) pasteText(text)
|
||||
if (store.cutSelection) store.executeCut()
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
store.setCell(targetR, targetC, cellData)
|
||||
// 如果是图片,自动撑开行高
|
||||
if (cellData.type === 'image') {
|
||||
ensureRowHeightForImage(targetR)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 })
|
||||
ensureRowHeightForImage(store.selection.start.r)
|
||||
}
|
||||
else pasteText(doc.body.textContent)
|
||||
const updateSuggestionState = () => {
|
||||
if (!store.isEditing || !editorRef.value) {
|
||||
showSuggestions.value = false
|
||||
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' })
|
||||
ensureRowHeightForImage(targetR)
|
||||
}
|
||||
else store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
|
||||
}
|
||||
})
|
||||
|
||||
const val = store.editingValue
|
||||
// 确保使用最新的 DOM 值或 Store 值
|
||||
const cursor = editorRef.value.selectionStart
|
||||
|
||||
// 简单判断:必须以 = 开头
|
||||
if (!val.startsWith('=')) {
|
||||
showSuggestions.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const textBeforeCursor = val.slice(0, cursor)
|
||||
|
||||
// 提取光标前的单词
|
||||
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
|
||||
|
||||
if (match) {
|
||||
const word = match[1]
|
||||
suggestionSearch.value = word
|
||||
|
||||
const rect = editorRef.value.getBoundingClientRect()
|
||||
suggestionRect.value = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
height: rect.height
|
||||
}
|
||||
showSuggestions.value = true
|
||||
} else {
|
||||
// 刚输入完 = 号
|
||||
if (val.trim() === '=') {
|
||||
suggestionSearch.value = '' // 显示所有
|
||||
const rect = editorRef.value.getBoundingClientRect()
|
||||
suggestionRect.value = { top: rect.top, left: rect.left, height: rect.height }
|
||||
showSuggestions.value = true
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理建议选中
|
||||
const handleSuggestionSelect = (funcName) => {
|
||||
const val = store.editingValue
|
||||
const cursor = editorRef.value.selectionStart
|
||||
const textBeforeCursor = val.slice(0, cursor)
|
||||
const textAfterCursor = val.slice(cursor)
|
||||
|
||||
let newTextBefore = ''
|
||||
|
||||
if (textBeforeCursor.trim() === '=') {
|
||||
newTextBefore = `=${funcName}(`
|
||||
} else {
|
||||
const match = textBeforeCursor.match(/([a-zA-Z]+)$/)
|
||||
if (match) {
|
||||
newTextBefore = textBeforeCursor.slice(0, match.index) + funcName + '('
|
||||
} else {
|
||||
newTextBefore = textBeforeCursor + funcName + '('
|
||||
}
|
||||
}
|
||||
|
||||
store.editingValue = newTextBefore + textAfterCursor
|
||||
showSuggestions.value = false
|
||||
|
||||
nextTick(() => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.focus()
|
||||
const newCursorPos = newTextBefore.length
|
||||
editorRef.value.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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' })
|
||||
ensureRowHeightForImage(targetR)
|
||||
} else {
|
||||
store.setCell(targetR, targetC, { value: val, type: 'text' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// ------------------------------------
|
||||
|
||||
const performCopy = (isCut = false) => copyToClipboard(store, isCut)
|
||||
const performPaste = () => pasteFromClipboard(store)
|
||||
|
||||
// --- 图片上传 ---
|
||||
const triggerHiddenFileInput = () => hiddenFileInput.value.click()
|
||||
const handleContextMenuImageUpload = (e) => {
|
||||
|
||||
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
|
||||
const r = store.selection.start.r
|
||||
store.setCell(r, store.selection.start.c, { type: 'image', value: base64 })
|
||||
ensureRowHeightForImage(r)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
try {
|
||||
const base64 = await readSingleFileAsDataURL(file)
|
||||
if (base64) {
|
||||
const r = store.selection.start.r
|
||||
store.setCell(r, store.selection.start.c, { type: 'image', value: base64 })
|
||||
ensureRowHeightForImage(store, r)
|
||||
}
|
||||
} catch (err) { console.error(err) }
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// --- 鼠标交互 ---
|
||||
const getCellByPoint = (x, y) => {
|
||||
if (!scroller.value) return { r: -1, c: -1 }
|
||||
const contentEl = scroller.value.firstElementChild
|
||||
@@ -227,19 +137,7 @@ 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
|
||||
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) }
|
||||
return resolveCellPosition(relX, relY, store.rows, store.cols)
|
||||
}
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
@@ -247,7 +145,6 @@ const handleMouseDown = (e) => {
|
||||
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
|
||||
@@ -271,14 +168,10 @@ const handleMouseUp = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 右键处理
|
||||
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
|
||||
@@ -290,7 +183,7 @@ const handleContextAction = (action) => {
|
||||
const c = store.selection.start.c
|
||||
switch(action) {
|
||||
case 'copy': performCopy(); break;
|
||||
case 'cut': performCopy(true); break; // 触发剪切
|
||||
case 'cut': performCopy(true); break;
|
||||
case 'paste': performPaste(); break;
|
||||
case 'insertImage': triggerHiddenFileInput(); break;
|
||||
case 'insertRowAbove': store.insertRow(r, 1); break;
|
||||
@@ -303,71 +196,63 @@ const handleContextAction = (action) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷键处理
|
||||
// --- 键盘交互 ---
|
||||
const handleKeyDown = (e) => {
|
||||
if (store.isEditing) 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 }
|
||||
// 1. 建议框导航
|
||||
if (showSuggestions.value && suggestionRef.value) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
suggestionRef.value.navigate('up')
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
suggestionRef.value.navigate('down')
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
suggestionRef.value.navigate('enter')
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
showSuggestions.value = false
|
||||
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
|
||||
// 2. 编辑模式
|
||||
if (store.isEditing) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// 让 Enter 结束编辑
|
||||
} else {
|
||||
e.stopPropagation()
|
||||
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)
|
||||
|
||||
// 3. 回车行为
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.shiftKey ? (r > 0 && store.select(r - 1, c)) : store.select(r + 1, c)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 3. 通用处理
|
||||
handleGridKeyDown(e, store)
|
||||
}
|
||||
|
||||
// 监听编辑状态变化
|
||||
watchEffect(() => {
|
||||
if (store.isEditing) {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('textarea.grid-editor-textarea')
|
||||
if(el) el.focus()
|
||||
if (el) {
|
||||
el.focus()
|
||||
editorRef.value = el
|
||||
// 关键修改:手动将光标移到末尾,并立即触发一次建议检查
|
||||
// 这样当用户直接输入 "=" 激活编辑时,能立即弹出列表
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
updateSuggestionState()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -376,12 +261,21 @@ watchEffect(() => {
|
||||
<div class="relative w-full h-full outline-none" tabindex="0" @keydown="handleKeyDown">
|
||||
<input type="file" ref="hiddenFileInput" class="hidden" accept="image/*" @change="handleContextMenuImageUpload">
|
||||
|
||||
<!-- 引入公式建议组件 -->
|
||||
<FormulaSuggestion
|
||||
v-if="showSuggestions"
|
||||
ref="suggestionRef"
|
||||
:search="suggestionSearch"
|
||||
:rect="suggestionRect"
|
||||
@select="handleSuggestionSelect"
|
||||
@close="showSuggestions = false"
|
||||
/>
|
||||
|
||||
<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"
|
||||
@@ -393,7 +287,6 @@ watchEffect(() => {
|
||||
</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"
|
||||
@@ -403,7 +296,6 @@ watchEffect(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单元格区域 -->
|
||||
<div class="relative"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@@ -432,10 +324,16 @@ watchEffect(() => {
|
||||
<textarea
|
||||
v-if="store.isEditing"
|
||||
v-model="store.editingValue"
|
||||
ref="editorRef"
|
||||
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"
|
||||
@input="updateSuggestionState"
|
||||
@blur="() => {
|
||||
setTimeout(() => {
|
||||
if (!showSuggestions) store.finishEditing()
|
||||
}, 150)
|
||||
}"
|
||||
@keydown.stop="handleKeyDown"
|
||||
></textarea>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
Undo2, Redo2, Bold, Italic, Underline,
|
||||
AlignLeft, AlignCenter, AlignRight, Image as ImageIcon, Plus
|
||||
Undo2, Redo2, PaintRoller,
|
||||
Bold, Italic, Underline, Strikethrough,
|
||||
AlignLeft, AlignCenter, AlignRight,
|
||||
Image as ImageIcon, Plus,
|
||||
Type, Palette, ChevronDown
|
||||
} from 'lucide-vue-next'
|
||||
import { readFilesAsDataURL } from '@/utils/file'
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
const props = defineProps(['currentStyle'])
|
||||
const emit = defineEmits(['action'])
|
||||
@@ -12,81 +17,123 @@ 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)
|
||||
const images = await readFilesAsDataURL(e.target.files)
|
||||
if (images.length > 0) {
|
||||
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 handleColorChange = (type, color) => {
|
||||
emit('action', 'setStyle', { [type]: color })
|
||||
}
|
||||
|
||||
const handleFontChange = (e) => emit('action', 'setStyle', { fontFamily: e.target.value })
|
||||
const handleFontSizeChange = (e) => emit('action', 'setStyle', { fontSize: e.target.value })
|
||||
|
||||
const btnClass = "p-1.5 rounded hover:bg-gray-200 transition text-gray-700 flex items-center justify-center cursor-pointer relative"
|
||||
const activeClass = "bg-gray-300 text-black"
|
||||
const dividerClass = "w-px h-5 bg-gray-300 mx-1 self-center"
|
||||
</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 class="h-10 bg-[#fbfbfb] border-b border-gray-300 flex items-center px-3 gap-1 select-none shrink-0 overflow-x-auto whitespace-nowrap z-40">
|
||||
|
||||
<!-- 历史 -->
|
||||
<div class="flex gap-0.5">
|
||||
<button :class="btnClass" title="撤销 (Ctrl+Z)" @click="$emit('action', 'undo')"><Undo2 class="w-4 h-4" /></button>
|
||||
<button :class="btnClass" title="重做 (Ctrl+Y)" @click="$emit('action', 'redo')"><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="dividerClass"></div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 mx-1 self-center"></div>
|
||||
<!-- 字体与字号 -->
|
||||
<div class="flex gap-1 items-center">
|
||||
<div class="relative group">
|
||||
<select
|
||||
class="appearance-none bg-transparent hover:bg-gray-200 pl-2 pr-6 py-1 rounded text-xs text-gray-700 outline-none cursor-pointer w-24 border border-transparent hover:border-gray-300 transition-colors"
|
||||
:value="currentStyle.fontFamily || 'Arial'"
|
||||
@change="handleFontChange"
|
||||
>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
</select>
|
||||
<ChevronDown class="w-3 h-3 absolute right-1 top-1.5 text-gray-500 pointer-events-none" />
|
||||
</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 class="relative group">
|
||||
<select
|
||||
class="appearance-none bg-transparent hover:bg-gray-200 pl-2 pr-5 py-1 rounded text-xs text-gray-700 outline-none cursor-pointer w-14 border border-transparent hover:border-gray-300 transition-colors"
|
||||
:value="currentStyle.fontSize || 11"
|
||||
@change="handleFontSizeChange"
|
||||
>
|
||||
<option v-for="size in [9,10,11,12,14,18,24,36]" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
<ChevronDown class="w-3 h-3 absolute right-1 top-1.5 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 pr-2 border-r border-gray-300">
|
||||
<div :class="dividerClass"></div>
|
||||
|
||||
<!-- 样式 -->
|
||||
<div class="flex gap-0.5">
|
||||
<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>
|
||||
<button :class="[btnClass, currentStyle.strike ? activeClass : '']" @click="$emit('action', 'toggleStyle', 'strike')"><Strikethrough class="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div :class="dividerClass"></div>
|
||||
|
||||
<!-- 颜色选择 (使用新组件) -->
|
||||
<div class="flex gap-0.5">
|
||||
<ColorPicker
|
||||
type="text"
|
||||
:icon-component="Type"
|
||||
:color="currentStyle.color"
|
||||
@change="(c) => handleColorChange('color', c)"
|
||||
/>
|
||||
<ColorPicker
|
||||
type="fill"
|
||||
:icon-component="Palette"
|
||||
:color="currentStyle.bg"
|
||||
@change="(c) => handleColorChange('bg', c)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="dividerClass"></div>
|
||||
|
||||
<!-- 对齐方式 (修复状态回显:确保 Cell.vue 中使用了 flex justify 来实现对齐,store 中存储的是 'left'/'center'/'right') -->
|
||||
<div class="flex gap-0.5">
|
||||
<button :class="[btnClass, (!currentStyle.align || 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="dividerClass"></div>
|
||||
|
||||
<!-- 插入 -->
|
||||
<div class="flex gap-0.5">
|
||||
<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="dividerClass"></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')">
|
||||
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700 transition" @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')">
|
||||
<button class="flex items-center gap-1 px-2 py-1 hover:bg-gray-200 rounded text-xs text-gray-700 transition" @click="$emit('action', 'addCol')">
|
||||
<Plus class="w-3 h-3" /> 列
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,13 @@ const routes = [
|
||||
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/excel',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', name: 'Excel', component: () => import('@/views/excel/index.vue') },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { db } from '@/utils/db'
|
||||
import { evaluateFormula } from '@/utils/formula'
|
||||
|
||||
export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
// --- State ---
|
||||
@@ -8,24 +9,24 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
const cols = ref(Array.from({ length: 26 }, (_, i) => ({ label: String.fromCharCode(65 + i), width: 100 })))
|
||||
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)
|
||||
|
||||
// --- History State (Undo/Redo) ---
|
||||
// 颜色历史记录 (最近使用的颜色)
|
||||
const colorHistory = ref([])
|
||||
|
||||
// --- History State ---
|
||||
const history = ref([])
|
||||
const historyIndex = ref(-1)
|
||||
const isUndoing = ref(false)
|
||||
|
||||
// --- Helpers ---
|
||||
const SHEET_KEY = 'default_sheet_v1'
|
||||
const getKey = (r, c) => `${r}_${c}`
|
||||
|
||||
// --- Persistence ---
|
||||
const SHEET_KEY = 'default_sheet_v1'
|
||||
|
||||
// --- Data Loading ---
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -35,6 +36,7 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
Object.assign(cells, data.cells)
|
||||
if (data.rows) rows.value = data.rows
|
||||
if (data.cols) cols.value = data.cols
|
||||
if (data.colorHistory) colorHistory.value = data.colorHistory // 加载历史颜色
|
||||
saveSnapshot()
|
||||
} else {
|
||||
saveSnapshot()
|
||||
@@ -46,115 +48,119 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Snapshot Logic ---
|
||||
// --- Formula & Value Helpers ---
|
||||
|
||||
// 获取原始数据对象
|
||||
const getRawCell = (r, c) => cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
|
||||
|
||||
// 获取计算后的值 (供显示用)
|
||||
// useRecursionStack 防止循环引用 (简单处理)
|
||||
const recursionStack = new Set()
|
||||
|
||||
const getEvaluatedValue = (r, c) => {
|
||||
const key = getKey(r, c)
|
||||
if (recursionStack.has(key)) return "#CYCLE!"
|
||||
|
||||
const cell = getRawCell(r, c)
|
||||
const val = cell.value
|
||||
|
||||
// 如果不是公式,直接返回
|
||||
if (typeof val !== 'string' || !val.startsWith('=')) {
|
||||
return val
|
||||
}
|
||||
|
||||
recursionStack.add(key)
|
||||
try {
|
||||
// 递归求解
|
||||
const result = evaluateFormula(val, (targetR, targetC) => {
|
||||
return getEvaluatedValue(targetR, targetC)
|
||||
})
|
||||
return result
|
||||
} finally {
|
||||
recursionStack.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Snapshot & Save ---
|
||||
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-- }
|
||||
|
||||
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], () => {
|
||||
watch([cells, rows, cols, colorHistory], () => {
|
||||
if (loading.value || isUndoing.value) return
|
||||
clearTimeout(snapshotTimeout)
|
||||
snapshotTimeout = setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
saveSnapshot('auto_save')
|
||||
db.set(SHEET_KEY, { cells, rows: rows.value, cols: cols.value })
|
||||
db.set(SHEET_KEY, {
|
||||
cells, rows: rows.value, cols: cols.value,
|
||||
colorHistory: colorHistory.value
|
||||
})
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
// --- Getters ---
|
||||
// --- Actions ---
|
||||
|
||||
// 添加颜色到历史记录 (去重,保持最近10个)
|
||||
const addToColorHistory = (color) => {
|
||||
if (!color) return
|
||||
const list = colorHistory.value.filter(c => c !== color)
|
||||
list.unshift(color)
|
||||
if (list.length > 10) list.pop()
|
||||
colorHistory.value = list
|
||||
}
|
||||
|
||||
// 基础操作... (省略部分未变代码以节省篇幅,重点看下面的 getCell 逻辑)
|
||||
|
||||
const minR = computed(() => Math.min(selection.start.r, selection.end.r))
|
||||
const maxR = computed(() => Math.max(selection.start.r, selection.end.r))
|
||||
const minC = computed(() => Math.min(selection.start.c, selection.end.c))
|
||||
const maxC = computed(() => Math.max(selection.start.c, selection.end.c))
|
||||
|
||||
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) => cells[getKey(r, c)] || { value: '', type: 'text', style: {} }
|
||||
const currentStyle = computed(() => getCell(selection.start.r, selection.start.c).style || {})
|
||||
const currentStyle = computed(() => getRawCell(selection.start.r, selection.start.c).style || {})
|
||||
|
||||
// 公式栏显示原始值 (公式)
|
||||
const activeCellFormula = computed(() => {
|
||||
if (isEditing.value) return editingValue.value
|
||||
const cell = getCell(selection.start.r, selection.start.c)
|
||||
const cell = getRawCell(selection.start.r, selection.start.c)
|
||||
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
|
||||
return { top, left, width, height }
|
||||
return {
|
||||
top: getRowTop(minR.value),
|
||||
left: getColLeft(minC.value),
|
||||
width: cols.value.slice(minC.value, maxC.value + 1).reduce((acc, c) => acc + c.width, 0),
|
||||
height: rows.value.slice(minR.value, maxR.value + 1).reduce((acc, r) => acc + r.height, 0)
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
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: getRowTop(r1),
|
||||
left: getColLeft(c1),
|
||||
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)
|
||||
}
|
||||
@@ -163,11 +169,9 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
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 || {}
|
||||
|
||||
const style = getRawCell(selection.start.r, selection.start.c).style || {}
|
||||
return {
|
||||
top: sTop + 'px', left: sLeft + 'px',
|
||||
minWidth: w + 'px', minHeight: h + 'px',
|
||||
@@ -178,7 +182,7 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// --- Actions ---
|
||||
// --- Setter Wrapper ---
|
||||
function setCell(r, c, partialData) {
|
||||
const key = getKey(r, c)
|
||||
if (!cells[key]) cells[key] = { type: 'text', value: '', style: {} }
|
||||
@@ -187,51 +191,19 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
if (partialData.type !== undefined) cells[key].type = partialData.type
|
||||
}
|
||||
|
||||
function select(r, c) {
|
||||
if (isEditing.value) return
|
||||
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 }
|
||||
}
|
||||
// --- Standard Actions ---
|
||||
function select(r, c) { if (isEditing.value) return; 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 } }
|
||||
function enableEditing() { isEditing.value = true; const cell = getRawCell(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 }) }
|
||||
function forEachSelectedCell(cb) { for (let r = minR.value; r <= maxR.value; r++) { for (let c = minC.value; c <= maxC.value; c++) { cb(r, c) } } }
|
||||
|
||||
function enableEditing() {
|
||||
isEditing.value = true
|
||||
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 })
|
||||
}
|
||||
function toggleStyle(prop) { const current = !!currentStyle.value[prop]; forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: !current } })) }
|
||||
function setStyle(prop, value) { forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: value } })) }
|
||||
|
||||
function forEachSelectedCell(cb) {
|
||||
for (let r = minR.value; r <= maxR.value; r++) {
|
||||
for (let c = minC.value; c <= maxC.value; c++) {
|
||||
cb(r, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStyle(prop) {
|
||||
const current = !!currentStyle.value[prop]
|
||||
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: !current } }))
|
||||
}
|
||||
function setStyle(prop, value) {
|
||||
forEachSelectedCell((r, c) => setCell(r, c, { style: { [prop]: value } }))
|
||||
}
|
||||
|
||||
// --- Data Shifting ---
|
||||
// --- Data Shifting & Structure ---
|
||||
const shiftCells = (type, index, count, direction) => {
|
||||
const newCells = {}
|
||||
Object.entries(cells).forEach(([key, cell]) => {
|
||||
@@ -246,100 +218,40 @@ export const useSpreadsheetStore = defineStore('spreadsheet', () => {
|
||||
}
|
||||
if (!shouldDelete) newCells[`${newR}_${newC}`] = cell
|
||||
})
|
||||
for (const key in cells) delete cells[key]; Object.assign(cells, newCells)
|
||||
}
|
||||
|
||||
function insertRow(index, count = 1) { saveSnapshot(); for(let k=0; k<count; k++) rows.value.splice(index, 0, { index: -1, height: 25 }); rows.value.forEach((r, i) => r.index = i); shiftCells('row', index, count, 1) }
|
||||
function deleteRow(index, count = 1) { saveSnapshot(); rows.value.splice(index, count); rows.value.forEach((r, i) => r.index = i); shiftCells('row', index, count, -1) }
|
||||
function insertCol(index, count = 1) { saveSnapshot(); 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 deleteCol(index, count = 1) { saveSnapshot(); 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) }
|
||||
|
||||
function setCutSelection() { cutSelection.value = JSON.parse(JSON.stringify(selection)) }
|
||||
function clearCutSelection() { cutSelection.value = null }
|
||||
function clearSelection() { forEachSelectedCell((r, c) => setCell(r, c, { value: '', type: 'text' })) }
|
||||
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(); const srcR = selection.start.r; const srcC = selection.start.c; const template = getRawCell(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; setCell(r, c, JSON.parse(JSON.stringify(template))) } } selection.end = { r: targetEndR, c: targetEndC } }
|
||||
|
||||
// 恢复历史记录快照
|
||||
const restoreSnapshot = (snapshot) => {
|
||||
for (const key in cells) delete cells[key]
|
||||
Object.assign(cells, newCells)
|
||||
Object.assign(cells, snapshot.cells)
|
||||
rows.value = snapshot.rows
|
||||
cols.value = snapshot.cols
|
||||
}
|
||||
|
||||
function insertRow(index, count = 1) {
|
||||
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)
|
||||
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) {
|
||||
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 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
|
||||
setCell(r, c, JSON.parse(JSON.stringify(template)))
|
||||
}
|
||||
}
|
||||
selection.end = { r: targetEndR, c: targetEndC }
|
||||
}
|
||||
|
||||
function isColSelected(i) { return i >= minC.value && i <= maxC.value }
|
||||
function isRowSelected(i) { return i >= minR.value && i <= maxR.value }
|
||||
const undo = () => { if (historyIndex.value > 0) { isUndoing.value = true; historyIndex.value--; restoreSnapshot(history.value[historyIndex.value]); isUndoing.value = false } }
|
||||
const redo = () => { if (historyIndex.value < history.value.length - 1) { isUndoing.value = true; historyIndex.value++; restoreSnapshot(history.value[historyIndex.value]); isUndoing.value = false } }
|
||||
|
||||
return {
|
||||
rows, cols, cells, selection, cutSelection, // State
|
||||
isEditing, editingValue, loading,
|
||||
rows, cols, cells, selection, cutSelection, isEditing, editingValue, loading,
|
||||
minR, maxR, minC, maxC, totalWidth, totalHeight,
|
||||
getCell, currentStyle, activeCellFormula, layoutInfo, cutLayoutInfo, editorStyle,
|
||||
isColSelected, isRowSelected,
|
||||
loadData, select, extendSelection, selectAll,
|
||||
enableEditing, finishEditing, updateActiveCell, setCell,
|
||||
toggleStyle, setStyle,
|
||||
insertRow, insertCol, deleteRow, deleteCol,
|
||||
setCutSelection, clearCutSelection, executeCut, clearSelection,
|
||||
autoFill, forEachSelectedCell,
|
||||
undo, redo
|
||||
getCell: getRawCell, // 提供给内部复制粘贴用
|
||||
getEvaluatedValue, // 提供给 Grid 显示用
|
||||
currentStyle, activeCellFormula, layoutInfo, cutLayoutInfo, editorStyle, colorHistory,
|
||||
isColSelected: (i) => i >= minC.value && i <= maxC.value,
|
||||
isRowSelected: (i) => i >= minR.value && i <= maxR.value,
|
||||
loadData, select, extendSelection, selectAll, enableEditing, finishEditing, updateActiveCell, setCell,
|
||||
toggleStyle, setStyle, insertRow, insertCol, deleteRow, deleteCol, setCutSelection, clearCutSelection, executeCut, clearSelection, autoFill, forEachSelectedCell, undo, redo, addToColorHistory
|
||||
}
|
||||
})
|
||||
207
src/utils/clipboard.js
Normal file
207
src/utils/clipboard.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { isImageUrl, ensureRowHeightForImage } from './common'
|
||||
|
||||
/**
|
||||
* 执行复制操作
|
||||
* @param {Object} store Pinia Store 实例
|
||||
* @param {Boolean} isCut 是否为剪切模式
|
||||
*/
|
||||
export const copyToClipboard = async (store, isCut = false) => {
|
||||
try {
|
||||
const { minR, maxR, minC, maxC } = store
|
||||
const jsonGrid = []
|
||||
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)
|
||||
// 深度拷贝单元格数据
|
||||
rowJson.push(JSON.parse(JSON.stringify(cell)))
|
||||
|
||||
let val = cell.value == null ? '' : String(cell.value)
|
||||
rowValues.push(val)
|
||||
|
||||
// 构建 HTML
|
||||
let cellHtml = val.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
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 中,实现高保真粘贴
|
||||
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
|
||||
})
|
||||
])
|
||||
|
||||
// 如果是剪切,只标记状态
|
||||
if (isCut) {
|
||||
store.setCutSelection()
|
||||
} else {
|
||||
store.clearCutSelection()
|
||||
}
|
||||
console.log('复制成功')
|
||||
} catch (err) {
|
||||
console.error('剪贴板操作失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行粘贴操作
|
||||
* @param {Object} store Pinia Store 实例
|
||||
*/
|
||||
export const pasteFromClipboard = async (store) => {
|
||||
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()
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const wrapper = doc.querySelector('[data-spreadsheet-json]')
|
||||
|
||||
if (wrapper) {
|
||||
try {
|
||||
// 优先尝试高保真 JSON 粘贴
|
||||
pasteJsonGrid(store, JSON.parse(wrapper.dataset.spreadsheetJson))
|
||||
pasted = true
|
||||
} catch (e) {
|
||||
console.warn('JSON 解析失败,降级处理', e)
|
||||
}
|
||||
}
|
||||
if (!pasted) {
|
||||
pasteHtml(store, html, doc)
|
||||
pasted = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pasted) {
|
||||
const text = await navigator.clipboard.readText()
|
||||
pasteText(store, text)
|
||||
}
|
||||
|
||||
// 粘贴完成后,检查是否有剪切状态。如果有,执行真正的剪切。
|
||||
if (store.cutSelection) {
|
||||
store.executeCut()
|
||||
}
|
||||
} catch (err) {
|
||||
// 降级逻辑:直接读取文本
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) pasteText(store, text)
|
||||
if (store.cutSelection) store.executeCut()
|
||||
} catch(e) {
|
||||
console.error('粘贴失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内部辅助函数 ---
|
||||
|
||||
const pasteJsonGrid = (store, 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) {
|
||||
store.setCell(targetR, targetC, cellData)
|
||||
if (cellData.type === 'image') {
|
||||
ensureRowHeightForImage(store, targetR)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const pasteHtml = (store, html, doc = null) => {
|
||||
if (!doc) {
|
||||
const parser = new DOMParser()
|
||||
doc = parser.parseFromString(html, 'text/html')
|
||||
}
|
||||
const trs = doc.querySelectorAll('tr')
|
||||
|
||||
// 处理非表格的 HTML 内容 (如单个图片或文本)
|
||||
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 })
|
||||
ensureRowHeightForImage(store, store.selection.start.r)
|
||||
} else {
|
||||
pasteText(store, 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' })
|
||||
ensureRowHeightForImage(store, targetR)
|
||||
} else {
|
||||
store.setCell(targetR, targetC, { value: td.textContent.trim(), type: 'text' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const pasteText = (store, 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 (isImageUrl(cleanVal)) {
|
||||
store.setCell(targetR, targetC, { value: cleanVal, type: 'image' })
|
||||
ensureRowHeightForImage(store, targetR)
|
||||
} else {
|
||||
store.setCell(targetR, targetC, { value: val, type: 'text' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
15
src/utils/common.js
Normal file
15
src/utils/common.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// 检查字符串是否为图片 URL (支持 http/https 链接和 base64)
|
||||
export const isImageUrl = (url) => {
|
||||
if (!url || typeof url !== 'string') return false
|
||||
const trimmed = url.trim()
|
||||
if (trimmed.startsWith('data:image/')) return true
|
||||
return /\.(jpeg|jpg|gif|png|bmp|webp|svg)(\?.*)?$/i.test(trimmed)
|
||||
}
|
||||
|
||||
// 辅助:当插入图片时,确保行高至少为 100px 以便预览
|
||||
export const ensureRowHeightForImage = (store, rowIndex) => {
|
||||
const row = store.rows.find(r => r.index === rowIndex)
|
||||
if (row && row.height < 100) {
|
||||
row.height = 100
|
||||
}
|
||||
}
|
||||
35
src/utils/file.js
Normal file
35
src/utils/file.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 读取多个文件并返回 DataURL 数组
|
||||
* @param {FileList | File[]} fileList
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export const readFilesAsDataURL = async (fileList) => {
|
||||
const files = Array.from(fileList)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(readers)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单个文件并返回 DataURL
|
||||
* @param {File} file
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export const readSingleFileAsDataURL = async (file) => {
|
||||
if (!file) return null
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (evt) => resolve(evt.target.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
226
src/utils/formula.js
Normal file
226
src/utils/formula.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 将列索引 (0, 1, 2...) 转换为字母 (A, B, C...)
|
||||
*/
|
||||
export const indexToColName = (index) => {
|
||||
let label = ''
|
||||
let i = index
|
||||
while (i >= 0) {
|
||||
label = String.fromCharCode(65 + (i % 26)) + label
|
||||
i = Math.floor(i / 26) - 1
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
/**
|
||||
* 将坐标 (A1) 解析为 {r, c}
|
||||
*/
|
||||
export const parseCellCoord = (coord) => {
|
||||
const match = coord.match(/^([A-Z]+)([0-9]+)$/)
|
||||
if (!match) return null
|
||||
const colStr = match[1]
|
||||
const rowStr = match[2]
|
||||
|
||||
let c = 0
|
||||
for (let i = 0; i < colStr.length; i++) {
|
||||
c = c * 26 + (colStr.charCodeAt(i) - 64)
|
||||
}
|
||||
return { r: parseInt(rowStr) - 1, c: c - 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数注册表:包含实现逻辑、描述和语法提示
|
||||
* 用于公式计算和 UI 提示
|
||||
*/
|
||||
export const FUNCTION_REGISTRY = {
|
||||
// --- 数学与统计 ---
|
||||
SUM: {
|
||||
description: '计算单元格区域中所有数值的和',
|
||||
syntax: 'SUM(number1, [number2], ...)',
|
||||
impl: (...args) => {
|
||||
return args.flat(Infinity).reduce((a, b) => {
|
||||
const num = Number(b)
|
||||
return isNaN(num) ? a : a + num
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
AVERAGE: {
|
||||
description: '返回参数的平均值(算术平均值)',
|
||||
syntax: 'AVERAGE(number1, [number2], ...)',
|
||||
impl: (...args) => {
|
||||
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
|
||||
if (nums.length === 0) return 0
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||
}
|
||||
},
|
||||
MAX: {
|
||||
description: '返回一组值中的最大值',
|
||||
syntax: 'MAX(number1, [number2], ...)',
|
||||
impl: (...args) => {
|
||||
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
|
||||
return nums.length ? Math.max(...nums) : 0
|
||||
}
|
||||
},
|
||||
MIN: {
|
||||
description: '返回一组值中的最小值',
|
||||
syntax: 'MIN(number1, [number2], ...)',
|
||||
impl: (...args) => {
|
||||
const nums = args.flat(Infinity).map(Number).filter(n => !isNaN(n))
|
||||
return nums.length ? Math.min(...nums) : 0
|
||||
}
|
||||
},
|
||||
COUNT: {
|
||||
description: '计算区域中包含数字的单元格个数',
|
||||
syntax: 'COUNT(value1, [value2], ...)',
|
||||
impl: (...args) => {
|
||||
return args.flat(Infinity).filter(v => typeof v === 'number' || (typeof v === 'string' && !isNaN(Number(v)) && v.trim() !== '')).length
|
||||
}
|
||||
},
|
||||
ROUND: {
|
||||
description: '将数字四舍五入到指定的位数',
|
||||
syntax: 'ROUND(number, num_digits)',
|
||||
impl: (number, digits = 0) => {
|
||||
const n = Number(number)
|
||||
const d = Number(digits)
|
||||
if (isNaN(n) || isNaN(d)) return '#VALUE!'
|
||||
return Math.round(n * (10 ** d)) / (10 ** d)
|
||||
}
|
||||
},
|
||||
ABS: {
|
||||
description: '返回数字的绝对值',
|
||||
syntax: 'ABS(number)',
|
||||
impl: (number) => Math.abs(Number(number))
|
||||
},
|
||||
|
||||
// --- 逻辑 ---
|
||||
IF: {
|
||||
description: '判断是否满足某个条件,如果满足返回一个值,如果不满足返回另一个值',
|
||||
syntax: 'IF(logical_test, value_if_true, [value_if_false])',
|
||||
impl: (condition, trueVal, falseVal) => {
|
||||
return condition ? trueVal : (falseVal === undefined ? false : falseVal)
|
||||
}
|
||||
},
|
||||
AND: {
|
||||
description: '如果所有参数都为 TRUE,则返回 TRUE',
|
||||
syntax: 'AND(logical1, [logical2], ...)',
|
||||
impl: (...args) => args.flat(Infinity).every(Boolean)
|
||||
},
|
||||
OR: {
|
||||
description: '如果任一参数为 TRUE,则返回 TRUE',
|
||||
syntax: 'OR(logical1, [logical2], ...)',
|
||||
impl: (...args) => args.flat(Infinity).some(Boolean)
|
||||
},
|
||||
|
||||
// --- 文本 ---
|
||||
CONCAT: {
|
||||
description: '将两个或多个文本字符串联接为一个字符串',
|
||||
syntax: 'CONCAT(text1, [text2], ...)',
|
||||
impl: (...args) => args.flat(Infinity).join('')
|
||||
},
|
||||
LEN: {
|
||||
description: '返回文本字符串中的字符个数',
|
||||
syntax: 'LEN(text)',
|
||||
impl: (text) => String(text || '').length
|
||||
},
|
||||
TRIM: {
|
||||
description: '移除文本中多余的空格',
|
||||
syntax: 'TRIM(text)',
|
||||
impl: (text) => String(text || '').trim()
|
||||
},
|
||||
UPPER: {
|
||||
description: '将文本转换为大写',
|
||||
syntax: 'UPPER(text)',
|
||||
impl: (text) => String(text || '').toUpperCase()
|
||||
},
|
||||
LOWER: {
|
||||
description: '将文本转换为小写',
|
||||
syntax: 'LOWER(text)',
|
||||
impl: (text) => String(text || '').toLowerCase()
|
||||
},
|
||||
|
||||
// --- 日期与时间 ---
|
||||
NOW: {
|
||||
description: '返回当前日期和时间',
|
||||
syntax: 'NOW()',
|
||||
impl: () => new Date().toLocaleString()
|
||||
},
|
||||
TODAY: {
|
||||
description: '返回当前日期',
|
||||
syntax: 'TODAY()',
|
||||
impl: () => new Date().toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易公式求值器
|
||||
* @param {String} formula 公式字符串 (e.g. "=SUM(A1:B2) + 10")
|
||||
* @param {Function} getValueCb 回调函数,用于获取指定单元格的值 (r, c) => val
|
||||
*/
|
||||
export const evaluateFormula = (formula, getValueCb) => {
|
||||
if (!formula || typeof formula !== 'string' || !formula.startsWith('=')) return formula
|
||||
|
||||
const expression = formula.substring(1).trim()
|
||||
if (!expression) return ''
|
||||
|
||||
// 1. 预处理范围引用:将 A1:B2 转换为数值数组 [1, 2, 3, 4]
|
||||
// 注意:为了支持文本函数,如果单元格内容是文本,也需要正确提取
|
||||
const rangeRegex = /([A-Z]+[0-9]+):([A-Z]+[0-9]+)/g
|
||||
|
||||
const parsedExpressionWithRanges = expression.replace(rangeRegex, (match, start, end) => {
|
||||
const s = parseCellCoord(start)
|
||||
const e = parseCellCoord(end)
|
||||
if (!s || !e) return match
|
||||
|
||||
const values = []
|
||||
const minR = Math.min(s.r, e.r), maxR = Math.max(s.r, e.r)
|
||||
const minC = Math.min(s.c, e.c), maxC = Math.max(s.c, e.c)
|
||||
|
||||
for(let r=minR; r<=maxR; r++) {
|
||||
for(let c=minC; c<=maxC; c++) {
|
||||
let val = getValueCb(r, c)
|
||||
// 尝试转为数字,如果不仅是数字字符则保留原值(用于文本处理)
|
||||
const numVal = Number(val)
|
||||
if (!isNaN(numVal) && val !== '') {
|
||||
values.push(numVal)
|
||||
} else {
|
||||
// 简单的转义处理,防止字符串破坏数组结构
|
||||
values.push(`"${String(val).replace(/"/g, '\\"')}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `[${values.join(',')}]`
|
||||
})
|
||||
|
||||
// 2. 预处理单个单元格引用:将 A1 转换为具体值
|
||||
// 复杂的正则用于避免匹配到已经是字符串中的内容,这里做简化处理
|
||||
// 我们假设单元格引用不会出现在引号内部(这是一个简易引擎的限制)
|
||||
const cellRegex = /(?<!["'[a-zA-Z0-9_])([A-Z]+[0-9]+)(?!["'[a-zA-Z0-9_])/g
|
||||
const finalExpression = parsedExpressionWithRanges.replace(cellRegex, (match) => {
|
||||
const coords = parseCellCoord(match)
|
||||
if (!coords) return 0
|
||||
const val = getValueCb(coords.r, coords.c)
|
||||
const numVal = Number(val)
|
||||
// 如果是纯数字,返回数字;否则返回带引号的字符串
|
||||
return (!isNaN(numVal) && val !== '') ? numVal : `"${String(val).replace(/"/g, '\\"')}"`
|
||||
})
|
||||
|
||||
// 3. 构建执行上下文
|
||||
// 将 FUNCTION_REGISTRY 中的 impl 提取出来作为参数传递给 Function 构造器
|
||||
const funcNames = Object.keys(FUNCTION_REGISTRY)
|
||||
const funcImpls = funcNames.map(name => FUNCTION_REGISTRY[name].impl)
|
||||
|
||||
try {
|
||||
// 注入所有支持的函数到执行作用域
|
||||
// e.g. new Function('SUM', 'AVERAGE', ..., 'return SUM(1, 2)')
|
||||
const func = new Function(...funcNames, `return ${finalExpression}`)
|
||||
const result = func(...funcImpls)
|
||||
|
||||
// 如果结果是 NaN 或 Infinity,做一些友好的处理
|
||||
if (typeof result === 'number' && isNaN(result)) return '#VALUE!'
|
||||
if (typeof result === 'number' && !isFinite(result)) return '#DIV/0!'
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn(`Formula Eval Error: ${formula}`, e)
|
||||
return '#ERROR!'
|
||||
}
|
||||
}
|
||||
107
src/utils/keyboard.js
Normal file
107
src/utils/keyboard.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { copyToClipboard, pasteFromClipboard } from './clipboard'
|
||||
|
||||
/**
|
||||
* 统一处理网格的键盘事件
|
||||
* @param {KeyboardEvent} e 事件对象
|
||||
* @param {Object} store Pinia Store 实例
|
||||
*/
|
||||
export const handleGridKeyDown = async (e, store) => {
|
||||
// 如果正在编辑单元格,不拦截(除了特定的快捷键)
|
||||
if (store.isEditing) return
|
||||
|
||||
const { r, c } = store.selection.start
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
// 1. 系统级操作 (Ctrl 组合键)
|
||||
if (isCtrl) {
|
||||
if (key === 'c') {
|
||||
e.preventDefault()
|
||||
await copyToClipboard(store)
|
||||
return
|
||||
}
|
||||
if (key === 'x') {
|
||||
e.preventDefault()
|
||||
await copyToClipboard(store, true) // 剪切模式
|
||||
return
|
||||
}
|
||||
if (key === 'v') {
|
||||
e.preventDefault()
|
||||
await pasteFromClipboard(store)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 导航 (Tab)
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
if (c > 0) store.select(r, c - 1)
|
||||
} else {
|
||||
if (c < store.cols.length - 1) store.select(r, c + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 状态控制 (Esc)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
store.clearCutSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 方向键 (Arrow Keys)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 回车行为 (Enter)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
if (r > 0) store.select(r - 1, c)
|
||||
} else {
|
||||
store.select(r + 1, c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 快捷编辑/删除 (F2, Backspace, Delete)
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
store.enableEditing()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 普通输入触发编辑
|
||||
// 排除 Ctrl/Alt 组合键,只处理单字符或 Shift+字符
|
||||
if (e.key.length === 1 && !isCtrl && !e.altKey) {
|
||||
e.preventDefault()
|
||||
store.enableEditing()
|
||||
store.editingValue = e.key
|
||||
}
|
||||
}
|
||||
30
src/utils/layout.js
Normal file
30
src/utils/layout.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 根据相对坐标 (x, y) 计算单元格行列索引
|
||||
* @param {Number} x 相对于网格内容区域左上角的 X 坐标
|
||||
* @param {Number} y 相对于网格内容区域左上角的 Y 坐标
|
||||
* @param {Array} rows 行配置数组
|
||||
* @param {Array} cols 列配置数组
|
||||
* @returns {Object} { r, c } 如果坐标无效(如在负区域)返回 { r: -1, c: -1 }
|
||||
*/
|
||||
export const resolveCellPosition = (x, y, rows, cols) => {
|
||||
if (x < 0 || y < 0) return { r: -1, c: -1 }
|
||||
|
||||
let c = 0, currentX = 0
|
||||
// 遍历列宽
|
||||
while(c < cols.length && currentX + cols[c].width < x) {
|
||||
currentX += cols[c].width
|
||||
c++
|
||||
}
|
||||
|
||||
let r = 0, currentY = 0
|
||||
// 遍历行高
|
||||
while(r < rows.length && currentY + rows[r].height < y) {
|
||||
currentY += rows[r].height
|
||||
r++
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.min(r, rows.length - 1),
|
||||
c: Math.min(c, cols.length - 1)
|
||||
}
|
||||
}
|
||||
69
src/utils/toolbarActions.js
Normal file
69
src/utils/toolbarActions.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ensureRowHeightForImage } from './common'
|
||||
|
||||
/**
|
||||
* 统一处理工具栏的动作分发
|
||||
* @param {Object} store Pinia Store 实例
|
||||
* @param {String} type 动作类型
|
||||
* @param {Any} payload 动作参数
|
||||
*/
|
||||
export const handleToolbarAction = (store, type, payload) => {
|
||||
switch (type) {
|
||||
case 'undo':
|
||||
store.undo()
|
||||
break
|
||||
case 'redo':
|
||||
store.redo()
|
||||
break
|
||||
|
||||
case 'toggleStyle':
|
||||
store.toggleStyle(payload)
|
||||
break
|
||||
|
||||
case 'setAlign':
|
||||
store.setStyle('align', payload)
|
||||
break
|
||||
|
||||
case 'setStyle':
|
||||
if (payload && typeof payload === 'object') {
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
store.setStyle(key, value)
|
||||
|
||||
// 如果是颜色相关的设置,添加到历史记录
|
||||
if (key === 'color' || key === 'bg') {
|
||||
store.addToColorHistory(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'insertImages':
|
||||
handleInsertImages(store, payload)
|
||||
break
|
||||
|
||||
case 'addRow':
|
||||
store.insertRow(store.selection.start.r + 1)
|
||||
break
|
||||
case 'addCol':
|
||||
store.insertCol(store.selection.start.c + 1)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('未知的工具栏动作:', type)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsertImages = (store, images) => {
|
||||
if (!images || !images.length) return
|
||||
|
||||
const startR = store.selection.start.r
|
||||
const startC = store.selection.start.c
|
||||
|
||||
ensureRowHeightForImage(store, startR)
|
||||
|
||||
images.forEach((imgUrl, index) => {
|
||||
const targetC = startC + index
|
||||
if (targetC < store.cols.length) {
|
||||
store.setCell(startR, targetC, { value: imgUrl, type: 'image' })
|
||||
}
|
||||
})
|
||||
}
|
||||
54
src/views/excel/index.vue
Normal file
54
src/views/excel/index.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import Toolbar from '@/components/spreadsheet/Toolbar.vue'
|
||||
import FormulaBar from '@/components/spreadsheet/FormulaBar.vue'
|
||||
import GridEditor from '@/components/spreadsheet/GridEditor.vue'
|
||||
import StatusBar from '@/components/spreadsheet/StatusBar.vue'
|
||||
import { useSpreadsheetStore } from '@/stores/spreadsheet'
|
||||
// 引入封装好的工具栏逻辑
|
||||
import { handleToolbarAction } from '@/utils/toolbarActions'
|
||||
|
||||
const store = useSpreadsheetStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 从 IndexedDB 加载上次的数据
|
||||
store.loadData()
|
||||
})
|
||||
|
||||
// 统一处理工具栏事件
|
||||
const onToolbarAction = (type: string, payload?: any) => {
|
||||
handleToolbarAction(store, type, payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full bg-white">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
:current-style="store.currentStyle"
|
||||
@action="onToolbarAction"
|
||||
/>
|
||||
|
||||
<!-- 公式栏 -->
|
||||
<FormulaBar
|
||||
:value="store.activeCellFormula"
|
||||
@update="val => store.updateActiveCell(val)"
|
||||
/>
|
||||
|
||||
<!-- 网格编辑器 (自适应剩余空间) -->
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<!-- 加载遮罩 -->
|
||||
<div v-if="store.loading" class="absolute inset-0 flex items-center justify-center bg-white z-50">
|
||||
<span class="text-gray-500 text-sm">正在加载表格数据...</span>
|
||||
</div>
|
||||
|
||||
<GridEditor />
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<StatusBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user