Files
Starlight_Lancher/apps/app-frontend/src/pages/LabSkinEditor.vue
Disy a477b1fb9a
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
feat: integrate StarLight updates and improve font settings and skin editor loading
2026-09-19 18:14:41 +08:00

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>