317 lines
7.6 KiB
Vue
317 lines
7.6 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);
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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: 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.image-editor-shell :deep(.tui-image-editor-menu) {
|
|||
|
|
padding: 0 16px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|