xlx_teacher_app/src/components/marking/components/renderer/TraceToolbar.vue

460 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { MarkingSubmitData } from '../../composables/useMarkingData'
import { useSessionStorage } from '@vueuse/core'
import { DictCode, useDict } from '@/composables/useDict'
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
import { markingSettings as settings } from '../../composables/useMarkingSettings'
interface Props {
canUndo?: boolean
canRedo?: boolean
hasMarks?: boolean
specialMarks?: {
excellent: boolean
typical: boolean
problem: boolean
}
isLandscape?: boolean
currentMarkingData?: MarkingSubmitData
}
const props = withDefaults(defineProps<Props>(), {
canUndo: false,
canRedo: false,
hasMarks: false,
specialMarks: () => ({
excellent: false,
typical: false,
problem: false,
}),
isLandscape: false,
})
const emit = defineEmits<{
'add-correct-mark': [position: { x: number, y: number }]
'add-wrong-mark': [position: { x: number, y: number }]
'add-half-mark': [position: { x: number, y: number }]
'undo': []
'redo': []
'clear-all': []
'toggle-special-mark': [type: 'excellent' | 'typical' | 'problem']
}>()
const currentTool = defineModel<MarkingTool>('currentTool', { required: true })
const showToolOptions = computed(() => {
return [MarkingTool.PEN, MarkingTool.TEXT].includes(currentTool.value)
})
// 工具栏引用
const toolbarRef = ref<HTMLElement>()
// 工具栏位置状态,使用 SessionStorage 持久化,默认位于左下角
const position = useSessionStorage('marking-toolbar-position', { x: 16, y: 16 })
// 收起状态
const isCollapsed = useSessionStorage('marking-toolbar-collapsed', false)
// 拖动状态
const isDragging = ref(false)
const dragState = ref({
startX: 0,
startY: 0,
startPositionX: 0,
startPositionY: 0,
})
const { getDictOptionsComputed } = useDict()
const { options: problemTypeOptions } = getDictOptionsComputed(DictCode.SCAN_ANOMALY_STATUS)
const problemTooltipContent = computed(() => {
if (props.currentMarkingData?.isProblem) {
return `当前问题类型:${problemTypeOptions.value.find(item => item.value === props.currentMarkingData?.problemType)?.label}`
}
return '标记为问题卷'
})
/**
* 将 SVG 转换为 data URL用于小程序
*/
function svgToDataURL(svg: string): string {
// 将被设置到 dataset 中的属性还原出来
svg = svg.replace(/data-(.*?=(['"]).*?\2)/g, '$1')
// 将被设置到 data-xlink-href 的属性还原出来
svg = svg.replace(/xlink-href=/g, 'xlink:href=')
// 将 dataset 中被变成 kebab-case 写法的 viewBox 还原出来
svg = svg.replace(/view-box=/g, 'viewBox=')
// 清除 SVG 中不应该显示的 title、desc、defs 元素
svg = svg.replace(/<(title|desc|defs)>[\s\S]*?<\/\1>/g, '')
// 为非标准 XML 的 SVG 添加 xmlns防止视图层解析出错
if (!/xmlns=/.test(svg))
svg = svg.replace(/<svg/, '<svg xmlns=\'http://www.w3.org/2000/svg\'')
// 对 SVG 中出现的浮点数统一取最多两位小数,缓解数据量过大问题
svg = svg.replace(/\d+\.\d+/g, match => Number.parseFloat(Number.parseFloat(match).toFixed(2)) as any)
// 清除注释,缓解数据量过大的问题
svg = svg.replace(/<!--[\s\S]*?-->/g, '')
// 模拟 HTML 的 white-space 行为,将多个空格或换行符换成一个空格,减少数据量
svg = svg.replace(/\s+/g, ' ')
// 对特殊符号进行转义,这里参考了 https://github.com/bhovhannes/svg-url-loader/blob/master/src/loader.js
svg = svg.replace(/[{}|\\^~[\]`"<>#%]/g, (match) => {
return `%${match[0].charCodeAt(0).toString(16).toUpperCase()}`
})
// 单引号替换为 \',由于 kbone 的 bug节点属性中的双引号在生成 outerHTML 时不会被转义导致出错
// 因此 background-image: url( 后面只能跟单引号,所以生成的 URI 内部也就只能用斜杠转义单引号了
svg = svg.replace(/'/g, '\\\'')
// 最后添加 mime 头部,变成 Webview 可以识别的 Data URI
return `data:image/svg+xml,${svg.trim()}`
}
/**
* 生成半对标记的 SVG data URL
* 根据当前按钮状态动态生成颜色
*/
function generateHalfMarkSvg(isActive: boolean): string {
// 根据激活状态选择颜色
const color = isActive ? '#ffffff' : '#eab308' // 白色(激活)或黄色(未激活)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<line x1="6" y1="12" x2="10.5" y2="16.5" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
<line x1="10.5" y1="16.5" x2="18" y2="7.5" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
<line x1="11.25" y1="9.38" x2="17.25" y2="15" stroke="${color}" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>`
return svgToDataURL(svg)
}
const halfMarkSvg = computed(() => {
const isActive = currentTool.value === MarkingTool.HALF
return generateHalfMarkSvg(isActive)
})
// 待放置标记的状态
const pendingMarkType = ref<'correct' | 'wrong' | 'half' | null>(null)
// 统一的按钮基础样式
const baseButtonClass = computed(() => {
return props.isLandscape
? 'size-32rpx lg:size-8 !p-0 rounded-lg flex items-center justify-center transition-all duration-200 ease-in-out shadow-sm hover:shadow-md active:scale-95 after:border-none'
: 'size-8 lg:size-10 !p-0 rounded-lg flex items-center justify-center transition-all duration-200 ease-in-out shadow-sm hover:shadow-md active:scale-95 after:border-none'
})
const separatorClass = computed(() => {
return props.isLandscape
? 'mx-4rpx lg:mx-1 h-4 lg:h-6 w-px flex-shrink-0 bg-gray-300'
: 'h-5 lg:h-6 w-px bg-gray-300'
})
const iconClass = computed(() => {
return props.isLandscape
? 'size-20rpx lg:size-4'
: 'size-5 lg:size-5 w-4 lg:w-5'
})
/**
* 获取工具按钮样式类
*/
function getToolButtonClass(tool: MarkingTool) {
const isActive = currentTool.value === tool
return [
baseButtonClass.value,
isActive
? 'bg-blue-600 text-white border border-blue-500'
: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700',
]
}
/**
* 获取标记按钮样式类(对错半对)
*/
function getMarkButtonClass(type: 'correct' | 'wrong' | 'half') {
const toolMap = {
correct: MarkingTool.CORRECT,
wrong: MarkingTool.WRONG,
half: MarkingTool.HALF,
}
const colorMap = {
correct: {
active: 'bg-green-500 text-white border border-green-400',
inactive: 'bg-white text-green-600 border border-green-200 hover:bg-green-50 hover:border-green-300 hover:text-green-700',
},
wrong: {
active: 'bg-red-500 text-white border border-red-400',
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-red-50 hover:border-red-300 hover:text-red-700',
},
half: {
active: 'bg-yellow-500 text-white border border-yellow-400',
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700',
},
}
const isActive = currentTool.value === toolMap[type]
const colors = colorMap[type]
return [baseButtonClass.value, isActive ? colors.active : colors.inactive]
}
/**
* 获取特殊标记按钮样式类
*/
function getSpecialButtonClass(type: 'excellent' | 'typical' | 'problem') {
const isActive = props.specialMarks[type]
const colorMap = {
excellent: {
active: 'bg-yellow-500 text-white border border-yellow-400',
inactive: 'bg-white text-yellow-600 border border-yellow-200 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700',
},
typical: {
active: 'bg-blue-600 text-white border border-blue-500',
inactive: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700',
},
problem: {
active: 'bg-red-500 text-white border border-red-400',
inactive: 'bg-white text-red-600 border border-red-200 hover:bg-red-50 hover:border-red-300 hover:text-red-700',
},
}
const colors = colorMap[type]
return [baseButtonClass.value, props.isLandscape ? 'text-12rpx lg:text-sm' : 'text-sm lg:text-base', 'font-medium', isActive ? colors.active : colors.inactive]
}
/**
* 切换工具(支持取消激活)
*/
function switchTool(tool: MarkingTool) {
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
pendingMarkType.value = null
// 显示/隐藏工具选项
settings.value.showToolOptions = currentTool.value === 'pen' || currentTool.value === 'text'
}
/**
* 处理标记工具切换(正确/错误/半对)
*/
function handleMarkTool(type: 'correct' | 'wrong' | 'half') {
const toolMap = {
correct: MarkingTool.CORRECT,
wrong: MarkingTool.WRONG,
half: MarkingTool.HALF,
}
const tool = toolMap[type]
currentTool.value = currentTool.value === tool ? MarkingTool.SELECT : tool
pendingMarkType.value = null
}
/**
* 处理图片点击(用于放置标记)
*/
function handleImageClick(position: { x: number, y: number }) {
if (pendingMarkType.value) {
switch (pendingMarkType.value) {
case 'correct':
emit('add-correct-mark', position)
break
case 'wrong':
emit('add-wrong-mark', position)
break
case 'half':
emit('add-half-mark', position)
break
}
pendingMarkType.value = null
}
}
/**
* 处理撤销
*/
function handleUndo() {
if (props.canUndo) {
emit('undo')
}
}
/**
* 处理重做
*/
function handleRedo() {
if (props.canRedo) {
emit('redo')
}
}
/**
* 处理清除所有
*/
function handleClearAll() {
if (props.hasMarks) {
uni.showModal({
confirmButtonText: '确认清除',
cancelButtonText: '取消',
success: (result) => {
if (result.confirm) {
emit('clear-all')
}
},
})
}
}
/**
* 切换特殊标记
*/
function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
emit('toggle-special-mark', type)
}
/**
* 切换收起/展开状态
*/
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
// 暴露点击处理方法给父组件
defineExpose({
handleImageClick,
})
</script>
<template>
<div
ref="toolbarRef"
class="rounded-tr-base fixed bottom-0 left-0 z-10 select-none border border-white/20 shadow-xl backdrop-blur-sm transition-all duration-300"
:class="[
{
'px-2 py-1 w-fit cursor-move lg:px-3 lg:py-2': !isLandscape && !isCollapsed,
'px-4rpx py-2rpx': isLandscape && !isCollapsed,
'bg-slate-800/95': !isCollapsed,
'backdrop-saturate-150': !isCollapsed,
},
]"
>
<!-- 收起按钮 -->
<div
v-if="isCollapsed"
class="h-full w-8 flex transform cursor-pointer items-center justify-center border border-gray-200 rounded-tr-2xl bg-white shadow-inner"
:class="{
'w-64px': isLandscape,
}"
@click="toggleCollapse"
@mousedown.stop
>
<div class="i-carbon:chevron-right text-base text-blue-600 transition-colors lg:text-lg hover:text-blue-700" />
</div>
<!-- 工具栏内容 -->
<div
v-show="!isCollapsed"
class="flex items-center" :class="{
'gap-4rpx lg:gap-2': !isLandscape,
'gap-4rpx lg:gap-1': isLandscape,
}"
>
<!-- 收起按钮(展开状态时显示) -->
<button
class="border border-gray-200 bg-white text-slate-600 transition-all hover:border-gray-300 hover:bg-gray-50 hover:text-slate-700"
:class="baseButtonClass"
@click="toggleCollapse"
>
<div :class="iconClass" class="i-carbon:chevron-left" />
</button>
<div :class="separatorClass" />
<!-- 对错半对标记 -->
<wd-tooltip content="正确标记" placement="top">
<button :class="getMarkButtonClass('correct')" @click="handleMarkTool('correct')">
<div :class="iconClass" class="i-fluent:checkmark-24-regular" />
</button>
</wd-tooltip>
<wd-tooltip content="错误标记" placement="top">
<button :class="getMarkButtonClass('wrong')" @click="handleMarkTool('wrong')">
<div :class="iconClass" class="i-fluent:dismiss-24-regular" />
</button>
</wd-tooltip>
<wd-tooltip content="半对标记" placement="top">
<button :class="getMarkButtonClass('half')" @click="handleMarkTool('half')">
<view
class="h-48rpx w-48rpx" :class="iconClass"
:style="{
backgroundImage: `url('${halfMarkSvg}')`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}"
/>
</button>
</wd-tooltip>
<div :class="separatorClass" />
<!-- 橡皮擦 -->
<wd-tooltip content="擦除" placement="top">
<button
:class="getToolButtonClass(MarkingTool.ERASER)"
@click="switchTool(MarkingTool.ERASER)"
>
<div :class="iconClass" class="i-fluent:eraser-24-regular" />
</button>
</wd-tooltip>
<!-- 文字工具 -->
<wd-tooltip content="添加文字" placement="top">
<button :class="getToolButtonClass(MarkingTool.TEXT)" @click="switchTool(MarkingTool.TEXT)">
<div :class="iconClass" class="i-fluent:text-24-regular" />
</button>
</wd-tooltip>
<!-- 任意笔画 -->
<wd-tooltip content="自由绘制" placement="top">
<button :class="getToolButtonClass(MarkingTool.PEN)" @click="switchTool(MarkingTool.PEN)">
<div :class="iconClass" class="i-fluent:pen-24-regular" />
</button>
</wd-tooltip>
<div :class="separatorClass" />
<!-- 撤回 -->
<!-- <wd-tooltip content="撤回一步" placement="top">
<button :class="getActionButtonClass('undo')" :disabled="!canUndo" @click="handleUndo">
<div class="i-fluent:arrow-undo-24-regular h-4 w-4" />
</button>
</wd-tooltip> -->
<!-- 特殊标记 -->
<wd-tooltip content="标记为优秀卷" placement="top">
<button
:class="getSpecialButtonClass('excellent')"
@click="toggleSpecialMark('excellent')"
>
优秀
</button>
</wd-tooltip>
<wd-tooltip content="标记为典例卷" placement="top">
<button
:class="getSpecialButtonClass('typical')"
@click="toggleSpecialMark('typical')"
>
典例
</button>
</wd-tooltip>
<wd-tooltip :content="problemTooltipContent" placement="top">
<button
:class="getSpecialButtonClass('problem')"
@click="toggleSpecialMark('problem')"
>
问题
</button>
</wd-tooltip>
</div>
</div>
</template>