Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
251 lines
7.1 KiB
Vue
251 lines
7.1 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ButtonStyled,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
LoadingIndicator,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { save } from '@tauri-apps/plugin-dialog'
|
|
import { writeFile } from '@tauri-apps/plugin-fs'
|
|
import { platform } from '@tauri-apps/plugin-os'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import { createSkinEditorTheme } from '@/components/lab/skin-editor/skin-editor-theme'
|
|
|
|
const { locale, formatMessage } = useVIntl()
|
|
const { handleError } = injectNotificationManager()
|
|
const platformName = ref<string>()
|
|
const messages = defineMessages({
|
|
title: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
|
loading: { id: 'app.lab.skin-editor.loading', defaultMessage: 'Loading skin editor' },
|
|
loadErrorTitle: {
|
|
id: 'app.lab.skin-editor.load-error-title',
|
|
defaultMessage: 'Skin editor could not be loaded',
|
|
},
|
|
loadErrorDescription: {
|
|
id: 'app.lab.skin-editor.load-error-description',
|
|
defaultMessage: 'The embedded editor did not finish loading. Try again.',
|
|
},
|
|
resourceError: {
|
|
id: 'app.lab.skin-editor.resource-error',
|
|
defaultMessage:
|
|
'Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive.',
|
|
},
|
|
retry: { id: 'app.lab.skin-editor.retry', defaultMessage: 'Try again' },
|
|
exportSkin: { id: 'app.lab.skin-editor.export-skin', defaultMessage: 'Minecraft skin PNG' },
|
|
})
|
|
|
|
const blockbenchLocale = computed(() => {
|
|
const normalized = locale.value.toLowerCase().replace('_', '-')
|
|
if (normalized === 'zh-tw' || normalized === 'zh-hk') return 'zh_tw'
|
|
if (normalized.startsWith('zh')) return 'zh'
|
|
if (normalized === 'pt-br') return 'pt_br'
|
|
return normalized.split('-')[0]
|
|
})
|
|
|
|
const editorState = ref<'loading' | 'ready' | 'error'>('loading')
|
|
const errorDetail = ref('')
|
|
const resourceError = ref(false)
|
|
const frameKey = ref(0)
|
|
let loadAttempt = 0
|
|
let loadTimeout: number | undefined
|
|
|
|
const editorUrl = computed(() => {
|
|
if (import.meta.env.DEV) {
|
|
return `/__blockbench_skin__/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
|
|
}
|
|
if (!platformName.value) return ''
|
|
const baseUrl =
|
|
platformName.value === 'windows' ? 'http://axolotl-skin.localhost' : 'axolotl-skin://localhost'
|
|
return `${baseUrl}/index.html?embed=skin&lang=${encodeURIComponent(blockbenchLocale.value)}`
|
|
})
|
|
|
|
function clearLoadTimeout() {
|
|
if (loadTimeout !== undefined) window.clearTimeout(loadTimeout)
|
|
loadTimeout = undefined
|
|
}
|
|
|
|
function beginEditorLoad() {
|
|
clearLoadTimeout()
|
|
errorDetail.value = ''
|
|
resourceError.value = false
|
|
editorState.value = 'loading'
|
|
loadTimeout = window.setTimeout(() => {
|
|
editorState.value = 'error'
|
|
}, 15_000)
|
|
}
|
|
|
|
function markEditorReady() {
|
|
clearLoadTimeout()
|
|
editorState.value = 'ready'
|
|
}
|
|
|
|
function markEditorError() {
|
|
clearLoadTimeout()
|
|
editorState.value = 'error'
|
|
}
|
|
|
|
async function reloadEditor() {
|
|
const attempt = ++loadAttempt
|
|
beginEditorLoad()
|
|
if (!import.meta.env.DEV) {
|
|
platformName.value = undefined
|
|
try {
|
|
const errors = await invoke<string[]>('get_skin_editor_resource_errors')
|
|
if (attempt !== loadAttempt) return
|
|
if (errors.length) {
|
|
resourceError.value = true
|
|
errorDetail.value = errors.join(', ')
|
|
markEditorError()
|
|
return
|
|
}
|
|
platformName.value = platform()
|
|
} catch (error) {
|
|
if (attempt !== loadAttempt) return
|
|
markEditorError()
|
|
handleError(error)
|
|
return
|
|
}
|
|
}
|
|
frameKey.value += 1
|
|
}
|
|
|
|
function sendThemeToEditor() {
|
|
frame.value?.contentWindow?.postMessage(
|
|
{ type: 'axolotl-skin-theme', theme: createSkinEditorTheme() },
|
|
'*',
|
|
)
|
|
}
|
|
|
|
function handleFrameLoad() {
|
|
sendThemeToEditor()
|
|
}
|
|
|
|
async function handleEditorMessage(event: MessageEvent<unknown>) {
|
|
if (event.source !== frame.value?.contentWindow) return
|
|
if (!event.data || typeof event.data !== 'object') return
|
|
const message = event.data as {
|
|
type?: unknown
|
|
name?: unknown
|
|
dataUrl?: unknown
|
|
error?: unknown
|
|
}
|
|
if (message.type === 'axolotl-skin-load-error' && typeof message.error === 'string') {
|
|
if (editorState.value === 'ready') return
|
|
errorDetail.value = message.error.slice(0, 1000)
|
|
markEditorError()
|
|
return
|
|
}
|
|
if (message.type === 'axolotl-skin-theme-ready') {
|
|
sendThemeToEditor()
|
|
markEditorReady()
|
|
return
|
|
}
|
|
if (
|
|
message.type !== 'axolotl-skin-export' ||
|
|
typeof message.name !== 'string' ||
|
|
typeof message.dataUrl !== 'string'
|
|
)
|
|
return
|
|
try {
|
|
const path = await save({
|
|
defaultPath: message.name,
|
|
filters: [{ name: formatMessage(messages.exportSkin), extensions: ['png'] }],
|
|
})
|
|
if (!path) return
|
|
const response = await fetch(message.dataUrl)
|
|
if (!response.ok) throw new Error(`Failed to read exported skin: ${response.status}`)
|
|
await writeFile(path, new Uint8Array(await response.arrayBuffer()))
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
const frame = ref<HTMLIFrameElement>()
|
|
let themeObserver: MutationObserver | undefined
|
|
|
|
watch(
|
|
editorUrl,
|
|
(url) => {
|
|
if (url) beginEditorLoad()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onMounted(async () => {
|
|
window.addEventListener('message', handleEditorMessage)
|
|
themeObserver = new MutationObserver(sendThemeToEditor)
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['class', 'style'],
|
|
})
|
|
if (!import.meta.env.DEV) {
|
|
await reloadEditor()
|
|
}
|
|
})
|
|
onUnmounted(() => {
|
|
loadAttempt += 1
|
|
clearLoadTimeout()
|
|
window.removeEventListener('message', handleEditorMessage)
|
|
themeObserver?.disconnect()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<main class="skin-editor-page relative flex h-full min-h-0 w-full flex-1 bg-surface-1">
|
|
<h1 class="sr-only">{{ formatMessage(messages.title) }}</h1>
|
|
<div
|
|
v-if="editorState === 'loading'"
|
|
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 text-secondary"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<LoadingIndicator />
|
|
<p class="m-0">{{ formatMessage(messages.loading) }}</p>
|
|
</div>
|
|
<div
|
|
v-else-if="editorState === 'error'"
|
|
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center"
|
|
role="alert"
|
|
>
|
|
<h2 class="m-0 text-lg font-semibold text-contrast">
|
|
{{ formatMessage(messages.loadErrorTitle) }}
|
|
</h2>
|
|
<p class="m-0 max-w-md text-secondary">
|
|
{{ formatMessage(resourceError ? messages.resourceError : messages.loadErrorDescription) }}
|
|
</p>
|
|
<p v-if="errorDetail" class="m-0 max-w-xl break-words text-sm text-secondary">
|
|
{{ errorDetail }}
|
|
</p>
|
|
<ButtonStyled color="brand" @click="reloadEditor">
|
|
{{ formatMessage(messages.retry) }}
|
|
</ButtonStyled>
|
|
</div>
|
|
<iframe
|
|
v-if="editorUrl"
|
|
:key="`${frameKey}:${editorUrl}`"
|
|
ref="frame"
|
|
:title="formatMessage(messages.title)"
|
|
:src="editorUrl"
|
|
class="h-full min-h-0 w-full flex-1 border-0"
|
|
:class="{ 'pointer-events-none': editorState !== 'ready' }"
|
|
:aria-label="formatMessage(messages.title)"
|
|
:aria-hidden="editorState !== 'ready'"
|
|
@load="handleFrameLoad"
|
|
@error="markEditorError"
|
|
/>
|
|
</main>
|
|
</template>
|
|
|
|
<style>
|
|
.app-viewport:has(.skin-editor-page),
|
|
.app-viewport:has(.skin-editor-page) .page-transition-grid,
|
|
.app-viewport:has(.skin-editor-page) .page-transition-layer {
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|