322 lines
7.7 KiB
Vue
322 lines
7.7 KiB
Vue
<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>
|