You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1006 lines
30 KiB

1 month ago
<template>
<div class="task-page-content">
<input
ref="bgInput"
type="file"
accept="image/*"
style="display: none"
@change="handleBackgroundSelect"
/>
<input
ref="iconImageInput"
type="file"
accept="image/*"
style="display: none"
@change="handleIconImageSelect"
/>
<!-- 可编辑画布区域 -->
<div
ref="canvas"
class="task-canvas"
:class="{ 'insert-icon': insertMode === 'icon', 'insert-textbox': insertMode === 'textbox' }"
:style="canvasStyle"
@click="onCanvasClick"
@mousedown="onCanvasMouseDown"
@mousemove="onCanvasMouseMove"
@mouseup="onCanvasMouseUp"
@mouseleave="onCanvasMouseUp"
>
<!-- 图标元素插入的图片 -->
<div
v-for="icon in icons"
:key="icon.id"
class="canvas-icon"
:class="{ selected: selectedId === icon.id }"
:style="getIconStyle(icon)"
@mousedown.stop="selectElement(icon.id, $event)"
>
<div class="icon-body" :style="{ transform: `rotate(${icon.rotation || 0}deg)` }">
<img v-if="icon.src" :src="icon.src" class="icon-image" />
<i v-else class="el-icon-picture-outline icon-placeholder"></i>
</div>
<div class="icon-resize-handle" v-if="selectedId === icon.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResize($event, icon, pos)"
></div>
<div class="rotate-handle" @mousedown.stop="startRotate($event, icon)"></div>
<div class="delete-handle" @mousedown.stop="deleteIcon(icon.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 文本框元素Office 风格全透明可随时拖拽和调整大小 -->
<div
v-for="box in textBoxes"
:key="box.id"
class="canvas-textbox"
:class="{ selected: selectedId === box.id }"
:style="getTextBoxStyle(box)"
@mousedown="onTextBoxMouseDown(box, $event)"
>
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div>
4 weeks ago
<div class="textbox-input-wrapper">
<div
v-if="box.placeholder && !box.text && focusedBoxId !== box.id"
class="textbox-placeholder"
:style="getTextBoxPlaceholderStyle(box)"
@mousedown="focusTextBox(box, $event)"
>{{ box.placeholder }}</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@focus="onTextBoxFocus(box)"
@blur="onTextBoxBlur(box, $event)"
@mousedown.stop="selectedId = box.id"
@contextmenu="onTextBoxContextMenu(box, $event)"
></div>
</div>
1 month ago
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResizeTextBox($event, box, pos)"
></div>
<div class="textbox-rotate-handle" @mousedown.stop="startRotateTextBox($event, box)"></div>
<div class="delete-handle textbox-delete" @mousedown.stop="deleteTextBox(box.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 绘制中的文本框 -->
<div
v-if="drawingTextBox"
class="drawing-textbox"
:style="drawingTextBoxStyle"
></div>
</div>
4 weeks ago
<!-- 格式工具栏仅选中文字后右键时显示固定定位在右键位置 -->
<div
v-if="formatToolbarBox"
class="textbox-format-toolbar-fixed"
:style="{ left: formatToolbarPosition.x + 'px', top: formatToolbarPosition.y + 'px' }"
@mousedown.stop
>
<el-select v-model="formatToolbarBox.fontFamily" size="mini" placeholder="字体" class="format-font" @change="debouncedSave">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="formatToolbarBox.fontSize" size="mini" placeholder="字号" class="format-size" @change="debouncedSave">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="formatToolbarBox.color" class="format-color" @change="debouncedSave" />
</div>
1 month ago
</div>
</template>
<script>
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
1 month ago
let idCounter = 0
function genId() {
return 'el_' + (++idCounter) + '_' + Date.now()
}
const FONT_OPTIONS = ['宋体', '黑体', '微软雅黑', '楷体', '仿宋', 'Arial', 'Times New Roman', 'Verdana', 'Georgia']
const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
const DEFAULT_FONT = { fontSize: 14, fontFamily: '微软雅黑', color: '#333333' }
4 weeks ago
const MAIN_TITLE_ID = 'task_default_main_title'
const SUB_TITLE_ID = 'task_default_sub_title'
1 month ago
export default {
name: 'TaskPageContent',
props: {
roomId: {
type: [Number, String],
default: null
},
1 month ago
backgroundImage: {
type: String,
default: null
4 weeks ago
},
taskSubTitles: {
type: Array,
default: () => []
1 month ago
}
},
data() {
return {
fontOptions: FONT_OPTIONS,
fontSizeOptions: FONT_SIZE_OPTIONS,
1 month ago
insertMode: null,
pendingIconImage: null,
icons: [],
textBoxes: [],
selectedId: null,
resizeHandles: ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'],
// 拖拽/调整/旋转
dragState: null,
// 绘制文本框
drawingTextBox: false,
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
4 weeks ago
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null
1 month ago
}
},
watch: {
backgroundImage: { handler() { this.debouncedSave() }, immediate: false },
icons: { handler() { this.debouncedSave() }, deep: true },
4 weeks ago
textBoxes: { handler() { this.debouncedSave() }, deep: true },
taskSubTitles: { handler() { this.debouncedSave() }, deep: true }
},
1 month ago
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
4 weeks ago
this._clickHandler = (e) => {
if (this.formatToolbarBoxId && !e.target.closest('.textbox-format-toolbar-fixed')) {
this.formatToolbarBoxId = null
}
}
1 month ago
document.addEventListener('keydown', this._keydownHandler)
4 weeks ago
document.addEventListener('click', this._clickHandler)
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
4 weeks ago
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
1 month ago
},
beforeDestroy() {
this.syncFromDomToData()
this.$emit('task-page-data', this.getDataForSave())
1 month ago
document.removeEventListener('keydown', this._keydownHandler)
4 weeks ago
document.removeEventListener('click', this._clickHandler)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.$emit('save-request')
1 month ago
},
computed: {
4 weeks ago
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
return this.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null
},
1 month ago
canvasStyle() {
const style = {}
if (this.backgroundImage) {
style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
1 month ago
style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
}
return style
},
drawingTextBoxStyle() {
const style = {}
const left = Math.min(this.drawStartX, this.drawCurrentX)
const top = Math.min(this.drawStartY, this.drawCurrentY)
const width = Math.abs(this.drawCurrentX - this.drawStartX)
const height = Math.abs(this.drawCurrentY - this.drawStartY)
style.left = left + 'px'
style.top = top + 'px'
style.width = Math.max(width, 20) + 'px'
style.height = Math.max(height, 20) + 'px'
return style
}
},
methods: {
handleInsertCommand(cmd) {
4 weeks ago
if (cmd === 'removeBackground') {
this.$emit('background-change', null)
return
}
1 month ago
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
this.$refs.bgInput.click()
} else if (cmd === 'icon') {
this.$refs.iconImageInput.value = ''
this.$refs.iconImageInput.click()
}
},
async handleBackgroundSelect(e) {
1 month ago
const file = e.target.files?.[0]
if (!file) return
this.insertMode = null
e.target.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const res = await request.post('/common/upload', formData)
if (res && (res.code === 200 || res.fileName)) {
const path = res.fileName || res.url
if (path) this.$emit('background-change', path)
} else {
this.$message.error(res?.msg || '背景图上传失败')
}
} catch (err) {
this.$message.error(err?.response?.data?.msg || err?.message || '背景图上传失败')
}
1 month ago
},
handleIconImageSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.pendingIconImage = ev.target.result
this.insertMode = 'icon'
}
reader.readAsDataURL(file)
e.target.value = ''
},
onCanvasClick(e) {
if (this.insertMode === 'icon' && this.pendingIconImage) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
this.icons.push({
id: genId(),
x,
y,
width: 60,
height: 60,
rotation: 0,
src: this.pendingIconImage
})
this.pendingIconImage = null
this.insertMode = null
}
if (this.insertMode !== 'textbox' && !this.drawingTextBox) {
if (!e.target.closest('.canvas-textbox') && !e.target.closest('.canvas-icon')) {
this.selectedId = null
}
}
},
onCanvasMouseDown(e) {
if (this.insertMode === 'textbox' && !this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawingTextBox = true
this.drawStartX = e.clientX - rect.left
this.drawStartY = e.clientY - rect.top
this.drawCurrentX = this.drawStartX
this.drawCurrentY = this.drawStartY
}
},
onCanvasMouseMove(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawCurrentX = e.clientX - rect.left
this.drawCurrentY = e.clientY - rect.top
}
if (this.dragState) {
this.$refs.canvas.style.cursor = this.dragState.cursor || 'move'
}
},
onCanvasMouseUp(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = Math.min(this.drawStartX, this.drawCurrentX)
const y = Math.min(this.drawStartY, this.drawCurrentY)
const w = Math.max(Math.abs(this.drawCurrentX - this.drawStartX), 20)
const h = Math.max(Math.abs(this.drawCurrentY - this.drawStartY), 20)
this.textBoxes.push({
id: genId(),
x,
y,
width: w,
height: h,
text: '',
rotation: 0,
fontSize: DEFAULT_FONT.fontSize,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
1 month ago
})
this.drawingTextBox = false
this.insertMode = null
}
this.dragState = null
this.$refs.canvas && (this.$refs.canvas.style.cursor = '')
},
onTextBoxMouseDown(box, e) {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
4 weeks ago
focusTextBox(box, e) {
e.preventDefault()
e.stopPropagation()
this.focusedBoxId = box.id
this.selectedId = box.id
this.$nextTick(() => {
const wrapper = e.target.closest('.textbox-input-wrapper')
const input = wrapper && wrapper.querySelector('.textbox-input')
if (input) input.focus()
})
},
onTextBoxFocus(box) {
this.focusedBoxId = box.id
},
onTextBoxBlur(box, e) {
box.text = (e.target.innerText || '').trim()
this.focusedBoxId = null
},
onTextBoxContextMenu(box, e) {
const sel = window.getSelection()
const hasSelection = sel && sel.toString().trim().length > 0
if (!hasSelection) return
e.preventDefault()
this.formatToolbarBoxId = box.id
this.formatToolbarPosition = { x: e.clientX, y: e.clientY }
},
1 month ago
selectElement(id, e) {
this.selectedId = id
const icon = this.icons.find(i => i.id === id)
const textbox = this.textBoxes.find(t => t.id === id)
const el = icon || textbox
if (!el) return
const rect = this.$refs.canvas.getBoundingClientRect()
const offsetX = e.clientX - rect.left - el.x
const offsetY = e.clientY - rect.top - el.y
this.dragState = {
type: 'drag',
id,
isIcon: !!icon,
startX: e.clientX,
startY: e.clientY,
origX: el.x,
origY: el.y,
offsetX,
offsetY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'drag') return
if (this.dragState.isIcon) return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.origX += dx
this.dragState.origY += dy
el.x = this.dragState.origX
el.y = this.dragState.origY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
}
const onUp = (ev) => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
if (this.dragState?.type === 'drag' && this.dragState.isIcon) {
const r = this.$refs.canvas.getBoundingClientRect()
el.x = ev.clientX - r.left - this.dragState.offsetX
el.y = ev.clientY - r.top - this.dragState.offsetY
}
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResize(e, icon, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: icon.id,
pos,
startX: e.clientX,
startY: e.clientY,
origW: icon.width,
origH: icon.height,
origX: icon.x,
origY: icon.y
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos, origW, origH, origX, origY } = this.dragState
let w = icon.width
let h = icon.height
let x = icon.x
let y = icon.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = icon.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = icon.y + dy }
icon.width = w
icon.height = h
icon.x = x
icon.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startRotate(e, icon) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = icon.x + icon.width / 2
const cy = icon.y + icon.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = icon.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
icon.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResizeTextBox(e, box, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: box.id,
pos,
startX: e.clientX,
startY: e.clientY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos } = this.dragState
let w = box.width
let h = box.height
let x = box.x
let y = box.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = box.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = box.y + dy }
box.width = w
box.height = h
box.x = x
box.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
getIconStyle(icon) {
return {
left: icon.x + 'px',
top: icon.y + 'px',
width: icon.width + 'px',
height: icon.height + 'px'
}
},
getTextBoxStyle(box) {
return {
left: box.x + 'px',
top: box.y + 'px',
width: box.width + 'px',
height: box.height + 'px',
transform: `rotate(${box.rotation || 0}deg)`
}
},
getTextBoxInputStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: box.color || DEFAULT_FONT.color
}
},
4 weeks ago
getTextBoxPlaceholderStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: '#999'
}
},
1 month ago
deleteIcon(id) {
const idx = this.icons.findIndex(i => i.id === id)
if (idx >= 0) this.icons.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
syncTextBoxContent() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
this.textBoxes.forEach((box, idx) => {
const el = inputs[idx]
if (el && document.activeElement !== el && el.innerText !== box.text) {
el.innerText = box.text
}
})
},
syncFromDomToData() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
this.textBoxes.forEach((box, idx) => {
const el = inputs[idx]
if (el) box.text = (el.innerText || '').trim()
})
},
4 weeks ago
ensureDefaultTitleBoxes() {
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
const hasMain = this.textBoxes.some(t => t.id === MAIN_TITLE_ID)
const hasSub = this.textBoxes.some(t => t.id === SUB_TITLE_ID)
if (!hasMain) {
this.textBoxes.unshift({
id: MAIN_TITLE_ID,
x: Math.max(0, (w - 600) / 2) + 2,
4 weeks ago
y: Math.max(0, (h - 180) / 2 - 55),
width: 600,
height: 90,
text: '',
placeholder: '在此输入大标题',
rotation: 0,
fontSize: 56,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
if (!hasSub) {
const insertIdx = this.textBoxes.findIndex(t => t.id === MAIN_TITLE_ID)
const at = insertIdx >= 0 ? insertIdx + 1 : 0
this.textBoxes.splice(at, 0, {
id: SUB_TITLE_ID,
x: Math.max(0, (w - 500) / 2) + 2,
4 weeks ago
y: Math.max(0, (h - 180) / 2 + 55),
width: 500,
height: 52,
text: '',
placeholder: '在此输入副标题',
rotation: 0,
fontSize: 20,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
},
loadFromData(data) {
if (!data) return
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw) return
if (raw.icons && Array.isArray(raw.icons)) {
this.icons = raw.icons.map(i => ({
id: i.id || genId(),
x: Number(i.x) || 0,
y: Number(i.y) || 0,
width: Number(i.width) || 60,
height: Number(i.height) || 60,
rotation: Number(i.rotation) || 0,
src: i.src || ''
}))
}
if (raw.textBoxes && Array.isArray(raw.textBoxes)) {
this.textBoxes = raw.textBoxes.map(t => {
const id = t.id || genId()
const isMain = id === MAIN_TITLE_ID
const isSub = id === SUB_TITLE_ID
return {
id,
x: Number(t.x) || 0,
y: Number(t.y) || 0,
width: Number(t.width) || (isMain ? 600 : isSub ? 500 : 100),
height: Number(t.height) || (isMain ? 90 : isSub ? 52 : 60),
text: String(t.text || ''),
placeholder: isMain ? '在此输入大标题' : isSub ? '在此输入副标题' : undefined,
rotation: Number(t.rotation) || 0,
fontSize: Number(t.fontSize) || (isMain ? 56 : isSub ? 20 : DEFAULT_FONT.fontSize),
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}
})
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) {
this.$emit('task-sub-titles-change', raw.taskSubTitles)
}
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
},
getDataForSave() {
return {
background: this.backgroundImage || null,
icons: this.icons.map(i => ({
id: i.id,
x: i.x,
y: i.y,
width: i.width,
height: i.height,
rotation: i.rotation || 0,
src: i.src
})),
textBoxes: this.textBoxes.map(t => ({
id: t.id,
x: t.x,
y: t.y,
width: t.width,
height: t.height,
text: t.text || '',
4 weeks ago
placeholder: t.placeholder || undefined,
rotation: t.rotation || 0,
fontSize: t.fontSize || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
4 weeks ago
})),
taskSubTitles: this.taskSubTitles || []
}
},
debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
this.$emit('save-request')
}, 300)
},
1 month ago
deleteTextBox(id) {
const idx = this.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) this.textBoxes.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
onKeydown(e) {
if (!this.selectedId) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.contentEditable === 'true') return
e.preventDefault()
const icon = this.icons.find(i => i.id === this.selectedId)
const box = this.textBoxes.find(t => t.id === this.selectedId)
if (icon) this.deleteIcon(icon.id)
else if (box) this.deleteTextBox(box.id)
}
},
startRotateTextBox(e, box) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = box.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
box.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
}
}
</script>
<style scoped>
.task-page-content {
display: flex;
flex-direction: column;
height: 100%;
}
.task-canvas {
flex: 1;
position: relative;
min-height: 200px;
background: #fafafa;
}
.task-canvas.insert-icon {
cursor: crosshair;
}
.task-canvas.insert-textbox {
cursor: crosshair;
}
.canvas-icon {
position: absolute;
cursor: default;
border: 2px solid transparent;
}
.canvas-icon.selected {
border-color: #008aff;
z-index: 10;
}
.icon-body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border-radius: 0;
overflow: hidden;
}
.icon-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.icon-placeholder {
font-size: 24px;
color: #008aff;
}
.delete-handle {
position: absolute;
top: -28px;
right: -4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
pointer-events: auto;
color: #f56c6c;
font-size: 14px;
}
.delete-handle:hover {
background: #f56c6c;
color: #fff;
border-color: #f56c6c;
}
.textbox-delete {
top: -28px;
right: -4px;
}
.icon-resize-handle {
position: absolute;
left: -4px;
right: -4px;
top: -4px;
bottom: -4px;
pointer-events: none;
}
.icon-resize-handle .handle {
position: absolute;
width: 8px;
height: 8px;
background: #008aff;
border: 1px solid #fff;
border-radius: 2px;
pointer-events: auto;
}
.icon-resize-handle .handle.nw { left: -4px; top: -4px; cursor: nw-resize; }
.icon-resize-handle .handle.n { left: 50%; top: -4px; margin-left: -4px; cursor: n-resize; }
.icon-resize-handle .handle.ne { right: -4px; top: -4px; cursor: ne-resize; }
.icon-resize-handle .handle.e { right: -4px; top: 50%; margin-top: -4px; cursor: e-resize; }
.icon-resize-handle .handle.se { right: -4px; bottom: -4px; cursor: se-resize; }
.icon-resize-handle .handle.s { left: 50%; bottom: -4px; margin-left: -4px; cursor: s-resize; }
.icon-resize-handle .handle.sw { left: -4px; bottom: -4px; cursor: sw-resize; }
.icon-resize-handle .handle.w { left: -4px; top: 50%; margin-top: -4px; cursor: w-resize; }
.rotate-handle {
position: absolute;
top: -24px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #008aff;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
/* 文本框:默认显示细黑边框 */
.canvas-textbox {
position: absolute;
cursor: default;
border: 1px solid #333;
background: transparent;
border-radius: 0;
overflow: visible;
}
/* 编辑模式:虚线边框 + 空心圆控制点 + 旋转手柄 */
.canvas-textbox.selected {
border: 1px dashed #333;
z-index: 10;
}
.textbox-resize-handle {
position: absolute;
left: -6px;
right: -6px;
top: -6px;
bottom: -6px;
pointer-events: none;
}
.textbox-resize-handle .handle {
position: absolute;
width: 10px;
height: 10px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
pointer-events: auto;
box-sizing: border-box;
}
.textbox-resize-handle .handle.nw { left: -5px; top: -5px; cursor: nw-resize; }
.textbox-resize-handle .handle.n { left: 50%; top: -5px; margin-left: -5px; cursor: n-resize; }
.textbox-resize-handle .handle.ne { right: -5px; top: -5px; cursor: ne-resize; }
.textbox-resize-handle .handle.e { right: -5px; top: 50%; margin-top: -5px; cursor: e-resize; }
.textbox-resize-handle .handle.se { right: -5px; bottom: -5px; cursor: se-resize; }
.textbox-resize-handle .handle.s { left: 50%; bottom: -5px; margin-left: -5px; cursor: s-resize; }
.textbox-resize-handle .handle.sw { left: -5px; bottom: -5px; cursor: sw-resize; }
.textbox-resize-handle .handle.w { left: -5px; top: 50%; margin-top: -5px; cursor: w-resize; }
.textbox-rotate-handle {
position: absolute;
top: -28px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
4 weeks ago
/* Office 风格格式工具栏:仅选中文字右键时显示,固定定位在右键位置 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
4 weeks ago
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
4 weeks ago
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
margin-top: 4px;
}
4 weeks ago
.textbox-format-toolbar-fixed .format-font { width: 120px; }
.textbox-format-toolbar-fixed .format-size { width: 70px; }
.textbox-format-toolbar-fixed .format-color { vertical-align: middle; }
.textbox-format-toolbar-fixed .format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
}
1 month ago
.textbox-drag-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
cursor: default;
background: transparent;
}
4 weeks ago
.textbox-input-wrapper {
1 month ago
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
4 weeks ago
}
.textbox-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
outline: none;
overflow: hidden;
background: transparent;
color: #999;
cursor: text;
pointer-events: auto;
}
.textbox-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
1 month ago
padding: 8px;
font-size: 14px;
outline: none;
overflow: auto;
background: transparent;
color: #333;
}
4 weeks ago
.textbox-input::selection {
background: #c8c8c8;
color: inherit;
}
1 month ago
.drawing-textbox {
position: absolute;
border: 2px dashed #008aff;
background: rgba(0, 138, 255, 0.05);
pointer-events: none;
}
</style>