Files
llm-in-text/src/components/ImageEditorComponent.vue
T
2026-06-27 22:22:42 +08:00

322 lines
7.7 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>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import 'tui-color-picker/dist/tui-color-picker.css'
import 'tui-image-editor/dist/tui-image-editor.css'
const props = defineProps({
imageUrl: { type: String, required: true },
imageName: { type: String, default: 'image' }
})
const emit = defineEmits(['error'])
const editorHost = ref(null)
const isLoading = ref(false)
const isReady = ref(false)
const editorMenus = ['crop', 'flip', 'rotate', 'draw', 'shape', 'icon', 'text', 'filter']
const editorTheme = {
'common.bi.image': 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
'common.backgroundColor': '#08111f',
'common.border': '0px',
'menu.normalLabel.color': '#9fb0c8',
'menu.activeLabel.color': '#f8fafc',
'submenu.backgroundColor': 'rgba(8, 17, 31, 0.95)',
'submenu.partition.color': 'rgba(159, 176, 200, 0.2)',
'submenu.normalLabel.color': '#9fb0c8',
'submenu.activeLabel.color': '#f8fafc',
'checkbox.backgroundColor': '#f8fafc',
'range.pointer.color': '#f8fafc',
'range.bar.color': 'rgba(159, 176, 200, 0.3)',
'range.subbar.color': '#38bdf8',
'range.value.color': '#f8fafc',
'range.value.backgroundColor': 'rgba(15, 23, 42, 0.88)',
'range.value.border': '1px solid rgba(159, 176, 200, 0.22)',
'range.title.color': '#cbd5e1',
'colorpicker.button.border': '1px solid rgba(159, 176, 200, 0.2)',
'colorpicker.title.color': '#e2e8f0'
}
let editorInstance = null
let loadRequestId = 0
function getErrorMessage(error, fallback) {
return error instanceof Error && error.message ? error.message : fallback
}
function dataUrlToBlob(dataUrl) {
const [header, body = ''] = String(dataUrl || '').split(',')
const mimeMatch = header.match(/data:([^;]+);base64/)
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
const binary = atob(body)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return new Blob([bytes], { type: mimeType })
}
async function getImageEditorConstructor() {
const module = await import('tui-image-editor')
return module.default || module
}
async function initializeEditor() {
if (!editorHost.value || !props.imageUrl || editorInstance) return
isLoading.value = true
try {
const ImageEditor = await getImageEditorConstructor()
editorInstance = new ImageEditor(editorHost.value, {
includeUI: {
loadImage: {
path: props.imageUrl,
name: props.imageName || 'image'
},
menu: editorMenus,
initMenu: 'draw',
menuBarPosition: 'bottom',
uiSize: {
width: '100%',
height: '100%'
},
theme: editorTheme,
usageStatistics: false
},
cssMaxWidth: 1280,
cssMaxHeight: 960,
selectionStyle: {
cornerSize: 16,
rotatingPointOffset: 56
},
usageStatistics: false
})
isReady.value = true
} catch (error) {
emit('error', getErrorMessage(error, '图片编辑器加载失败'))
} finally {
isLoading.value = false
}
}
async function reloadImageFromProps() {
if (!editorInstance || !props.imageUrl) return
const currentRequestId = ++loadRequestId
isLoading.value = true
try {
await editorInstance.loadImageFromURL(props.imageUrl, props.imageName || 'image')
editorInstance.clearUndoStack()
editorInstance.clearRedoStack()
} catch (error) {
if (currentRequestId === loadRequestId) {
emit('error', getErrorMessage(error, '重新载入图片失败'))
}
} finally {
if (currentRequestId === loadRequestId) {
isLoading.value = false
}
}
}
async function exportImageBlob(options = {}) {
if (!editorInstance) {
throw new Error('图片编辑器尚未就绪')
}
editorInstance.discardSelection()
const dataUrl = editorInstance.toDataURL({
format: options.format || 'png',
quality: typeof options.quality === 'number' ? options.quality : 0.92
})
return dataUrlToBlob(dataUrl)
}
function destroyEditor() {
loadRequestId += 1
isReady.value = false
if (!editorInstance) return
editorInstance.destroy()
editorInstance = null
}
watch(
() => props.imageUrl,
(nextUrl, previousUrl) => {
if (!editorInstance || !nextUrl || nextUrl === previousUrl) return
reloadImageFromProps()
}
)
onMounted(() => {
initializeEditor()
})
onBeforeUnmount(() => {
destroyEditor()
})
defineExpose({
exportImageBlob,
reloadImageFromProps,
isReady
})
</script>
<template>
<div class="image-editor-shell">
<div v-if="isLoading && !isReady" class="editor-placeholder">
<div class="editor-placeholder-card">
<strong>正在装载图片编辑器</strong>
<p>首次打开会初始化 TUI Image Editor时间会略长一些</p>
</div>
</div>
<div
ref="editorHost"
class="image-editor-host tui-image-editor-container bottom"
:class="{ 'is-initializing': isLoading && !isReady }"
></div>
</div>
</template>
<style scoped>
.image-editor-shell {
position: relative;
overflow: hidden;
min-height: 640px;
height: min(78vh, 920px);
height: min(78dvh, 920px);
border: 1px solid var(--github-border);
border-radius: 18px;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.14), transparent 32%),
linear-gradient(180deg, #08111f, #0f172a 58%, #121a2c);
box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22);
}
.image-editor-host.tui-image-editor-container {
height: 100%;
min-height: 100%;
width: 100%;
}
.image-editor-host.is-initializing {
visibility: hidden;
}
.editor-placeholder {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.editor-placeholder-card {
width: min(420px, 100%);
padding: 22px 24px;
border: 1px solid rgba(159, 176, 200, 0.18);
border-radius: 18px;
background: rgba(8, 17, 31, 0.82);
color: #f8fafc;
text-align: center;
backdrop-filter: blur(14px);
}
.editor-placeholder-card strong {
display: block;
font-size: 1rem;
}
.editor-placeholder-card p {
margin: 10px 0 0;
color: rgba(226, 232, 240, 0.84);
line-height: 1.7;
}
.image-editor-shell :deep(.tui-image-editor-container) {
background: transparent;
}
.image-editor-shell :deep(.tui-image-editor-header) {
display: none;
}
.image-editor-shell :deep(.tui-image-editor-main-container) {
top: 0;
bottom: 78px;
}
.image-editor-shell :deep(.tui-image-editor-main) {
top: 0;
}
.image-editor-shell :deep(.tui-image-editor-wrap) {
top: 0;
}
.image-editor-shell :deep(.tui-image-editor-controls) {
height: 78px;
border-top: 1px solid rgba(159, 176, 200, 0.12);
background: rgba(8, 17, 31, 0.9);
backdrop-filter: blur(14px);
}
.image-editor-shell :deep(.tui-image-editor-menu > .tui-image-editor-item) {
margin: 0 6px;
padding: 10px 10px 4px;
border-radius: 12px;
}
.image-editor-shell :deep(.tui-image-editor-menu > .tui-image-editor-item.active) {
background: rgba(248, 250, 252, 0.14);
}
.image-editor-shell :deep(.tui-image-editor-submenu) {
height: 166px;
overflow-y: auto;
overscroll-behavior-y: contain;
}
.image-editor-shell :deep(.tui-image-editor-submenu > div) {
padding-bottom: 18px;
}
.image-editor-shell :deep(.tui-image-editor-canvas-container) {
border-radius: 14px;
overflow: hidden;
}
@media (max-width: 960px) {
.image-editor-shell {
min-height: 560px;
height: min(72dvh, 72vh);
}
.image-editor-shell :deep(.tui-image-editor-main-container) {
bottom: 88px;
}
.image-editor-shell :deep(.tui-image-editor-controls) {
height: 88px;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.image-editor-shell :deep(.tui-image-editor-menu) {
padding: 0 16px;
}
}
</style>