feat:移除了弹窗,服务器添加sls
This commit is contained in:
210
packages/ui/src/composables/console-state.ts
Normal file
210
packages/ui/src/composables/console-state.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { type Ref, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
|
||||
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
|
||||
|
||||
const ARCHIVE_CAPACITY = 20_000
|
||||
const ARCHIVE_TEXT_CAPACITY = 4 * 1024 * 1024
|
||||
const MAX_LOG_LINE_LENGTH = 64 * 1024
|
||||
const BATCH_TIMEOUT_MS = 300
|
||||
const INITIAL_BATCH_SIZE = 256
|
||||
const ENTRY_START_RE = /^\[\d{2}:\d{2}:\d{2}\]/
|
||||
const LOG_TRUNCATION_MARKER = ' … [log output truncated by Axolotl] … '
|
||||
|
||||
export interface ConsoleState {
|
||||
output: Ref<LogLine[]>
|
||||
addLog4jEvent: (event: Log4jEvent) => void
|
||||
addLegacyLog: (message: string) => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function groupContinuations(lines: LogLine[]): LogLine[] {
|
||||
if (lines.length <= 1) return lines
|
||||
|
||||
const groups: LogLine[][] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (ENTRY_START_RE.test(line.text)) {
|
||||
groups.push([line])
|
||||
} else if (groups.length > 0) {
|
||||
let target = groups.length - 1
|
||||
const lastEntry = groups[target][0]
|
||||
|
||||
if (lastEntry.level !== 'error' && lastEntry.level !== 'warn') {
|
||||
if (line.level === 'error' || line.level === null) {
|
||||
for (let i = groups.length - 2; i >= 0; i--) {
|
||||
if (groups[i][0].level === 'error' || groups[i][0].level === 'warn') {
|
||||
target = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups[target].push(line)
|
||||
} else {
|
||||
groups.push([line])
|
||||
}
|
||||
}
|
||||
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
function mapLog4jLevel(level?: string): LogLevel | null {
|
||||
if (!level) return null
|
||||
switch (level.toUpperCase()) {
|
||||
case 'FATAL':
|
||||
case 'ERROR':
|
||||
return 'error'
|
||||
case 'WARN':
|
||||
return 'warn'
|
||||
case 'INFO':
|
||||
return 'info'
|
||||
case 'DEBUG':
|
||||
return 'debug'
|
||||
case 'TRACE':
|
||||
return 'trace'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(millis?: number): string {
|
||||
if (!millis) return ''
|
||||
const date = new Date(millis)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `[${hours}:${minutes}:${seconds}]`
|
||||
}
|
||||
|
||||
function truncateLogText(text: string, maximumLength = MAX_LOG_LINE_LENGTH): string {
|
||||
if (text.length <= maximumLength) return text
|
||||
|
||||
const retainedLength = Math.max(0, maximumLength - LOG_TRUNCATION_MARKER.length)
|
||||
const prefixLength = Math.floor(retainedLength / 2)
|
||||
const suffixLength = retainedLength - prefixLength
|
||||
return `${text.slice(0, prefixLength)}${LOG_TRUNCATION_MARKER}${text.slice(-suffixLength)}`
|
||||
}
|
||||
|
||||
function formatLog4jLines(event: Log4jEvent): LogLine[] {
|
||||
const level = mapLog4jLevel(event.level)
|
||||
const time = formatTimestamp(event.timestamp_millis)
|
||||
const thread = event.thread_name ?? ''
|
||||
const levelText = event.level ?? ''
|
||||
const message = truncateLogText(event.message?.trim() ?? '')
|
||||
const prefix = time ? `${time} [${thread}/${levelText}]: ` : `[${thread}/${levelText}]: `
|
||||
const messageLines = message.split(/[\r\n]+/)
|
||||
const lines: LogLine[] = [{ text: prefix + messageLines[0], level }]
|
||||
|
||||
for (let i = 1; i < messageLines.length; i++) {
|
||||
if (!messageLines[i]) continue
|
||||
lines.push({ text: messageLines[i], level })
|
||||
}
|
||||
|
||||
if (event.throwable) {
|
||||
for (const line of truncateLogText(event.throwable).split(/[\r\n]+/)) {
|
||||
if (!line) continue
|
||||
lines.push({ text: line, level: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function textToLogLine(text: string): LogLine {
|
||||
const truncated = truncateLogText(text)
|
||||
return { text: truncated, level: detectLogLevel(truncated) }
|
||||
}
|
||||
|
||||
export function createConsoleState(): ConsoleState {
|
||||
const output = shallowRef<LogLine[]>([])
|
||||
let lineBuffer: LogLine[] = []
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let outputTextLength = 0
|
||||
|
||||
function appendLines(lines: LogLine[]) {
|
||||
for (const line of lines) {
|
||||
const text = truncateLogText(line.text)
|
||||
output.value.push(text === line.text ? line : { ...line, text })
|
||||
outputTextLength += text.length
|
||||
}
|
||||
|
||||
while (output.value.length > ARCHIVE_CAPACITY || outputTextLength > ARCHIVE_TEXT_CAPACITY) {
|
||||
const removed = output.value.shift()
|
||||
if (!removed) break
|
||||
outputTextLength -= removed.text.length
|
||||
}
|
||||
}
|
||||
|
||||
function flushBuffer() {
|
||||
if (lineBuffer.length === 0) return
|
||||
|
||||
const lines = groupContinuations(lineBuffer)
|
||||
lineBuffer = []
|
||||
batchTimer = null
|
||||
appendLines(lines)
|
||||
|
||||
triggerRef(output)
|
||||
}
|
||||
|
||||
function addLines(lines: LogLine[]) {
|
||||
if (lines.length === 0) return
|
||||
|
||||
if (output.value.length === 0 && lines.length >= INITIAL_BATCH_SIZE) {
|
||||
lineBuffer = lines
|
||||
flushBuffer()
|
||||
return
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
lineBuffer.push(line)
|
||||
}
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBuffer, BATCH_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function addLog4jEvent(event: Log4jEvent) {
|
||||
addLines(formatLog4jLines(event))
|
||||
}
|
||||
|
||||
// 历史日志/缓冲大文本一次进入数据(保持行序),渲染由高亮管线分帧消化,
|
||||
// 避免分块写入把实时事件插到历史行之间造成顺序错乱
|
||||
function addLegacyLog(message: string): Promise<void> {
|
||||
const lines = message
|
||||
.split(/[\r\n]+/)
|
||||
.filter(Boolean)
|
||||
.map(textToLogLine)
|
||||
|
||||
let parentLevel: LogLevel | null = null
|
||||
for (const line of lines) {
|
||||
if (ENTRY_START_RE.test(line.text)) {
|
||||
parentLevel = line.level
|
||||
} else if (line.level === null && parentLevel !== null) {
|
||||
line.level = parentLevel
|
||||
}
|
||||
}
|
||||
|
||||
appendLines(lines)
|
||||
triggerRef(output)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
output.value = []
|
||||
outputTextLength = 0
|
||||
lineBuffer = []
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer)
|
||||
batchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output,
|
||||
addLog4jEvent,
|
||||
addLegacyLog,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
28
packages/ui/src/composables/debug-logger.ts
Normal file
28
packages/ui/src/composables/debug-logger.ts
Normal file
@ -0,0 +1,28 @@
|
||||
function getCallerLocation(): string {
|
||||
try {
|
||||
const stack = new Error().stack
|
||||
if (!stack) return ''
|
||||
|
||||
const lines = stack.split('\n')
|
||||
const callerLine = lines[3]
|
||||
if (!callerLine) return ''
|
||||
|
||||
const match = callerLine.match(/(https?:\/\/.+?|file:\/\/.+?|\/.*?):(\d+):\d+/)
|
||||
if (!match) return ''
|
||||
|
||||
const [, fullPath, line] = match
|
||||
const fileName = fullPath.split('/').pop()?.split('?')[0] || fullPath
|
||||
return `${fileName}:${line}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useDebugLogger(namespace: string) {
|
||||
// eslint-disable-next-line
|
||||
return (...args: any[]) => {
|
||||
const location = getCallerLocation()
|
||||
const prefix = location ? `[${namespace}] ${location}` : `[${namespace}]`
|
||||
console.debug(prefix, ...args)
|
||||
}
|
||||
}
|
||||
117
packages/ui/src/composables/dynamic-font-size.ts
Normal file
117
packages/ui/src/composables/dynamic-font-size.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
export interface DynamicFontSizeOptions {
|
||||
containerElement: Ref<HTMLElement | null>
|
||||
text: Ref<string | undefined>
|
||||
baseFontSize?: number
|
||||
minFontSize?: number
|
||||
maxFontSize?: number
|
||||
availableWidthRatio?: number
|
||||
maxContainerWidth?: number
|
||||
padding?: number
|
||||
fontFamily?: string
|
||||
fontWeight?: string | number
|
||||
}
|
||||
|
||||
export function useDynamicFontSize(options: DynamicFontSizeOptions) {
|
||||
const {
|
||||
containerElement,
|
||||
text,
|
||||
baseFontSize = 1.25,
|
||||
minFontSize = 0.75,
|
||||
maxFontSize = 2,
|
||||
availableWidthRatio = 0.9,
|
||||
maxContainerWidth = 400,
|
||||
padding = 24,
|
||||
fontFamily = 'inherit',
|
||||
fontWeight = 'inherit',
|
||||
} = options
|
||||
|
||||
const { width: containerWidth } = useElementSize(containerElement)
|
||||
let measurementElement: HTMLElement | null = null
|
||||
|
||||
const createMeasurementElement = () => {
|
||||
if (measurementElement) return measurementElement
|
||||
|
||||
measurementElement = document.createElement('div')
|
||||
measurementElement.style.cssText = `
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
font-family: ${fontFamily};
|
||||
font-weight: ${fontWeight};
|
||||
`
|
||||
measurementElement.setAttribute('aria-hidden', 'true')
|
||||
document.body.appendChild(measurementElement)
|
||||
|
||||
return measurementElement
|
||||
}
|
||||
|
||||
const cleanupMeasurementElement = () => {
|
||||
if (measurementElement?.parentNode) {
|
||||
measurementElement.parentNode.removeChild(measurementElement)
|
||||
measurementElement = null
|
||||
}
|
||||
}
|
||||
|
||||
const measureTextWidth = (textContent: string, fontSize: number): number => {
|
||||
if (!textContent) return 0
|
||||
|
||||
const element = createMeasurementElement()
|
||||
element.style.fontSize = `${fontSize}rem`
|
||||
element.textContent = textContent
|
||||
|
||||
return element.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
const findOptimalFontSize = (textContent: string, availableWidth: number): number => {
|
||||
let low = minFontSize
|
||||
let high = maxFontSize
|
||||
let bestSize = minFontSize
|
||||
|
||||
const maxWidth = measureTextWidth(textContent, maxFontSize)
|
||||
if (maxWidth <= availableWidth) return maxFontSize
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const mid = (low + high) / 2
|
||||
const width = measureTextWidth(textContent, mid)
|
||||
|
||||
if (width <= availableWidth) {
|
||||
bestSize = mid
|
||||
low = mid
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
|
||||
if (high - low < 0.01) break
|
||||
}
|
||||
|
||||
return Math.max(bestSize, minFontSize)
|
||||
}
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (!text.value || !containerWidth.value) return `${baseFontSize}rem`
|
||||
|
||||
const availableWidth =
|
||||
Math.min(containerWidth.value * availableWidthRatio, maxContainerWidth) - padding
|
||||
|
||||
const baseWidth = measureTextWidth(text.value, baseFontSize)
|
||||
if (baseWidth <= availableWidth) return `${baseFontSize}rem`
|
||||
|
||||
const optimalSize = findOptimalFontSize(text.value, availableWidth)
|
||||
return `${optimalSize}rem`
|
||||
})
|
||||
|
||||
onMounted(createMeasurementElement)
|
||||
onUnmounted(cleanupMeasurementElement)
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
containerWidth,
|
||||
cleanup: cleanupMeasurementElement,
|
||||
}
|
||||
}
|
||||
39
packages/ui/src/composables/format-bytes.ts
Normal file
39
packages/ui/src/composables/format-bytes.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { defineMessage, useVIntl } from '#ui/composables/i18n.ts'
|
||||
|
||||
const messages = [
|
||||
defineMessage({
|
||||
id: 'format.bytes.0',
|
||||
defaultMessage: '{count, plural, one {# byte} other {# bytes}}',
|
||||
}),
|
||||
defineMessage({
|
||||
id: 'format.bytes.1',
|
||||
defaultMessage: '{count, number} KiB',
|
||||
}),
|
||||
defineMessage({
|
||||
id: 'format.bytes.2',
|
||||
defaultMessage: '{count, number} MiB',
|
||||
}),
|
||||
defineMessage({
|
||||
id: 'format.bytes.3',
|
||||
defaultMessage: '{count, number} GiB',
|
||||
}),
|
||||
defineMessage({
|
||||
id: 'format.bytes.4',
|
||||
defaultMessage: '{count, number} TiB',
|
||||
}),
|
||||
]
|
||||
|
||||
export function useFormatBytes() {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function format(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return formatMessage(messages[0], { count: 0 })
|
||||
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), messages.length - 1)
|
||||
return formatMessage(messages[exponent], {
|
||||
count: (bytes / Math.pow(1024, exponent)).toFixed(decimals),
|
||||
})
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
38
packages/ui/src/composables/format-date-time.ts
Normal file
38
packages/ui/src/composables/format-date-time.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.DateTimeFormat>({ max: 40 })
|
||||
|
||||
export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(date?: Date | number | string): string {
|
||||
if (typeof date === 'number' || typeof date === 'string') {
|
||||
date = new Date(date)
|
||||
}
|
||||
|
||||
const formatter = getFormatter(locale.value, options)
|
||||
return formatter!.format(date)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
function getFormatter(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
let cacheKey = locale
|
||||
if (options) {
|
||||
const entries = Object.entries(options)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.sort()
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
cacheKey = [locale, ...entries].join(':')
|
||||
}
|
||||
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale, options)
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
78
packages/ui/src/composables/format-money.ts
Normal file
78
packages/ui/src/composables/format-money.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 10 })
|
||||
const maxDigitsCache = new LRUCache<string, number>({ max: 10 })
|
||||
|
||||
// `formatMoney(1234.56, 'USD')` → `$1,234.56`
|
||||
export function useFormatMoney() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(number: number, currency = 'USD'): string {
|
||||
try {
|
||||
const formatter = getFormatter(locale.value, currency)
|
||||
return formatter!.format(number)
|
||||
} catch {
|
||||
return `${currency} ${number.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
// `formatPrice(123456, 'USD')` → `$1,234.56`
|
||||
export function useFormatPrice() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(price: number, currency: string, trimZeros = false): string {
|
||||
const maxDigits = getMaxDigits(currency)
|
||||
const convertedPrice = price / Math.pow(10, maxDigits)
|
||||
|
||||
const minimumFractionDigits = trimZeros && Number.isInteger(convertedPrice) ? 0 : undefined
|
||||
|
||||
try {
|
||||
const formatter = getFormatter(locale.value, currency, minimumFractionDigits)
|
||||
return formatter.format(convertedPrice)
|
||||
} catch {
|
||||
return `${currency} ${convertedPrice}`
|
||||
}
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
function getFormatter(
|
||||
locale: string,
|
||||
currency: string,
|
||||
minimumFractionDigits?: number,
|
||||
): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:${currency}:${minimumFractionDigits}`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits,
|
||||
})
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
function getMaxDigits(currency: string): number {
|
||||
let maxDigits = maxDigitsCache.get(currency)
|
||||
if (!maxDigits) {
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
})
|
||||
maxDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2
|
||||
} catch {
|
||||
maxDigits = 2
|
||||
}
|
||||
maxDigitsCache.set(currency, maxDigits)
|
||||
}
|
||||
return maxDigits
|
||||
}
|
||||
72
packages/ui/src/composables/format-number.ts
Normal file
72
packages/ui/src/composables/format-number.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { LOCALES } from './i18n.ts'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
|
||||
|
||||
// `formatNumber(1234567)` → `1,234,567`
|
||||
export function useFormatNumber() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(value: number | bigint): string {
|
||||
const formatter = getStandardFormatter(locale.value)
|
||||
return formatter!.format(value)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
// `formatCompactNumber(1234567)` → `1.23M`
|
||||
//
|
||||
// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
|
||||
export function useCompactNumber() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function formatCompactNumber(value: number | bigint): string {
|
||||
if (value < 10_000) {
|
||||
const standardFormatter = getStandardFormatter(locale.value)
|
||||
return standardFormatter.format(value)
|
||||
}
|
||||
if (value < 1_000_000) {
|
||||
const oneDigitCompactFormatter = getCompactFormatter('en', 1)
|
||||
return oneDigitCompactFormatter.format(value)
|
||||
}
|
||||
const twoDigitsCompactFormatter = getCompactFormatter('en', 2)
|
||||
return twoDigitsCompactFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatCompactNumberPlural(value: number | bigint): number | bigint {
|
||||
if (value < 10_000) {
|
||||
return value
|
||||
}
|
||||
const currentLocale = locale.value
|
||||
const localeDefinition = LOCALES.find((l) => l.code === currentLocale)
|
||||
return localeDefinition?.compactNumberPlural ?? NaN
|
||||
}
|
||||
|
||||
return { formatCompactNumber, formatCompactNumberPlural }
|
||||
}
|
||||
|
||||
function getStandardFormatter(locale: string): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:standard`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale)
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
function getCompactFormatter(locale: string, maximumFractionDigits: number): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:compact:${maximumFractionDigits}`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits,
|
||||
})
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
70
packages/ui/src/composables/how-ago.ts
Normal file
70
packages/ui/src/composables/how-ago.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { LOCALES } from './i18n.ts'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 15 })
|
||||
|
||||
export function useRelativeTime(options?: Intl.RelativeTimeFormatOptions) {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
return (value: Date | number | string | null | undefined) => {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const now = Date.now()
|
||||
const diff = date.getTime() - now
|
||||
|
||||
const seconds = Math.round(diff / 1000)
|
||||
const minutes = Math.round(diff / 60000)
|
||||
const hours = Math.round(diff / 3600000)
|
||||
const days = Math.round(diff / 86400000)
|
||||
const weeks = Math.round(diff / 604800000)
|
||||
const months = Math.round(diff / 2629746000)
|
||||
const years = Math.round(diff / 31556952000)
|
||||
|
||||
const rtf = getFormatter(locale.value, options)
|
||||
|
||||
if (Math.abs(seconds) < 60) {
|
||||
return rtf.format(seconds, 'second')
|
||||
} else if (Math.abs(minutes) < 60) {
|
||||
return rtf.format(minutes, 'minute')
|
||||
} else if (Math.abs(hours) < 24) {
|
||||
return rtf.format(hours, 'hour')
|
||||
} else if (Math.abs(days) < 7) {
|
||||
return rtf.format(days, 'day')
|
||||
} else if (Math.abs(weeks) < 4) {
|
||||
return rtf.format(weeks, 'week')
|
||||
} else if (Math.abs(months) < 12) {
|
||||
return rtf.format(months, 'month')
|
||||
} else {
|
||||
return rtf.format(years, 'year')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatter(
|
||||
locale: string,
|
||||
options?: Intl.RelativeTimeFormatOptions,
|
||||
): Intl.RelativeTimeFormat {
|
||||
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
|
||||
const numeric = options?.numeric ?? localeDefinition?.numeric ?? 'auto'
|
||||
const style = options?.style ?? 'long'
|
||||
const cacheKey = `${locale}:${numeric}:${style}`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.RelativeTimeFormat(locale, {
|
||||
...options,
|
||||
numeric,
|
||||
style,
|
||||
})
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
223
packages/ui/src/composables/i18n-debug.ts
Normal file
223
packages/ui/src/composables/i18n-debug.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject, provide, watch } from 'vue'
|
||||
|
||||
export interface RegistryEntry {
|
||||
key: string
|
||||
value: string
|
||||
defaultMessage?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface I18nDebugContext {
|
||||
enabled: Ref<boolean>
|
||||
keyReveal: Ref<boolean>
|
||||
registry: Map<string, RegistryEntry>
|
||||
panelOpen: Ref<boolean>
|
||||
}
|
||||
|
||||
export const I18N_DEBUG_KEY: InjectionKey<I18nDebugContext> = Symbol('i18n-debug')
|
||||
|
||||
export function provideI18nDebug(context: I18nDebugContext): void {
|
||||
provide(I18N_DEBUG_KEY, context)
|
||||
}
|
||||
|
||||
export function injectI18nDebug(): I18nDebugContext | null {
|
||||
return inject(I18N_DEBUG_KEY, null)
|
||||
}
|
||||
|
||||
export function buildCrowdinUrl(key: string, locale: string): string {
|
||||
return `https://crowdin.com/translate/modrinth-platform/all/en-${locale}?filter=basic&value=0&search_type=identifier&search=${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
export function initI18nDebugRuntime(context: I18nDebugContext): void {
|
||||
import('@modrinth/assets/styles/i18n-debug.css')
|
||||
document.body.classList.add('i18n-debug')
|
||||
startMutationObserver(context.registry, context.keyReveal)
|
||||
setupKeyTooltip()
|
||||
registerKeyboardShortcuts(context.panelOpen, context.keyReveal)
|
||||
}
|
||||
|
||||
function startMutationObserver(registry: Map<string, RegistryEntry>, keyReveal: Ref<boolean>) {
|
||||
let pending = false
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (pending || keyReveal.value) return
|
||||
pending = true
|
||||
requestAnimationFrame(() => {
|
||||
pending = false
|
||||
if (!keyReveal.value) {
|
||||
processMutations(mutations, registry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
|
||||
// Re-annotate whenever the registry grows (keys register after render)
|
||||
let annotateTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastSize = 0
|
||||
watch(
|
||||
() => registry.size,
|
||||
(size) => {
|
||||
if (size <= lastSize || keyReveal.value) return
|
||||
lastSize = size
|
||||
clearTimeout(annotateTimer)
|
||||
annotateTimer = setTimeout(() => annotateFullDocument(registry), 200)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
function processMutations(mutations: MutationRecord[], registry: Map<string, RegistryEntry>) {
|
||||
const reverseLookup = new Map<string, string>()
|
||||
for (const [, entry] of registry) {
|
||||
if (entry.value) {
|
||||
reverseLookup.set(entry.value, entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
if (reverseLookup.size === 0) return
|
||||
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
annotateTextNodes(node as Element, reverseLookup)
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
annotateTextNode(node as Text, reverseLookup)
|
||||
}
|
||||
}
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
clearStaleAttributes(node as Element)
|
||||
}
|
||||
}
|
||||
} else if (mutation.type === 'characterData') {
|
||||
if (mutation.target.nodeType === Node.TEXT_NODE) {
|
||||
annotateTextNode(mutation.target as Text, reverseLookup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function annotateTextNodes(element: Element, reverseLookup: Map<string, string>) {
|
||||
if (element.closest('.i18n-debug-panel')) return
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
let node: Text | null
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
annotateTextNode(node, reverseLookup)
|
||||
}
|
||||
}
|
||||
|
||||
function annotateTextNode(node: Text, reverseLookup: Map<string, string>) {
|
||||
const parent = node.parentElement
|
||||
if (!parent || parent.closest('.i18n-debug-panel')) return
|
||||
|
||||
const text = node.textContent?.trim()
|
||||
if (!text) return
|
||||
|
||||
const key = reverseLookup.get(text)
|
||||
if (key) {
|
||||
parent.setAttribute('data-i18n-key', key)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllAnnotations() {
|
||||
document.querySelectorAll('[data-i18n-key]').forEach((el) => {
|
||||
el.removeAttribute('data-i18n-key')
|
||||
})
|
||||
}
|
||||
|
||||
export function hideKeyTooltip() {
|
||||
const tooltip = document.querySelector('.i18n-key-tooltip') as HTMLElement | null
|
||||
if (tooltip) tooltip.style.display = 'none'
|
||||
}
|
||||
|
||||
function clearStaleAttributes(element: Element) {
|
||||
if (element.hasAttribute?.('data-i18n-key')) {
|
||||
element.removeAttribute('data-i18n-key')
|
||||
}
|
||||
const children = element.querySelectorAll?.('[data-i18n-key]')
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
child.removeAttribute('data-i18n-key')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function annotateFullDocument(registry: Map<string, RegistryEntry>) {
|
||||
const reverseLookup = new Map<string, string>()
|
||||
for (const [, entry] of registry) {
|
||||
if (entry.value) {
|
||||
reverseLookup.set(entry.value, entry.key)
|
||||
}
|
||||
}
|
||||
if (reverseLookup.size === 0) return
|
||||
annotateTextNodes(document.body, reverseLookup)
|
||||
}
|
||||
|
||||
function setupKeyTooltip() {
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.className = 'i18n-key-tooltip'
|
||||
tooltip.style.display = 'none'
|
||||
document.body.appendChild(tooltip)
|
||||
|
||||
let activeTarget: Element | null = null
|
||||
|
||||
function positionTooltip() {
|
||||
if (!activeTarget) return
|
||||
const rect = activeTarget.getBoundingClientRect()
|
||||
const tooltipRect = tooltip.getBoundingClientRect()
|
||||
let top = rect.top - tooltipRect.height - 6
|
||||
if (top < 4) top = rect.bottom + 6
|
||||
let left = rect.left
|
||||
if (left + tooltipRect.width > window.innerWidth - 4) {
|
||||
left = window.innerWidth - tooltipRect.width - 4
|
||||
}
|
||||
tooltip.style.top = `${top}px`
|
||||
tooltip.style.left = `${left}px`
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', (e) => {
|
||||
const target = (e.target as Element).closest?.('[data-i18n-key]')
|
||||
if (!target) return
|
||||
const key = target.getAttribute('data-i18n-key')
|
||||
if (!key) return
|
||||
activeTarget = target
|
||||
tooltip.textContent = key
|
||||
tooltip.style.display = ''
|
||||
positionTooltip()
|
||||
})
|
||||
|
||||
document.body.addEventListener('mouseout', (e) => {
|
||||
const target = (e.target as Element).closest?.('[data-i18n-key]')
|
||||
if (!target) return
|
||||
const related = (e as MouseEvent).relatedTarget as Element | null
|
||||
if (related?.closest?.('[data-i18n-key]') === target) return
|
||||
activeTarget = null
|
||||
tooltip.style.display = 'none'
|
||||
})
|
||||
|
||||
document.addEventListener('scroll', () => positionTooltip(), { capture: true, passive: true })
|
||||
}
|
||||
|
||||
function registerKeyboardShortcuts(panelOpen: Ref<boolean>, keyReveal: Ref<boolean>) {
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Use Cmd on macOS, Ctrl on other platforms
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod || !e.shiftKey) return
|
||||
|
||||
if (e.code === 'Period') {
|
||||
e.preventDefault()
|
||||
panelOpen.value = !panelOpen.value
|
||||
} else if (e.code === 'KeyK') {
|
||||
e.preventDefault()
|
||||
keyReveal.value = !keyReveal.value
|
||||
}
|
||||
})
|
||||
}
|
||||
344
packages/ui/src/composables/i18n.ts
Normal file
344
packages/ui/src/composables/i18n.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import IntlMessageFormat from 'intl-messageformat'
|
||||
import type { Ref } from 'vue'
|
||||
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { injectI18nDebug } from './i18n-debug'
|
||||
|
||||
export interface MessageDescriptor {
|
||||
id: string
|
||||
defaultMessage?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor>
|
||||
|
||||
export type CrowdinMessages = Record<string, { message?: string; defaultMessage?: string } | string>
|
||||
|
||||
export function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
export function defineMessages<K extends string, T extends MessageDescriptorMap<K>>(
|
||||
descriptors: T,
|
||||
): T {
|
||||
return descriptors
|
||||
}
|
||||
|
||||
export interface LocaleDefinition {
|
||||
code: string
|
||||
name: string
|
||||
translatedName: MessageDescriptor
|
||||
numeric?: Intl.RelativeTimeFormatNumeric
|
||||
compactNumberPlural?: number
|
||||
dir?: 'ltr' | 'rtl'
|
||||
serverLanguageCode?: string
|
||||
}
|
||||
|
||||
export const LOCALES: LocaleDefinition[] = [
|
||||
// Commented out as it's RTL - will enable when we have better RTL support
|
||||
// {
|
||||
// code: 'ar-SA',
|
||||
// name: 'العربية (السعودية)',
|
||||
// translatedName: defineMessage({ id: 'locale.ar-SA', defaultMessage: 'Arabic' }),
|
||||
// dir: 'rtl',
|
||||
// },
|
||||
{
|
||||
code: 'cs-CZ',
|
||||
name: 'Čeština',
|
||||
translatedName: defineMessage({ id: 'locale.cs-CZ', defaultMessage: 'Czech' }),
|
||||
},
|
||||
{
|
||||
code: 'da-DK',
|
||||
name: 'Dansk',
|
||||
translatedName: defineMessage({ id: 'locale.da-DK', defaultMessage: 'Danish' }),
|
||||
},
|
||||
{
|
||||
code: 'de-CH',
|
||||
name: 'Deutsch (Schweiz)',
|
||||
translatedName: defineMessage({ id: 'locale.de-CH', defaultMessage: 'German (Switzerland)' }),
|
||||
},
|
||||
{
|
||||
code: 'de-DE',
|
||||
name: 'Deutsch (Deutschland)',
|
||||
translatedName: defineMessage({ id: 'locale.de-DE', defaultMessage: 'German (Germany)' }),
|
||||
},
|
||||
{
|
||||
code: 'en-US',
|
||||
name: 'English (United States)',
|
||||
translatedName: defineMessage({
|
||||
id: 'locale.en-US',
|
||||
defaultMessage: 'English (United States)',
|
||||
}),
|
||||
},
|
||||
{
|
||||
code: 'es-419',
|
||||
name: 'Español (Latinoamérica)',
|
||||
translatedName: defineMessage({
|
||||
id: 'locale.es-419',
|
||||
defaultMessage: 'Spanish (Latin America)',
|
||||
}),
|
||||
},
|
||||
{
|
||||
code: 'es-ES',
|
||||
name: 'Español (España)',
|
||||
translatedName: defineMessage({ id: 'locale.es-ES', defaultMessage: 'Spanish (Spain)' }),
|
||||
},
|
||||
{
|
||||
code: 'fi-FI',
|
||||
name: 'Suomi',
|
||||
translatedName: defineMessage({ id: 'locale.fi-FI', defaultMessage: 'Finnish' }),
|
||||
},
|
||||
{
|
||||
code: 'fil-PH',
|
||||
name: 'Filipino',
|
||||
translatedName: defineMessage({ id: 'locale.fil-PH', defaultMessage: 'Filipino' }),
|
||||
compactNumberPlural: 1,
|
||||
serverLanguageCode: 'tl',
|
||||
},
|
||||
{
|
||||
code: 'fr-FR',
|
||||
name: 'Français',
|
||||
translatedName: defineMessage({ id: 'locale.fr-FR', defaultMessage: 'French' }),
|
||||
},
|
||||
{
|
||||
code: 'he-IL',
|
||||
name: 'עברית',
|
||||
translatedName: defineMessage({ id: 'locale.he-IL', defaultMessage: 'Hebrew' }),
|
||||
dir: 'rtl',
|
||||
},
|
||||
{
|
||||
code: 'hu-HU',
|
||||
name: 'Magyar',
|
||||
translatedName: defineMessage({ id: 'locale.hu-HU', defaultMessage: 'Hungarian' }),
|
||||
},
|
||||
{
|
||||
code: 'id-ID',
|
||||
name: 'Bahasa Indonesia',
|
||||
translatedName: defineMessage({ id: 'locale.id-ID', defaultMessage: 'Indonesian' }),
|
||||
},
|
||||
{
|
||||
code: 'it-IT',
|
||||
name: 'Italiano (Italia)',
|
||||
translatedName: defineMessage({ id: 'locale.it-IT', defaultMessage: 'Italian (Italy)' }),
|
||||
numeric: 'always',
|
||||
},
|
||||
{
|
||||
code: 'ja-JP',
|
||||
name: '日本語',
|
||||
translatedName: defineMessage({ id: 'locale.ja-JP', defaultMessage: 'Japanese' }),
|
||||
},
|
||||
{
|
||||
code: 'ko-KR',
|
||||
name: '한국어',
|
||||
translatedName: defineMessage({ id: 'locale.ko-KR', defaultMessage: 'Korean' }),
|
||||
},
|
||||
{
|
||||
code: 'ms-MY',
|
||||
name: 'Bahasa Melayu',
|
||||
translatedName: defineMessage({ id: 'locale.ms-MY', defaultMessage: 'Malay' }),
|
||||
},
|
||||
{
|
||||
code: 'nl-NL',
|
||||
name: 'Nederlands',
|
||||
translatedName: defineMessage({ id: 'locale.nl-NL', defaultMessage: 'Dutch' }),
|
||||
},
|
||||
{
|
||||
code: 'no-NO',
|
||||
name: 'Norsk (Bokmål)',
|
||||
translatedName: defineMessage({ id: 'locale.no-NO', defaultMessage: 'Norwegian Bokmål' }),
|
||||
},
|
||||
{
|
||||
code: 'pl-PL',
|
||||
name: 'Polski',
|
||||
translatedName: defineMessage({ id: 'locale.pl-PL', defaultMessage: 'Polish' }),
|
||||
},
|
||||
{
|
||||
code: 'pt-BR',
|
||||
name: 'Português (Brasil)',
|
||||
translatedName: defineMessage({ id: 'locale.pt-BR', defaultMessage: 'Portuguese (Brazil)' }),
|
||||
},
|
||||
{
|
||||
code: 'pt-PT',
|
||||
name: 'Português (Portugal)',
|
||||
translatedName: defineMessage({ id: 'locale.pt-PT', defaultMessage: 'Portuguese (Portugal)' }),
|
||||
},
|
||||
{
|
||||
code: 'ro-RO',
|
||||
name: 'Română',
|
||||
translatedName: defineMessage({ id: 'locale.ro-RO', defaultMessage: 'Romanian' }),
|
||||
},
|
||||
{
|
||||
code: 'ru-RU',
|
||||
name: 'Русский',
|
||||
translatedName: defineMessage({ id: 'locale.ru-RU', defaultMessage: 'Russian' }),
|
||||
numeric: 'always',
|
||||
},
|
||||
{
|
||||
code: 'sr-CS',
|
||||
name: 'Srpski (latinica)',
|
||||
translatedName: defineMessage({ id: 'locale.sr-CS', defaultMessage: 'Serbian (Latin)' }),
|
||||
},
|
||||
{
|
||||
code: 'sv-SE',
|
||||
name: 'Svenska',
|
||||
translatedName: defineMessage({ id: 'locale.sv-SE', defaultMessage: 'Swedish' }),
|
||||
},
|
||||
{
|
||||
code: 'th-TH',
|
||||
name: 'ไทย',
|
||||
translatedName: defineMessage({ id: 'locale.th-TH', defaultMessage: 'Thai' }),
|
||||
},
|
||||
{
|
||||
code: 'tr-TR',
|
||||
name: 'Türkçe',
|
||||
translatedName: defineMessage({ id: 'locale.tr-TR', defaultMessage: 'Turkish' }),
|
||||
},
|
||||
{
|
||||
code: 'uk-UA',
|
||||
name: 'Українська',
|
||||
translatedName: defineMessage({ id: 'locale.uk-UA', defaultMessage: 'Ukrainian' }),
|
||||
},
|
||||
{
|
||||
code: 'vi-VN',
|
||||
name: 'Tiếng Việt',
|
||||
translatedName: defineMessage({ id: 'locale.vi-VN', defaultMessage: 'Vietnamese' }),
|
||||
},
|
||||
{
|
||||
code: 'zh-CN',
|
||||
name: '简体中文',
|
||||
translatedName: defineMessage({ id: 'locale.zh-CN', defaultMessage: 'Chinese (Simplified)' }),
|
||||
},
|
||||
{
|
||||
code: 'zh-TW',
|
||||
name: '繁體中文',
|
||||
translatedName: defineMessage({ id: 'locale.zh-TW', defaultMessage: 'Chinese (Traditional)' }),
|
||||
},
|
||||
]
|
||||
|
||||
export function transformCrowdinMessages(messages: CrowdinMessages): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
const msg = value.message ?? value.defaultMessage
|
||||
if (msg) {
|
||||
result[key] = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const LOCALE_CODES = new Set(LOCALES.map((l) => l.code))
|
||||
|
||||
/**
|
||||
* Builds locale messages from glob-imported modules.
|
||||
* Only includes locales that are defined in the LOCALES array.
|
||||
* Usage: buildLocaleMessages(import.meta.glob('./locales/* /index.json', { eager: true }))
|
||||
*/
|
||||
export function buildLocaleMessages(
|
||||
...allModules: Record<string, { default: CrowdinMessages }>[]
|
||||
): Record<string, Record<string, string>> {
|
||||
const messages: Record<string, Record<string, string>> = {}
|
||||
for (const modules of allModules) {
|
||||
for (const [path, module] of Object.entries(modules)) {
|
||||
// Extract locale code from path like './locales/en-US/index.json', './src/locales/en-US/index.json' or './locales/en-US/meta.json'
|
||||
const match = path.match(/\/([^/]+)\/(index|meta)\.json$/)
|
||||
if (match) {
|
||||
const locale = match[1]
|
||||
// Only include locales that are in our LOCALES list
|
||||
if (LOCALE_CODES.has(locale)) {
|
||||
const mergedMessages = messages[locale] ?? {}
|
||||
messages[locale] = Object.assign(mergedMessages, transformCrowdinMessages(module.default))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a vue-i18n message compiler that uses IntlMessageFormat for ICU syntax support.
|
||||
* This enables pluralization, select, and other ICU message features.
|
||||
*/
|
||||
export function createMessageCompiler(): MessageCompiler {
|
||||
return (msg, { locale, key, onError }) => {
|
||||
let messageString: string
|
||||
|
||||
if (typeof msg === 'string') {
|
||||
messageString = msg
|
||||
} else if (typeof msg === 'object' && msg !== null && 'message' in msg) {
|
||||
messageString = (msg as { message: string }).message
|
||||
} else {
|
||||
onError?.(new Error('Invalid message format') as CompileError)
|
||||
return () => key
|
||||
}
|
||||
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(messageString, locale)
|
||||
return (ctx: MessageContext) => {
|
||||
try {
|
||||
return formatter.format(ctx.values as Record<string, unknown>) as string
|
||||
} catch {
|
||||
return messageString
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e as CompileError)
|
||||
return () => key
|
||||
}
|
||||
}
|
||||
}
|
||||
export interface VIntlFormatters {
|
||||
formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that provides formatMessage() with the same API as @vintl/vintl.
|
||||
* Uses the injected I18nContext from the provider.
|
||||
*/
|
||||
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
|
||||
const { t, locale } = injectI18n()
|
||||
const debugContext = injectI18nDebug()
|
||||
|
||||
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string {
|
||||
// Read locale.value to ensure Vue tracks this as a reactive dependency
|
||||
// when formatMessage is called during component render
|
||||
void locale.value
|
||||
|
||||
const key = descriptor.id
|
||||
const translation = t(key, values ?? {})
|
||||
|
||||
let result: string
|
||||
if (translation && translation !== key) {
|
||||
result = translation as string
|
||||
} else {
|
||||
// Fallback to defaultMessage if key not found
|
||||
const defaultMsg = descriptor.defaultMessage ?? key
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
|
||||
result = formatter.format(values ?? {}) as string
|
||||
} catch {
|
||||
result = defaultMsg
|
||||
}
|
||||
}
|
||||
|
||||
if (debugContext?.enabled.value) {
|
||||
debugContext.registry.set(key, {
|
||||
key,
|
||||
value: result,
|
||||
defaultMessage: descriptor.defaultMessage,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (debugContext.keyReveal.value) {
|
||||
return `\u300C${key}\u300D`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return { formatMessage, locale }
|
||||
}
|
||||
22
packages/ui/src/composables/index.ts
Normal file
22
packages/ui/src/composables/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export * from './console-state'
|
||||
export * from './debug-logger'
|
||||
export * from './dynamic-font-size'
|
||||
export * from './format-bytes'
|
||||
export * from './format-date-time'
|
||||
export * from './format-money'
|
||||
export * from './format-number'
|
||||
export * from './how-ago'
|
||||
export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
export * from './page-leave-safety'
|
||||
export * from './scroll-indicator'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './use-batch-drop'
|
||||
export * from './use-global-drop'
|
||||
export * from './use-instance-context'
|
||||
export * from './use-loading-bar-token'
|
||||
export * from './use-loading-state-core'
|
||||
export * from './use-ready-state'
|
||||
export * from './use-server-image'
|
||||
export * from './virtual-scroll'
|
||||
39
packages/ui/src/composables/modal-stack.ts
Normal file
39
packages/ui/src/composables/modal-stack.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const stack: symbol[] = []
|
||||
const stackSizeRef = ref(0)
|
||||
|
||||
export function useModalStack() {
|
||||
const id = Symbol()
|
||||
|
||||
function push() {
|
||||
if (isClient && !stack.includes(id)) {
|
||||
stack.push(id)
|
||||
stackSizeRef.value = stack.length
|
||||
}
|
||||
}
|
||||
|
||||
function pop() {
|
||||
if (!isClient) return
|
||||
const idx = stack.indexOf(id)
|
||||
if (idx !== -1) {
|
||||
stack.splice(idx, 1)
|
||||
stackSizeRef.value = stack.length
|
||||
}
|
||||
}
|
||||
|
||||
function isTopmost() {
|
||||
if (!isClient) return true
|
||||
return stack.length === 0 || stack[stack.length - 1] === id
|
||||
}
|
||||
|
||||
function stackSize() {
|
||||
return isClient ? stack.length : 0
|
||||
}
|
||||
|
||||
const hasModal = computed(() => stackSizeRef.value > 0)
|
||||
const stackCount: Readonly<Ref<number>> = stackSizeRef
|
||||
|
||||
return { push, pop, isTopmost, stackSize, hasModal, stackCount }
|
||||
}
|
||||
38
packages/ui/src/composables/page-leave-safety.ts
Normal file
38
packages/ui/src/composables/page-leave-safety.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import type ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
|
||||
export function usePageLeaveSafety(dirty: Ref<boolean> | ComputedRef<boolean>) {
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (dirty.value) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(dirty, (isDirty) => {
|
||||
if (isDirty) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (dirty.value) {
|
||||
return (await confirmLeaveModal.value?.prompt()) ?? false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return { confirmLeaveModal }
|
||||
}
|
||||
181
packages/ui/src/composables/scroll-indicator.ts
Normal file
181
packages/ui/src/composables/scroll-indicator.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useDebugLogger } from './debug-logger'
|
||||
|
||||
export interface ScrollIndicatorOptions {
|
||||
watchContent?: boolean
|
||||
debounceMs?: number
|
||||
tolerance?: number
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface ScrollIndicator {
|
||||
showTopFade: Ref<boolean>
|
||||
showBottomFade: Ref<boolean>
|
||||
checkScrollState: () => void
|
||||
forceCheck: () => void
|
||||
}
|
||||
|
||||
export function useScrollIndicator(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
options: ScrollIndicatorOptions = {},
|
||||
): ScrollIndicator {
|
||||
const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
|
||||
|
||||
const showTopFade = ref(false)
|
||||
const showBottomFade = ref(false)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let rafId: number | null = null
|
||||
let debounceTimer: number | null = null
|
||||
|
||||
const log = useDebugLogger('ScrollIndicator')
|
||||
|
||||
const checkScrollStateInternal = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) {
|
||||
showTopFade.value = false
|
||||
showBottomFade.value = false
|
||||
if (debug) log('Container not found, hiding fades')
|
||||
return
|
||||
}
|
||||
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const isScrollable = scrollHeight > clientHeight + tolerance
|
||||
|
||||
if (debug) {
|
||||
log('Checking scroll state', {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
isScrollable,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isScrollable) {
|
||||
showTopFade.value = false
|
||||
showBottomFade.value = false
|
||||
if (debug) log('Content fits, no fades needed')
|
||||
} else {
|
||||
showTopFade.value = scrollTop > tolerance
|
||||
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
|
||||
|
||||
if (debug) {
|
||||
log('Fades updated', {
|
||||
showTop: showTopFade.value,
|
||||
showBottom: showBottomFade.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const checkScrollState = () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
checkScrollStateInternal()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
const forceCheck = () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
checkScrollStateInternal()
|
||||
}
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const container = containerRef.value
|
||||
if (!container) {
|
||||
if (debug) log('No container, skipping setup')
|
||||
return
|
||||
}
|
||||
|
||||
if (debug) log('Setting up observers for container', container)
|
||||
|
||||
nextTick(() => {
|
||||
forceCheck()
|
||||
})
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (debug) log('ResizeObserver triggered')
|
||||
checkScrollState()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
if (watchContent) {
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
if (debug) log('MutationObserver triggered')
|
||||
checkScrollState()
|
||||
})
|
||||
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: false,
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (debug) log('Scroll event triggered')
|
||||
checkScrollState()
|
||||
}
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
const handleResize = () => {
|
||||
if (debug) log('Window resize triggered')
|
||||
checkScrollState()
|
||||
}
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
onCleanup(() => {
|
||||
if (debug) log('Cleaning up observers and listeners')
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
mutationObserver?.disconnect()
|
||||
mutationObserver = null
|
||||
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showTopFade,
|
||||
showBottomFade,
|
||||
checkScrollState,
|
||||
forceCheck,
|
||||
}
|
||||
}
|
||||
6
packages/ui/src/composables/skin-rendering/index.ts
Normal file
6
packages/ui/src/composables/skin-rendering/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
export * from './use-skin-preview-animation'
|
||||
export * from './use-skin-preview-controls'
|
||||
export * from './use-skin-preview-fit'
|
||||
export * from './use-skin-preview-loading'
|
||||
export * from './use-skin-preview-scene'
|
||||
28
packages/ui/src/composables/skin-rendering/types.ts
Normal file
28
packages/ui/src/composables/skin-rendering/types.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export interface SkinPreviewAnimationConfig {
|
||||
baseAnimation: string
|
||||
randomAnimations: string[]
|
||||
randomAnimationInterval?: number
|
||||
transitionDuration?: number
|
||||
}
|
||||
|
||||
export type SkinPreviewFraming = 'page' | 'modal'
|
||||
|
||||
export interface SkinPreviewFitPadding {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export interface SkinPreviewFitLock {
|
||||
containerSize: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
modelCenter: SkinPreviewTuple
|
||||
modelSize: SkinPreviewTuple
|
||||
padding: SkinPreviewFitPadding
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export type SkinPreviewTuple = [number, number, number]
|
||||
@ -0,0 +1,408 @@
|
||||
import { useRenderLoop } from '@tresjs/core'
|
||||
import * as THREE from 'three'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SkinPreviewAnimationConfig } from './types'
|
||||
|
||||
type AnimationFinishedListener = (
|
||||
event: THREE.AnimationMixerEventMap['finished'] & {
|
||||
readonly type: 'finished'
|
||||
readonly target: THREE.AnimationMixer
|
||||
},
|
||||
) => void
|
||||
|
||||
export const INTERACT_ANIMATION_NAME = 'interact'
|
||||
|
||||
const INTERACT_VISIBLE_DURATION_SECONDS = 0.5
|
||||
const CLICK_IMPULSE_MAX_ENERGY = 5
|
||||
const CLICK_IMPULSE_ENERGY_PER_CLICK = 1
|
||||
const DAMAGE_FLASH_MIN_CLICKS_PER_SECOND = 2
|
||||
const CLICK_IMPULSE_DECAY_PER_SECOND =
|
||||
DAMAGE_FLASH_MIN_CLICKS_PER_SECOND * CLICK_IMPULSE_ENERGY_PER_CLICK
|
||||
const CLICK_IMPULSE_BASE_SPEED = 18
|
||||
const CLICK_IMPULSE_SPEED_BOOST = 7
|
||||
const CLICK_IMPULSE_OFFSET_X = 0.035
|
||||
const CLICK_IMPULSE_ROTATION_Z = 0.055
|
||||
const CLICK_IMPULSE_SCALE_X = 0.018
|
||||
const CLICK_IMPULSE_SCALE_Y = 0.025
|
||||
const DAMAGE_FLASH_DURATION_SECONDS = 0.2
|
||||
const DAMAGE_FLASH_REPEAT_DELAY_SECONDS = 0.5
|
||||
const DAMAGE_FLASH_MAX_INTENSITY = 0.7
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewAnimation(
|
||||
animationConfig: MaybeReadonlyRef<SkinPreviewAnimationConfig | undefined>,
|
||||
) {
|
||||
const mixer = ref<THREE.AnimationMixer | null>(null)
|
||||
const actions = ref<Record<string, THREE.AnimationAction>>({})
|
||||
const clock = new THREE.Clock()
|
||||
const currentAnimation = ref<string>('')
|
||||
const randomAnimationTimer = ref<number | null>(null)
|
||||
const lastRandomAnimation = ref<string>('')
|
||||
const animationFinishedListeners: AnimationFinishedListener[] = []
|
||||
|
||||
const clickImpulseEnergy = ref(0)
|
||||
const clickImpulsePhase = ref(0)
|
||||
const clickImpulseOffsetX = ref(0)
|
||||
const clickImpulseRotationZ = ref(0)
|
||||
const clickImpulseScaleX = ref(1)
|
||||
const clickImpulseScaleY = ref(1)
|
||||
const damageFlashIntensity = ref(0)
|
||||
|
||||
let damageFlashRemainingSeconds = 0
|
||||
let damageFlashCooldownSeconds = 0
|
||||
|
||||
const baseAnimation = computed(() => animationConfig.value?.baseAnimation ?? '')
|
||||
const randomAnimations = computed(() => animationConfig.value?.randomAnimations ?? [])
|
||||
const transitionDuration = computed(() => animationConfig.value?.transitionDuration || 0.3)
|
||||
|
||||
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
|
||||
if (!clips || clips.length === 0) {
|
||||
console.warn('No animation clips found in the model')
|
||||
return
|
||||
}
|
||||
|
||||
mixer.value = new THREE.AnimationMixer(loadedScene)
|
||||
clock.start()
|
||||
actions.value = {}
|
||||
|
||||
clips.forEach((clip) => {
|
||||
if (clip.name === INTERACT_ANIMATION_NAME) {
|
||||
clip.duration = INTERACT_VISIBLE_DURATION_SECONDS
|
||||
}
|
||||
|
||||
const action = mixer.value!.clipAction(clip)
|
||||
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
actions.value[clip.name] = action
|
||||
})
|
||||
|
||||
if (baseAnimation.value && actions.value[baseAnimation.value]) {
|
||||
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
|
||||
playAnimation(baseAnimation.value, true)
|
||||
setupRandomAnimationLoop()
|
||||
} else {
|
||||
console.warn(`Base animation "${baseAnimation.value}" not found`)
|
||||
|
||||
const firstAnimationName = Object.keys(actions.value)[0]
|
||||
if (firstAnimationName) {
|
||||
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
|
||||
playAnimation(firstAnimationName, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playAnimation(name: string, immediate = false) {
|
||||
if (!mixer.value || !actions.value[name]) {
|
||||
console.warn(`Animation "${name}" not found!`)
|
||||
return false
|
||||
}
|
||||
|
||||
const action = actions.value[name]
|
||||
|
||||
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
|
||||
console.log(`Animation "${name}" is already running, ignoring request`)
|
||||
return false
|
||||
}
|
||||
|
||||
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
|
||||
if (actionName !== name && actionInstance.isRunning()) {
|
||||
actionInstance.fadeOut(transitionDuration.value)
|
||||
}
|
||||
})
|
||||
|
||||
action.reset()
|
||||
|
||||
if (name === baseAnimation.value) {
|
||||
action.setLoop(THREE.LoopRepeat, Infinity)
|
||||
} else {
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
|
||||
const onFinished: AnimationFinishedListener = (event) => {
|
||||
if (event.action === action) {
|
||||
removeAnimationFinishedListener(onFinished)
|
||||
if (currentAnimation.value === name && baseAnimation.value) {
|
||||
action.fadeOut(transitionDuration.value)
|
||||
const baseAction = actions.value[baseAnimation.value]
|
||||
if (baseAction) {
|
||||
baseAction.reset()
|
||||
baseAction.fadeIn(transitionDuration.value)
|
||||
baseAction.play()
|
||||
currentAnimation.value = baseAnimation.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addAnimationFinishedListener(onFinished)
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
action.setEffectiveWeight(1)
|
||||
} else {
|
||||
action.fadeIn(transitionDuration.value)
|
||||
}
|
||||
action.play()
|
||||
|
||||
if (immediate) {
|
||||
mixer.value.update(0)
|
||||
}
|
||||
|
||||
currentAnimation.value = name
|
||||
return true
|
||||
}
|
||||
|
||||
function setupRandomAnimationLoop() {
|
||||
const interval = animationConfig.value?.randomAnimationInterval || 10000
|
||||
|
||||
function scheduleNextAnimation() {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearTimeout(randomAnimationTimer.value)
|
||||
}
|
||||
|
||||
randomAnimationTimer.value = window.setTimeout(() => {
|
||||
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
|
||||
const availableAnimations = randomAnimations.value.filter(
|
||||
(anim) => anim !== lastRandomAnimation.value,
|
||||
)
|
||||
const animationsToChooseFrom =
|
||||
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
|
||||
const randomAnimationName = animationsToChooseFrom[randomIndex]
|
||||
|
||||
if (actions.value[randomAnimationName]) {
|
||||
lastRandomAnimation.value = randomAnimationName
|
||||
playRandomAnimation(randomAnimationName)
|
||||
}
|
||||
} else {
|
||||
scheduleNextAnimation()
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
scheduleNextAnimation()
|
||||
}
|
||||
|
||||
function playRandomAnimation(name: string) {
|
||||
if (!mixer.value || !actions.value[name]) {
|
||||
console.warn(`Animation "${name}" not found!`)
|
||||
return
|
||||
}
|
||||
|
||||
const action = actions.value[name]
|
||||
|
||||
if (currentAnimation.value === name && action.isRunning()) {
|
||||
console.log(`Animation "${name}" is already running, ignoring request`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseAction = baseAnimation.value ? actions.value[baseAnimation.value] : undefined
|
||||
if (baseAction?.isRunning()) {
|
||||
baseAction.fadeOut(transitionDuration.value)
|
||||
}
|
||||
|
||||
action.reset()
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
action.setEffectiveTimeScale(1)
|
||||
action.fadeIn(transitionDuration.value)
|
||||
action.play()
|
||||
|
||||
currentAnimation.value = name
|
||||
|
||||
const onFinished: AnimationFinishedListener = (event) => {
|
||||
if (event.action === action) {
|
||||
removeAnimationFinishedListener(onFinished)
|
||||
if (currentAnimation.value === name && baseAnimation.value) {
|
||||
action.fadeOut(transitionDuration.value)
|
||||
const nextBaseAction = actions.value[baseAnimation.value]
|
||||
if (nextBaseAction) {
|
||||
nextBaseAction.reset()
|
||||
nextBaseAction.setEffectiveTimeScale(1)
|
||||
nextBaseAction.fadeIn(transitionDuration.value)
|
||||
nextBaseAction.play()
|
||||
currentAnimation.value = baseAnimation.value
|
||||
|
||||
setupRandomAnimationLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addAnimationFinishedListener(onFinished)
|
||||
}
|
||||
|
||||
function playInteractAnimation() {
|
||||
if (actions.value[INTERACT_ANIMATION_NAME]) {
|
||||
playRandomAnimation(INTERACT_ANIMATION_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
function playClickInteraction() {
|
||||
addClickImpulse()
|
||||
playInteractAnimation()
|
||||
}
|
||||
|
||||
function addClickImpulse() {
|
||||
clickImpulseEnergy.value = Math.min(
|
||||
CLICK_IMPULSE_MAX_ENERGY,
|
||||
clickImpulseEnergy.value + CLICK_IMPULSE_ENERGY_PER_CLICK,
|
||||
)
|
||||
|
||||
if (clickImpulseEnergy.value >= CLICK_IMPULSE_MAX_ENERGY && damageFlashCooldownSeconds <= 0) {
|
||||
triggerDamageFlash()
|
||||
}
|
||||
}
|
||||
|
||||
function updateClickImpulse(delta: number) {
|
||||
const energy = Math.max(0, clickImpulseEnergy.value - CLICK_IMPULSE_DECAY_PER_SECOND * delta)
|
||||
clickImpulseEnergy.value = energy
|
||||
|
||||
if (energy <= 0) {
|
||||
clickImpulseOffsetX.value = 0
|
||||
clickImpulseRotationZ.value = 0
|
||||
clickImpulseScaleX.value = 1
|
||||
clickImpulseScaleY.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
const intensity = energy / CLICK_IMPULSE_MAX_ENERGY
|
||||
clickImpulsePhase.value +=
|
||||
delta * (CLICK_IMPULSE_BASE_SPEED + energy * CLICK_IMPULSE_SPEED_BOOST)
|
||||
|
||||
const shake = Math.sin(clickImpulsePhase.value) * intensity
|
||||
const squash = Math.abs(Math.sin(clickImpulsePhase.value * 1.7)) * intensity
|
||||
|
||||
clickImpulseOffsetX.value = shake * CLICK_IMPULSE_OFFSET_X
|
||||
clickImpulseRotationZ.value = shake * CLICK_IMPULSE_ROTATION_Z
|
||||
clickImpulseScaleX.value = 1 + squash * CLICK_IMPULSE_SCALE_X
|
||||
clickImpulseScaleY.value = 1 - squash * CLICK_IMPULSE_SCALE_Y
|
||||
}
|
||||
|
||||
function triggerDamageFlash() {
|
||||
damageFlashRemainingSeconds = DAMAGE_FLASH_DURATION_SECONDS
|
||||
damageFlashCooldownSeconds = DAMAGE_FLASH_DURATION_SECONDS + DAMAGE_FLASH_REPEAT_DELAY_SECONDS
|
||||
damageFlashIntensity.value = DAMAGE_FLASH_MAX_INTENSITY
|
||||
}
|
||||
|
||||
function updateDamageFlash(delta: number) {
|
||||
damageFlashCooldownSeconds = Math.max(0, damageFlashCooldownSeconds - delta)
|
||||
|
||||
if (damageFlashRemainingSeconds <= 0) {
|
||||
damageFlashIntensity.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
damageFlashRemainingSeconds = Math.max(0, damageFlashRemainingSeconds - delta)
|
||||
damageFlashIntensity.value =
|
||||
DAMAGE_FLASH_MAX_INTENSITY * (damageFlashRemainingSeconds / DAMAGE_FLASH_DURATION_SECONDS)
|
||||
}
|
||||
|
||||
function stopAnimations() {
|
||||
if (mixer.value) {
|
||||
mixer.value.stopAllAction()
|
||||
}
|
||||
currentAnimation.value = ''
|
||||
}
|
||||
|
||||
function getAvailableAnimations(): string[] {
|
||||
return Object.keys(actions.value)
|
||||
}
|
||||
|
||||
function clearRandomAnimationTimer() {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearTimeout(randomAnimationTimer.value)
|
||||
randomAnimationTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function addAnimationFinishedListener(listener: AnimationFinishedListener) {
|
||||
mixer.value?.addEventListener('finished', listener)
|
||||
animationFinishedListeners.push(listener)
|
||||
}
|
||||
|
||||
function removeAnimationFinishedListener(
|
||||
listener: AnimationFinishedListener,
|
||||
targetMixer = mixer.value,
|
||||
) {
|
||||
targetMixer?.removeEventListener('finished', listener)
|
||||
|
||||
const index = animationFinishedListeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
animationFinishedListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnimationFinishedListeners(targetMixer = mixer.value) {
|
||||
animationFinishedListeners.forEach((listener) => {
|
||||
targetMixer?.removeEventListener('finished', listener)
|
||||
})
|
||||
animationFinishedListeners.length = 0
|
||||
}
|
||||
|
||||
function cleanupAnimationState(root: THREE.Object3D | null) {
|
||||
clearRandomAnimationTimer()
|
||||
|
||||
const currentMixer = mixer.value
|
||||
if (currentMixer) {
|
||||
clearAnimationFinishedListeners(currentMixer)
|
||||
currentMixer.stopAllAction()
|
||||
|
||||
if (root) {
|
||||
currentMixer.uncacheRoot(root)
|
||||
}
|
||||
}
|
||||
|
||||
mixer.value = null
|
||||
actions.value = {}
|
||||
currentAnimation.value = ''
|
||||
lastRandomAnimation.value = ''
|
||||
damageFlashRemainingSeconds = 0
|
||||
damageFlashCooldownSeconds = 0
|
||||
damageFlashIntensity.value = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => animationConfig.value,
|
||||
(newConfig) => {
|
||||
clearRandomAnimationTimer()
|
||||
|
||||
if (mixer.value && newConfig?.baseAnimation && actions.value[newConfig.baseAnimation]) {
|
||||
playAnimation(newConfig.baseAnimation)
|
||||
setupRandomAnimationLoop()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const { onLoop } = useRenderLoop()
|
||||
onLoop(() => {
|
||||
const delta = clock.getDelta()
|
||||
|
||||
if (mixer.value) {
|
||||
mixer.value.update(delta)
|
||||
}
|
||||
|
||||
updateClickImpulse(delta)
|
||||
updateDamageFlash(delta)
|
||||
})
|
||||
|
||||
return {
|
||||
clickImpulseOffsetX,
|
||||
clickImpulseRotationZ,
|
||||
clickImpulseScaleX,
|
||||
clickImpulseScaleY,
|
||||
cleanupAnimationState,
|
||||
currentAnimation,
|
||||
damageFlashIntensity,
|
||||
getAvailableAnimations,
|
||||
initializeAnimations,
|
||||
playAnimation,
|
||||
playClickInteraction,
|
||||
stopAnimations,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { type ComputedRef, type Ref, ref } from 'vue'
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewControls({
|
||||
initialRotation,
|
||||
onClickWithoutDrag,
|
||||
}: {
|
||||
initialRotation: MaybeReadonlyRef<number | undefined>
|
||||
onClickWithoutDrag: () => void
|
||||
}) {
|
||||
const modelRotation = ref((initialRotation.value ?? 15.75) + Math.PI)
|
||||
const isDragging = ref(false)
|
||||
const previousX = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
isDragging.value = true
|
||||
previousX.value = event.clientX
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
const deltaX = event.clientX - previousX.value
|
||||
modelRotation.value += deltaX * 0.01
|
||||
previousX.value = event.clientX
|
||||
hasDragged.value = true
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
isDragging.value = false
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (target.hasPointerCapture(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasClick() {
|
||||
if (!hasDragged.value) {
|
||||
onClickWithoutDrag()
|
||||
}
|
||||
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function ignoreControlClick(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return {
|
||||
ignoreControlClick,
|
||||
modelRotation,
|
||||
onCanvasClick,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,380 @@
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
type CSSProperties,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Ref,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import type {
|
||||
SkinPreviewFitLock,
|
||||
SkinPreviewFitPadding,
|
||||
SkinPreviewFraming,
|
||||
SkinPreviewTuple,
|
||||
} from './types'
|
||||
|
||||
const FRAMING_PRESETS = {
|
||||
page: {
|
||||
fov: 35,
|
||||
zoom: 0.96,
|
||||
padding: { top: 0.2, right: 0.14, bottom: 0.3, left: 0.14 },
|
||||
},
|
||||
modal: {
|
||||
fov: 35,
|
||||
zoom: 1,
|
||||
padding: { top: 0.1, right: 0.1, bottom: 0.18, left: 0.1 },
|
||||
},
|
||||
} satisfies Record<
|
||||
SkinPreviewFraming,
|
||||
{ fov: number; zoom: number; padding: SkinPreviewFitPadding }
|
||||
>
|
||||
|
||||
const PREVIEW_CONTROLS_FOOT_OFFSET = 64
|
||||
const SUBTITLE_CONTROLS_OFFSET = 48
|
||||
const NAMETAG_HEAD_OFFSET = 16
|
||||
|
||||
function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
|
||||
return [tuple[0], tuple[1], tuple[2]]
|
||||
}
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewFit({
|
||||
containerElement,
|
||||
fit,
|
||||
lockFit,
|
||||
framing,
|
||||
fitZoom,
|
||||
fitPadding,
|
||||
scale,
|
||||
fov,
|
||||
modelRotation,
|
||||
nametag,
|
||||
hasSubtitle,
|
||||
hasNametagBadge,
|
||||
subtitleWrapped,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
isModelLoaded,
|
||||
}: {
|
||||
containerElement: MaybeReadonlyRef<HTMLElement | null>
|
||||
fit: MaybeReadonlyRef<boolean | undefined>
|
||||
lockFit: MaybeReadonlyRef<boolean | undefined>
|
||||
framing: MaybeReadonlyRef<SkinPreviewFraming | undefined>
|
||||
fitZoom: MaybeReadonlyRef<number | undefined>
|
||||
fitPadding: MaybeReadonlyRef<Partial<SkinPreviewFitPadding> | undefined>
|
||||
scale: MaybeReadonlyRef<number | undefined>
|
||||
fov: MaybeReadonlyRef<number | undefined>
|
||||
modelRotation: MaybeReadonlyRef<number>
|
||||
nametag: MaybeReadonlyRef<string | undefined>
|
||||
hasSubtitle: MaybeReadonlyRef<boolean>
|
||||
hasNametagBadge: MaybeReadonlyRef<boolean>
|
||||
subtitleWrapped: MaybeReadonlyRef<boolean>
|
||||
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
isModelLoaded: MaybeReadonlyRef<boolean>
|
||||
}) {
|
||||
const containerSize = ref({ width: 1, height: 1 })
|
||||
const fitLock = ref<SkinPreviewFitLock | null>(null)
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
const fitEnabled = computed(() => {
|
||||
if (fit.value !== undefined) return fit.value
|
||||
return scale.value === undefined && fov.value === undefined
|
||||
})
|
||||
const currentFraming = computed<SkinPreviewFraming>(() => framing.value ?? 'page')
|
||||
const lockFitEnabled = computed(() => currentFraming.value === 'page' || (lockFit.value ?? true))
|
||||
const legacyScale = computed(() => scale.value ?? 1)
|
||||
const legacyFov = computed(() => fov.value ?? 40)
|
||||
|
||||
const hasUsableFitSize = computed(
|
||||
() => containerSize.value.width > 1 && containerSize.value.height > 1,
|
||||
)
|
||||
const hasResolvedFit = computed(
|
||||
() =>
|
||||
!fitEnabled.value || (lockFitEnabled.value ? fitLock.value !== null : hasUsableFitSize.value),
|
||||
)
|
||||
|
||||
const fitContainerSize = computed(() =>
|
||||
lockFitEnabled.value
|
||||
? (fitLock.value?.containerSize ?? containerSize.value)
|
||||
: containerSize.value,
|
||||
)
|
||||
const fitModelCenter = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.modelCenter ?? modelCenter.value) : modelCenter.value,
|
||||
)
|
||||
const fitModelSize = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.modelSize ?? modelSize.value) : modelSize.value,
|
||||
)
|
||||
const fitModelRotation = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
|
||||
)
|
||||
|
||||
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
|
||||
const preset = FRAMING_PRESETS[currentFraming.value].padding
|
||||
|
||||
return {
|
||||
top: Math.max(preset.top, hasNametagBadge.value ? 0.28 : nametag.value ? 0.2 : 0),
|
||||
right: preset.right,
|
||||
bottom: Math.max(preset.bottom, hasSubtitle.value ? 0.28 : preset.bottom),
|
||||
left: preset.left,
|
||||
...(fitPadding.value ?? {}),
|
||||
}
|
||||
})
|
||||
const fitResolvedPadding = computed(() =>
|
||||
lockFitEnabled.value
|
||||
? (fitLock.value?.padding ?? resolvedFitPadding.value)
|
||||
: resolvedFitPadding.value,
|
||||
)
|
||||
|
||||
const modelOffset = computed<SkinPreviewTuple>(() => {
|
||||
if (!fitEnabled.value) return [0, 0, 0]
|
||||
|
||||
const [x, y, z] = fitModelCenter.value
|
||||
return [-x, -y, -z]
|
||||
})
|
||||
|
||||
const modelGroupPosition = computed<SkinPreviewTuple>(() => {
|
||||
if (fitEnabled.value) return [0, 0, 0]
|
||||
return [0, -0.05 * legacyScale.value, 1.95]
|
||||
})
|
||||
|
||||
const modelGroupScale = computed<SkinPreviewTuple>(() => {
|
||||
if (fitEnabled.value) return [1, 1, 1]
|
||||
|
||||
const resolvedScale = 0.8 * legacyScale.value
|
||||
return [resolvedScale, resolvedScale, resolvedScale]
|
||||
})
|
||||
|
||||
const fittedCamera = computed(() => {
|
||||
const width = Math.max(fitContainerSize.value.width, 1)
|
||||
const height = Math.max(fitContainerSize.value.height, 1)
|
||||
const aspect = width / height
|
||||
const preset = FRAMING_PRESETS[currentFraming.value]
|
||||
const padding = fitResolvedPadding.value
|
||||
|
||||
const usableWidth = Math.max(width * (1 - padding.left - padding.right), 1)
|
||||
const usableHeight = Math.max(height * (1 - padding.top - padding.bottom), 1)
|
||||
|
||||
const [sizeX, sizeY, sizeZ] = fitModelSize.value
|
||||
const halfWidth = Math.sqrt((sizeX / 2) ** 2 + (sizeZ / 2) ** 2)
|
||||
const halfHeight = sizeY / 2
|
||||
|
||||
const resolvedFov = fov.value ?? preset.fov
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect)
|
||||
|
||||
const paddedHalfWidth = halfWidth * (width / usableWidth)
|
||||
const paddedHalfHeight = halfHeight * (height / usableHeight)
|
||||
const zoom = Math.max((fitZoom.value ?? 1) * preset.zoom, 0.01)
|
||||
|
||||
const distance =
|
||||
Math.max(
|
||||
paddedHalfHeight / Math.tan(verticalFov / 2),
|
||||
paddedHalfWidth / Math.tan(horizontalFov / 2),
|
||||
) / zoom
|
||||
|
||||
const visibleHalfHeight = distance * Math.tan(verticalFov / 2)
|
||||
const targetY = -(padding.bottom - padding.top) * visibleHalfHeight
|
||||
|
||||
return {
|
||||
fov: resolvedFov,
|
||||
position: [0, targetY, -distance] as SkinPreviewTuple,
|
||||
target: [0, targetY, 0] as SkinPreviewTuple,
|
||||
}
|
||||
})
|
||||
|
||||
const cameraConfig = computed(() => {
|
||||
if (fitEnabled.value) return fittedCamera.value
|
||||
|
||||
return {
|
||||
fov: legacyFov.value,
|
||||
position: [0, 1.5, -3.25] as SkinPreviewTuple,
|
||||
target: modelCenter.value,
|
||||
}
|
||||
})
|
||||
|
||||
const modelFeetTop = computed(() => {
|
||||
if (!fitEnabled.value) return null
|
||||
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [, sizeY] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelFeetY = -sizeY / 2
|
||||
const projectedY =
|
||||
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
|
||||
|
||||
return (topPercent / 100) * height
|
||||
})
|
||||
|
||||
const previewControlsTop = computed(() =>
|
||||
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
|
||||
)
|
||||
|
||||
const previewControlsPositionStyle = computed<CSSProperties>(() => {
|
||||
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
|
||||
return {
|
||||
bottom: currentFraming.value === 'modal' ? '6%' : 'calc(15% + 64px)',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${previewControlsTop.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const subtitlePositionStyle = computed<CSSProperties>(() => {
|
||||
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
|
||||
return {
|
||||
bottom:
|
||||
currentFraming.value === 'modal'
|
||||
? '6%'
|
||||
: subtitleWrapped.value
|
||||
? 'calc(15% - 32px)'
|
||||
: '15%',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${previewControlsTop.value + SUBTITLE_CONTROLS_OFFSET}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const nametagTop = computed(() => {
|
||||
if (!fitEnabled.value) return '18%'
|
||||
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [sizeX, sizeY, sizeZ] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelTopY = sizeY / 2
|
||||
const halfX = sizeX / 2
|
||||
const halfZ = sizeZ / 2
|
||||
const sinRotation = Math.sin(fitModelRotation.value)
|
||||
const cosRotation = Math.cos(fitModelRotation.value)
|
||||
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
|
||||
const projectedY =
|
||||
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = ((1 - projectedY) / 2) * 100
|
||||
|
||||
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
|
||||
})
|
||||
|
||||
const spotlightY = computed(() => {
|
||||
if (!fitEnabled.value) return -0.1 * legacyScale.value
|
||||
|
||||
const [, sizeY] = fitModelSize.value
|
||||
return -sizeY / 2 - 0.02
|
||||
})
|
||||
|
||||
const spotlightPosition = computed<SkinPreviewTuple>(() => [
|
||||
0,
|
||||
spotlightY.value,
|
||||
fitEnabled.value ? 0 : 2,
|
||||
])
|
||||
|
||||
const spotlightScale = computed<SkinPreviewTuple>(() => {
|
||||
if (!fitEnabled.value) {
|
||||
const resolvedScale = 0.75 * legacyScale.value
|
||||
return [resolvedScale, resolvedScale, resolvedScale]
|
||||
}
|
||||
|
||||
const [sizeX, , sizeZ] = fitModelSize.value
|
||||
const radius = Math.max(sizeX, sizeZ, 1) * 0.8
|
||||
return [radius, radius, radius]
|
||||
})
|
||||
|
||||
function lockFitState() {
|
||||
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
|
||||
|
||||
const { width, height } = containerSize.value
|
||||
if (width <= 1 || height <= 1) return
|
||||
|
||||
fitLock.value = {
|
||||
containerSize: { width, height },
|
||||
modelCenter: cloneModelTuple(modelCenter.value),
|
||||
modelSize: cloneModelTuple(modelSize.value),
|
||||
padding: { ...resolvedFitPadding.value },
|
||||
rotation: modelRotation.value,
|
||||
}
|
||||
}
|
||||
|
||||
function resetFitLockForLayoutChange() {
|
||||
if (!fitEnabled.value || !lockFitEnabled.value) return
|
||||
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerElement.value
|
||||
if (!el) return
|
||||
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
const nextContainerSize = {
|
||||
width: Math.max(width, 1),
|
||||
height: Math.max(height, 1),
|
||||
}
|
||||
const didContainerSizeChange =
|
||||
nextContainerSize.width !== containerSize.value.width ||
|
||||
nextContainerSize.height !== containerSize.value.height
|
||||
|
||||
containerSize.value = nextContainerSize
|
||||
|
||||
if (didContainerSizeChange) {
|
||||
resetFitLockForLayoutChange()
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(el)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => isModelLoaded.value,
|
||||
(loaded) => {
|
||||
if (loaded) lockFitState()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lockFitEnabled.value,
|
||||
() => {
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
},
|
||||
)
|
||||
|
||||
watch(fitEnabled, () => {
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
cameraConfig,
|
||||
currentFraming,
|
||||
fitEnabled,
|
||||
hasResolvedFit,
|
||||
legacyScale,
|
||||
modelGroupPosition,
|
||||
modelGroupScale,
|
||||
modelOffset,
|
||||
nametagTop,
|
||||
previewControlsPositionStyle,
|
||||
spotlightPosition,
|
||||
spotlightScale,
|
||||
subtitlePositionStyle,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { computed, type ComputedRef, onUnmounted, type Ref, ref, watch } from 'vue'
|
||||
|
||||
const LOADING_INDICATOR_DELAY_MS = 200
|
||||
const LOADING_INDICATOR_MIN_MS = 250
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewLoading(isReady: MaybeReadonlyRef<boolean>) {
|
||||
const showLoading = ref(false)
|
||||
const isPreviewVisible = computed(() => isReady.value && !showLoading.value)
|
||||
let loadingIndicatorDelayTimer: number | null = null
|
||||
let loadingIndicatorMinTimer: number | null = null
|
||||
let loadingIndicatorShownAt = 0
|
||||
|
||||
function clearLoadingIndicatorDelayTimer() {
|
||||
if (loadingIndicatorDelayTimer !== null) {
|
||||
clearTimeout(loadingIndicatorDelayTimer)
|
||||
loadingIndicatorDelayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLoadingIndicatorMinTimer() {
|
||||
if (loadingIndicatorMinTimer !== null) {
|
||||
clearTimeout(loadingIndicatorMinTimer)
|
||||
loadingIndicatorMinTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoadingIndicatorAfterMinimum() {
|
||||
const visibleFor = Date.now() - loadingIndicatorShownAt
|
||||
const remaining = LOADING_INDICATOR_MIN_MS - visibleFor
|
||||
|
||||
if (remaining <= 0) {
|
||||
showLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingIndicatorMinTimer = window.setTimeout(() => {
|
||||
showLoading.value = false
|
||||
loadingIndicatorMinTimer = null
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isReady.value,
|
||||
(ready) => {
|
||||
clearLoadingIndicatorDelayTimer()
|
||||
|
||||
if (ready) {
|
||||
if (showLoading.value) {
|
||||
clearLoadingIndicatorMinTimer()
|
||||
hideLoadingIndicatorAfterMinimum()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearLoadingIndicatorMinTimer()
|
||||
|
||||
if (showLoading.value || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
loadingIndicatorDelayTimer = window.setTimeout(() => {
|
||||
loadingIndicatorDelayTimer = null
|
||||
|
||||
if (isReady.value) {
|
||||
return
|
||||
}
|
||||
|
||||
showLoading.value = true
|
||||
loadingIndicatorShownAt = Date.now()
|
||||
}, LOADING_INDICATOR_DELAY_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearLoadingIndicatorDelayTimer()
|
||||
clearLoadingIndicatorMinTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
isPreviewVisible,
|
||||
showLoading,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
import { useGLTF } from '@tresjs/cientos'
|
||||
import { useTexture } from '@tresjs/core'
|
||||
import * as THREE from 'three'
|
||||
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
import {
|
||||
type ComputedRef,
|
||||
markRaw,
|
||||
onBeforeMount,
|
||||
onUnmounted,
|
||||
type Ref,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import {
|
||||
applyCapeTexture,
|
||||
applyTexture,
|
||||
applyThreeDSkinLayers,
|
||||
createTransparentTexture,
|
||||
loadTexture as loadSkinTexture,
|
||||
} from '#ui/utils/webgl/skin-rendering.ts'
|
||||
|
||||
import type { SkinPreviewTuple } from './types'
|
||||
|
||||
const SKIN_LAYER_DEPTH_BIAS = -1
|
||||
|
||||
function configureSkinPreviewMesh(mesh: THREE.Mesh) {
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
mesh.renderOrder = 0
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
materials.forEach((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
|
||||
|
||||
material.transparent = isSkinLayer
|
||||
material.alphaTest = 0.1
|
||||
material.depthTest = true
|
||||
material.depthWrite = true
|
||||
material.polygonOffset = isSkinLayer
|
||||
material.polygonOffsetFactor = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
|
||||
material.polygonOffsetUnits = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
|
||||
material.needsUpdate = true
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSceneForRenderer(source: THREE.Object3D) {
|
||||
const cloned = cloneSkeleton(source)
|
||||
|
||||
cloned.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.material) return
|
||||
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map((material) => material.clone())
|
||||
: mesh.material.clone()
|
||||
|
||||
configureSkinPreviewMesh(mesh)
|
||||
})
|
||||
|
||||
return markRaw(cloned)
|
||||
}
|
||||
|
||||
function disposeSceneMaterials(root: THREE.Object3D | null) {
|
||||
if (!root) return
|
||||
|
||||
const materials = new Set<THREE.Material>()
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.material) return
|
||||
|
||||
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
meshMaterials.forEach((material) => materials.add(material))
|
||||
if (mesh.userData.threeDSkinLayersApplied) {
|
||||
mesh.geometry.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
materials.forEach((material) => material.dispose())
|
||||
}
|
||||
|
||||
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
|
||||
root.updateWorldMatrix(true, true)
|
||||
|
||||
const result = new THREE.Box3()
|
||||
const meshBox = new THREE.Box3()
|
||||
let found = false
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
if (materials.length && materials.every((material) => material.visible === false)) return
|
||||
|
||||
if (!mesh.geometry.boundingBox) {
|
||||
mesh.geometry.computeBoundingBox()
|
||||
}
|
||||
|
||||
if (!mesh.geometry.boundingBox) return
|
||||
|
||||
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
|
||||
result.union(meshBox)
|
||||
found = true
|
||||
})
|
||||
|
||||
return found && !result.isEmpty() ? result.clone() : null
|
||||
}
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc,
|
||||
capeSrc,
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
}: {
|
||||
selectedModelSrc: MaybeReadonlyRef<string>
|
||||
textureSrc: MaybeReadonlyRef<string>
|
||||
capeSrc: MaybeReadonlyRef<string | undefined>
|
||||
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
|
||||
cleanupAnimationState: (root: THREE.Object3D | null) => void
|
||||
}) {
|
||||
const scene = shallowRef<THREE.Object3D | null>(null)
|
||||
const lastCapeSrc = ref<string | undefined>(undefined)
|
||||
const loadedModelSrc = ref<string | undefined>(undefined)
|
||||
const loadedTextureSrc = ref<string | undefined>(undefined)
|
||||
const loadedCapeSrc = ref<string | undefined>(undefined)
|
||||
const texture = shallowRef<THREE.Texture | null>(null)
|
||||
const capeTexture = shallowRef<THREE.Texture | null>(null)
|
||||
const transparentTexture = createTransparentTexture()
|
||||
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
|
||||
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
|
||||
const isModelLoaded = ref(false)
|
||||
const isTextureLoaded = ref(false)
|
||||
let modelLoadVersion = 0
|
||||
let textureLoadVersion = 0
|
||||
let capeLoadVersion = 0
|
||||
let isUnmounted = false
|
||||
|
||||
function applyTextureToLoadedModel() {
|
||||
if (
|
||||
!scene.value ||
|
||||
!texture.value ||
|
||||
loadedModelSrc.value !== selectedModelSrc.value ||
|
||||
loadedTextureSrc.value !== textureSrc.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
applyTexture(scene.value, texture.value)
|
||||
applyThreeDSkinLayers(scene.value, texture.value)
|
||||
}
|
||||
|
||||
function applyCapeTextureToLoadedModel() {
|
||||
if (!scene.value || loadedModelSrc.value !== selectedModelSrc.value) return
|
||||
|
||||
applyCapeTexture(
|
||||
scene.value,
|
||||
loadedCapeSrc.value === capeSrc.value ? capeTexture.value : null,
|
||||
transparentTexture,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadModel(src: string) {
|
||||
const loadVersion = ++modelLoadVersion
|
||||
|
||||
try {
|
||||
isModelLoaded.value = false
|
||||
const { scene: loadedScene, animations } = await useGLTF(src)
|
||||
const clonedScene = cloneSceneForRenderer(loadedScene)
|
||||
if (isUnmounted || loadVersion !== modelLoadVersion) {
|
||||
disposeSceneMaterials(clonedScene)
|
||||
return
|
||||
}
|
||||
|
||||
const previousScene = scene.value
|
||||
cleanupAnimationState(previousScene)
|
||||
disposeSceneMaterials(previousScene)
|
||||
scene.value = clonedScene
|
||||
loadedModelSrc.value = src
|
||||
|
||||
applyTextureToLoadedModel()
|
||||
|
||||
applyCapeTextureToLoadedModel()
|
||||
|
||||
if (animations && animations.length > 0) {
|
||||
initializeAnimations(clonedScene, animations)
|
||||
}
|
||||
|
||||
updateModelInfo()
|
||||
isModelLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load model:', error)
|
||||
if (!isUnmounted && loadVersion === modelLoadVersion) {
|
||||
isModelLoaded.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyTexture(src: string) {
|
||||
if (!src) return null
|
||||
|
||||
try {
|
||||
try {
|
||||
return await loadSkinTexture(src)
|
||||
} catch {
|
||||
const tex = await useTexture([src])
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.flipY = false
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
tex.minFilter = THREE.NearestFilter
|
||||
return tex
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyCapeTexture(src: string | undefined) {
|
||||
if (src === lastCapeSrc.value) return
|
||||
|
||||
const loadVersion = ++capeLoadVersion
|
||||
lastCapeSrc.value = src
|
||||
|
||||
let loadedCapeTexture: THREE.Texture | null = null
|
||||
if (src) {
|
||||
loadedCapeTexture = await loadAndApplyTexture(src)
|
||||
}
|
||||
if (isUnmounted || loadVersion !== capeLoadVersion) return
|
||||
|
||||
capeTexture.value = loadedCapeTexture
|
||||
loadedCapeSrc.value = src
|
||||
applyCapeTextureToLoadedModel()
|
||||
}
|
||||
|
||||
function updateModelInfo() {
|
||||
const box = scene.value ? getVisibleMeshBox(scene.value) : null
|
||||
|
||||
if (!box) {
|
||||
modelCenter.value = [0, 1, 0]
|
||||
modelSize.value = [1, 2, 1]
|
||||
return
|
||||
}
|
||||
|
||||
const center = new THREE.Vector3()
|
||||
const size = new THREE.Vector3()
|
||||
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
modelCenter.value = [center.x, center.y, center.z]
|
||||
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedModelSrc.value,
|
||||
(src) => loadModel(src),
|
||||
)
|
||||
watch(
|
||||
() => textureSrc.value,
|
||||
async (newSrc) => {
|
||||
const loadVersion = ++textureLoadVersion
|
||||
|
||||
isTextureLoaded.value = false
|
||||
const loadedTexture = await loadAndApplyTexture(newSrc)
|
||||
if (isUnmounted || loadVersion !== textureLoadVersion) return
|
||||
|
||||
texture.value = loadedTexture
|
||||
loadedTextureSrc.value = newSrc
|
||||
applyTextureToLoadedModel()
|
||||
isTextureLoaded.value = true
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => capeSrc.value,
|
||||
async (newCapeSrc) => {
|
||||
await loadAndApplyCapeTexture(newCapeSrc)
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
isTextureLoaded.value = false
|
||||
texture.value = await loadAndApplyTexture(textureSrc.value)
|
||||
loadedTextureSrc.value = textureSrc.value
|
||||
isTextureLoaded.value = true
|
||||
|
||||
await loadModel(selectedModelSrc.value)
|
||||
|
||||
if (capeSrc.value) {
|
||||
await loadAndApplyCapeTexture(capeSrc.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skin preview:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
modelLoadVersion++
|
||||
textureLoadVersion++
|
||||
capeLoadVersion++
|
||||
|
||||
cleanupAnimationState(scene.value)
|
||||
disposeSceneMaterials(scene.value)
|
||||
scene.value = null
|
||||
transparentTexture.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
isModelLoaded,
|
||||
isTextureLoaded,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
}
|
||||
}
|
||||
84
packages/ui/src/composables/sticky-observer.ts
Normal file
84
packages/ui/src/composables/sticky-observer.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useDebugLogger } from './debug-logger'
|
||||
|
||||
/**
|
||||
* Observes when a target element becomes "stuck" (i.e. its natural position has scrolled out of view).
|
||||
* Injects a zero-height sentinel element before the target and uses IntersectionObserver to detect
|
||||
* when the sentinel leaves the viewport.
|
||||
*/
|
||||
export function useStickyObserver(target: Ref<HTMLElement | null | undefined>, label?: string) {
|
||||
const debug = useDebugLogger(`sticky-observer${label ? `:${label}` : ''}`)
|
||||
const isStuck = ref(false)
|
||||
let sentinel: HTMLElement | null = null
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
debug('init, target value:', target.value)
|
||||
|
||||
watch(
|
||||
target,
|
||||
(el, oldEl) => {
|
||||
debug('watch fired, el:', el, 'oldEl:', oldEl)
|
||||
observer?.disconnect()
|
||||
sentinel?.remove()
|
||||
observer = null
|
||||
sentinel = null
|
||||
|
||||
if (el) {
|
||||
debug(
|
||||
'setting up sentinel, parent:',
|
||||
el.parentElement,
|
||||
'parentClasses:',
|
||||
el.parentElement?.className,
|
||||
)
|
||||
debug('el classes:', el.className)
|
||||
debug('el computed overflow:', getComputedStyle(el).overflow)
|
||||
debug(
|
||||
'parent computed overflow:',
|
||||
el.parentElement ? getComputedStyle(el.parentElement).overflow : 'no parent',
|
||||
)
|
||||
|
||||
sentinel = document.createElement('div')
|
||||
sentinel.style.height = '0'
|
||||
const parentGap = getComputedStyle(el.parentElement!).gap
|
||||
sentinel.style.marginBottom = parentGap ? `-${parentGap}` : '0'
|
||||
sentinel.setAttribute('aria-hidden', 'true')
|
||||
el.parentElement?.insertBefore(sentinel, el)
|
||||
|
||||
debug('sentinel inserted, sentinel parent:', sentinel.parentElement?.className)
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const wasStuck = isStuck.value
|
||||
isStuck.value = !entry.isIntersecting
|
||||
if (wasStuck !== isStuck.value) {
|
||||
debug(
|
||||
'isStuck changed:',
|
||||
isStuck.value,
|
||||
'intersectionRatio:',
|
||||
entry.intersectionRatio,
|
||||
'boundingClientRect:',
|
||||
entry.boundingClientRect,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ threshold: 0, rootMargin: '-1px 0px 0px 0px' },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
debug('observer started')
|
||||
} else {
|
||||
debug('el is null, no observer set up')
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
debug('unmounted, cleaning up')
|
||||
observer?.disconnect()
|
||||
sentinel?.remove()
|
||||
})
|
||||
|
||||
return { isStuck }
|
||||
}
|
||||
282
packages/ui/src/composables/terminal.ts
Normal file
282
packages/ui/src/composables/terminal.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import type { FitAddon } from '@xterm/addon-fit'
|
||||
import type { SearchAddon } from '@xterm/addon-search'
|
||||
import type { ITerminalOptions, Terminal } from '@xterm/xterm'
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
type Ref,
|
||||
ref,
|
||||
type ShallowRef,
|
||||
shallowRef,
|
||||
} from 'vue'
|
||||
|
||||
export function getCssVar(name: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
function buildTerminalTheme() {
|
||||
const surface2 = getCssVar('--surface-2', '#1d1f23')
|
||||
const surface5 = getCssVar('--surface-5', '#42444a')
|
||||
const textDefault = getCssVar('--color-text-default', '#b0bac5')
|
||||
const textTertiary = getCssVar('--color-text-tertiary', '#96a2b0')
|
||||
const textPrimary = getCssVar('--color-text-primary', '#ffffff')
|
||||
const red = getCssVar('--color-red', '#ff496e')
|
||||
const orange = getCssVar('--color-orange', '#ffa347')
|
||||
const green = getCssVar('--color-green', '#1bd96a')
|
||||
const blue = getCssVar('--color-blue', '#4a9eff')
|
||||
const purple = getCssVar('--color-purple', '#bc3fbc')
|
||||
|
||||
return {
|
||||
background: surface2,
|
||||
foreground: textDefault,
|
||||
cursor: textDefault,
|
||||
cursorAccent: surface2,
|
||||
selectionBackground: 'rgba(128, 128, 128, 0.3)',
|
||||
black: surface2,
|
||||
red,
|
||||
green,
|
||||
yellow: orange,
|
||||
blue,
|
||||
magenta: purple,
|
||||
cyan: textTertiary,
|
||||
white: textDefault,
|
||||
brightBlack: surface5,
|
||||
brightRed: red,
|
||||
brightGreen: green,
|
||||
brightYellow: orange,
|
||||
brightBlue: blue,
|
||||
brightMagenta: purple,
|
||||
brightCyan: textTertiary,
|
||||
brightWhite: textPrimary,
|
||||
scrollbarSliderBackground: surface5,
|
||||
scrollbarSliderHoverBackground: surface5,
|
||||
scrollbarSliderActiveBackground: surface5,
|
||||
overviewRulerBorder: 'transparent',
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseTerminalOptions {
|
||||
container: Ref<HTMLElement | null>
|
||||
options?: ITerminalOptions
|
||||
scrollback?: number
|
||||
onReady?: (terminal: Terminal) => void
|
||||
onResize?: () => void
|
||||
}
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
terminal: ShallowRef<Terminal | null>
|
||||
fitAddon: ShallowRef<FitAddon | null>
|
||||
searchAddon: ShallowRef<SearchAddon | null>
|
||||
isAtBottom: Ref<boolean>
|
||||
write: (data: string) => void
|
||||
writeln: (data: string) => void
|
||||
clear: () => void
|
||||
reset: () => void
|
||||
fit: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
const terminal = shallowRef<Terminal | null>(null)
|
||||
const fitAddon = shallowRef<FitAddon | null>(null)
|
||||
const searchAddon = shallowRef<SearchAddon | null>(null)
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let wheelHandler: ((e: WheelEvent) => void) | null = null
|
||||
let hasWritten = false
|
||||
const pendingWrites: Array<{ data: string; newline: boolean }> = []
|
||||
|
||||
const write = (data: string) => {
|
||||
if (terminal.value) {
|
||||
terminal.value.write(data)
|
||||
hasWritten = true
|
||||
} else {
|
||||
pendingWrites.push({ data, newline: false })
|
||||
}
|
||||
}
|
||||
|
||||
const writeln = (data: string) => {
|
||||
if (terminal.value) {
|
||||
if (hasWritten) {
|
||||
terminal.value.write('\r\n' + data)
|
||||
} else {
|
||||
terminal.value.write(data)
|
||||
hasWritten = true
|
||||
}
|
||||
} else {
|
||||
pendingWrites.push({ data, newline: true })
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
terminal.value?.clear()
|
||||
hasWritten = false
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
terminal.value?.reset()
|
||||
hasWritten = false
|
||||
}
|
||||
|
||||
const fit = () => {
|
||||
const fa = fitAddon.value
|
||||
const term = terminal.value
|
||||
if (!fa || !term) return
|
||||
const dims = fa.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
terminal.value?.scrollToBottom()
|
||||
isAtBottom.value = true
|
||||
|
||||
// dont even ask, shit is broken as hell
|
||||
// scrollToBottom is unreliable so we have to spam it to make sure it actually goes to the bottom
|
||||
let calls = 0
|
||||
const interval = setInterval(() => {
|
||||
terminal.value?.scrollToBottom()
|
||||
if (++calls >= 10) clearInterval(interval)
|
||||
}, 25)
|
||||
}
|
||||
|
||||
const checkIfAtBottom = () => {
|
||||
const term = terminal.value
|
||||
if (!term) return
|
||||
const buffer = term.buffer.active
|
||||
isAtBottom.value = buffer.baseY - buffer.viewportY <= 2
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const container = options.container.value
|
||||
if (!container) return
|
||||
|
||||
const [{ Terminal }, { FitAddon }, { SearchAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-search'),
|
||||
])
|
||||
|
||||
await import('@xterm/xterm/css/xterm.css')
|
||||
|
||||
const term = new Terminal({
|
||||
disableStdin: true,
|
||||
scrollback: options.scrollback ?? Infinity,
|
||||
convertEol: true,
|
||||
smoothScrollDuration: 125,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
allowProposedApi: true,
|
||||
theme: buildTerminalTheme(),
|
||||
...options.options,
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
const search = new SearchAddon()
|
||||
|
||||
term.loadAddon(fit)
|
||||
term.loadAddon(search)
|
||||
term.open(container)
|
||||
await nextTick()
|
||||
const dims = fit.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows)
|
||||
}
|
||||
|
||||
term.options.disableStdin = true
|
||||
term.write('\x1b[?25l')
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
if (!mod) return true
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'a') {
|
||||
e.preventDefault()
|
||||
term.selectAll()
|
||||
return false
|
||||
}
|
||||
if (key === 'c' || key === 'insert') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
wheelHandler = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
container.addEventListener('wheel', wheelHandler, { passive: false })
|
||||
|
||||
term.onScroll(() => checkIfAtBottom())
|
||||
term.onWriteParsed(() => {
|
||||
if (isAtBottom.value) {
|
||||
term.scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
terminal.value = term
|
||||
fitAddon.value = fit
|
||||
searchAddon.value = search
|
||||
|
||||
for (const pending of pendingWrites) {
|
||||
if (pending.newline) {
|
||||
writeln(pending.data)
|
||||
} else {
|
||||
write(pending.data)
|
||||
}
|
||||
}
|
||||
pendingWrites.length = 0
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const d = fit.proposeDimensions()
|
||||
if (d) {
|
||||
term.resize(d.cols, d.rows)
|
||||
}
|
||||
options.onResize?.()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
term.options.theme = buildTerminalTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'class'],
|
||||
})
|
||||
|
||||
options.onReady?.(term)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (wheelHandler && options.container.value) {
|
||||
options.container.value.removeEventListener('wheel', wheelHandler)
|
||||
wheelHandler = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
themeObserver?.disconnect()
|
||||
themeObserver = null
|
||||
terminal.value?.dispose()
|
||||
terminal.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
isAtBottom,
|
||||
write,
|
||||
writeln,
|
||||
clear,
|
||||
reset,
|
||||
fit,
|
||||
scrollToBottom,
|
||||
}
|
||||
}
|
||||
67
packages/ui/src/composables/use-batch-drop.ts
Normal file
67
packages/ui/src/composables/use-batch-drop.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { ClassificationChoice, ClassificationResult } from './use-global-drop'
|
||||
|
||||
export type BatchDropPhase =
|
||||
| 'idle'
|
||||
| 'scanning'
|
||||
| 'picking-instance'
|
||||
| 'picking-world'
|
||||
| 'confirming'
|
||||
| 'installing'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
|
||||
export type BatchDropScanState = 'pending' | 'scanning' | 'done' | 'skipped' | 'error'
|
||||
export type BatchDropInstallState =
|
||||
| 'queued'
|
||||
| 'processing'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'skipped'
|
||||
export type BatchDropResultStatus = 'success' | 'failed' | 'skipped' | 'cancelled'
|
||||
|
||||
export interface BatchDropItem {
|
||||
id: string
|
||||
sourcePath: string
|
||||
name: string
|
||||
/** Optional source qualifier used to disambiguate identical names (e.g. launcher folder). */
|
||||
sourceLabel?: string
|
||||
scanState: BatchDropScanState
|
||||
classification?: ClassificationResult
|
||||
/** Resolved type after confirmation: mod, resource_pack, shader_pack, world_save, litematic, schematic, datapack, modpack, instance. */
|
||||
itemType?: string
|
||||
innerBase?: string
|
||||
launcherType?: string
|
||||
basePath?: string
|
||||
instanceFolder?: string
|
||||
instancePath?: string
|
||||
fromZip?: boolean
|
||||
tempDir?: string
|
||||
reason?: string
|
||||
candidates?: string[]
|
||||
choices?: ClassificationChoice[]
|
||||
confirmedType?: string
|
||||
selected?: boolean
|
||||
importName?: string
|
||||
installState?: BatchDropInstallState
|
||||
installError?: string
|
||||
symlink?: boolean
|
||||
gameVersion?: string | null
|
||||
loader?: string | null
|
||||
loaderVersion?: string | null
|
||||
gameDirOverride?: string | null
|
||||
}
|
||||
|
||||
export interface BatchDropGroup {
|
||||
id: string
|
||||
/** Stable type key used by the UI to choose labels/options. */
|
||||
type: string
|
||||
items: BatchDropItem[]
|
||||
}
|
||||
|
||||
export interface BatchDropResult {
|
||||
id: string
|
||||
name: string
|
||||
status: BatchDropResultStatus
|
||||
message?: string
|
||||
}
|
||||
263
packages/ui/src/composables/use-global-drop.ts
Normal file
263
packages/ui/src/composables/use-global-drop.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import type { FileDropProvider, NativeFileDropEvent } from '#ui/providers/file-drop'
|
||||
import { injectFileDrop } from '#ui/providers/file-drop'
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
/**
|
||||
* Classification result returned by the Rust backend.
|
||||
*/
|
||||
export interface ClassificationResult {
|
||||
item_type:
|
||||
| 'launcher'
|
||||
| 'hmcl_launcher'
|
||||
| 'mod'
|
||||
| 'modpack'
|
||||
| 'litematic'
|
||||
| 'resource_pack'
|
||||
| 'shader_pack'
|
||||
| 'world_save'
|
||||
| 'shortcut_resolved'
|
||||
| 'multiple'
|
||||
| 'unknown'
|
||||
file_path?: string
|
||||
candidates?: string[]
|
||||
choices?: ClassificationChoice[]
|
||||
launcher_type?: string
|
||||
base_path?: string
|
||||
/** For ZIP sources, the virtual folder inside the archive where launcher
|
||||
* markers matched (e.g. `.minecraft`). Scan/import against
|
||||
* `<extracted-temp>/<inner_base>`. */
|
||||
innerBase?: string
|
||||
launcher_dir?: string
|
||||
data_dir?: string
|
||||
original?: string
|
||||
resolved_to?: ClassificationResult
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ClassificationChoice {
|
||||
itemType: string
|
||||
filePath?: string
|
||||
innerBase?: string
|
||||
candidates?: string[]
|
||||
}
|
||||
|
||||
export interface UseGlobalDropOptions {
|
||||
/**
|
||||
* Function that classifies a dropped file by its filesystem path.
|
||||
* Typically wraps `classifyDroppedItem` from `@/helpers/drop`.
|
||||
*/
|
||||
classifyFile: (path: string) => Promise<ClassificationResult>
|
||||
|
||||
/** Called when classification begins (before the loading bar). Receives the file name. */
|
||||
onClassifyStart?: (fileName: string) => void
|
||||
|
||||
/**
|
||||
* Called when a droppable item is successfully classified.
|
||||
* @param type - 'launcher' | 'content'
|
||||
* @param data - The (resolved) ClassificationResult
|
||||
*/
|
||||
onImportStart?: (type: string, data: ClassificationResult) => void
|
||||
|
||||
/** Called after processing ends (success or error). */
|
||||
onImportEnd?: () => void
|
||||
|
||||
/**
|
||||
* Called when the user drops multiple files. The consumer owns the rest of
|
||||
* the batch flow and must call `finishBatch()` when it is done.
|
||||
*/
|
||||
onBatchStart?: (paths: string[]) => void
|
||||
|
||||
/** Called when an error occurs (unknown type, too many files, etc.). */
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
export function useGlobalDrop(
|
||||
options: UseGlobalDropOptions,
|
||||
fileDropOverride?: FileDropProvider | null,
|
||||
) {
|
||||
const fileDrop = fileDropOverride ?? injectFileDrop(null)
|
||||
const loadingState = injectLoadingState(null)
|
||||
const debug = useDebugLogger('useGlobalDrop')
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const droppedFileName = ref<string | null>(null)
|
||||
|
||||
let nativeFileDropUnlisten: (() => void) | null = null
|
||||
let unmounted = false
|
||||
|
||||
/**
|
||||
* Walk the `shortcut_resolved` chain up to 3 levels.
|
||||
* If still a shortcut after depth 3, return the terminal node
|
||||
* (the caller will treat it as unknown).
|
||||
*/
|
||||
function resolveClassification(result: ClassificationResult, depth = 0): ClassificationResult {
|
||||
if (result.item_type === 'shortcut_resolved' && result.resolved_to && depth < 3) {
|
||||
debug('resolveClassification: shortcut chain', {
|
||||
depth,
|
||||
from: result.file_path,
|
||||
to: result.resolved_to.file_path,
|
||||
})
|
||||
return resolveClassification(result.resolved_to, depth + 1)
|
||||
}
|
||||
if (depth > 0)
|
||||
debug('resolveClassification: resolved at depth', {
|
||||
depth,
|
||||
item_type: result.item_type,
|
||||
file_path: result.file_path,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleNativeDrop(event: NativeFileDropEvent) {
|
||||
if (event.type === 'enter' || event.type === 'over') {
|
||||
isDragging.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'leave') {
|
||||
isDragging.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// ── type === 'drop' ──
|
||||
isDragging.value = false
|
||||
|
||||
const { paths } = event
|
||||
if (!paths?.length) {
|
||||
debug('handleNativeDrop: drop event with empty paths — skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Multiple files start a batch flow owned by the consumer.
|
||||
if (paths.length > 1) {
|
||||
console.log('[BatchDrop] native drop MULTI paths=', paths)
|
||||
if (options.onBatchStart) {
|
||||
isProcessing.value = true
|
||||
droppedFileName.value = null
|
||||
options.onBatchStart(paths)
|
||||
return
|
||||
}
|
||||
options.onError?.('multiple-files')
|
||||
return
|
||||
}
|
||||
|
||||
if (isProcessing.value) {
|
||||
debug('handleNativeDrop: drop while another file is still processing — skipping')
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
droppedFileName.value = paths[0].split(/[/\\]/).pop() ?? 'file'
|
||||
options.onClassifyStart?.(droppedFileName.value)
|
||||
|
||||
const loadToken = loadingState?.begin()
|
||||
|
||||
try {
|
||||
const raw = await options.classifyFile(paths[0])
|
||||
debug('classifyFile raw result', {
|
||||
item_type: raw.item_type,
|
||||
file_path: raw.file_path,
|
||||
launcher_type: raw.launcher_type,
|
||||
reason: raw.reason,
|
||||
})
|
||||
|
||||
const resolved = resolveClassification(raw)
|
||||
debug('resolveClassification final', {
|
||||
item_type: resolved.item_type,
|
||||
file_path: resolved.file_path,
|
||||
launcher_type: resolved.launcher_type,
|
||||
})
|
||||
|
||||
if (resolved.item_type === 'unknown') {
|
||||
debug('routing: unknown type — passing to onImportStart so the consumer can decide', {
|
||||
reason: resolved.reason,
|
||||
})
|
||||
options.onImportStart?.('unknown', resolved)
|
||||
return
|
||||
}
|
||||
|
||||
// If still a shortcut after max depth — treat as unknown
|
||||
if (resolved.item_type === 'shortcut_resolved') {
|
||||
debug('routing: shortcut exceeded max depth', { file_path: resolved.file_path })
|
||||
options.onError?.('shortcut-exceeded')
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.item_type === 'launcher' || resolved.item_type === 'hmcl_launcher') {
|
||||
debug('routing: launcher import', {
|
||||
launcher_type: resolved.launcher_type,
|
||||
base_path: resolved.base_path,
|
||||
data_dir: resolved.data_dir,
|
||||
})
|
||||
options.onImportStart?.('launcher', resolved)
|
||||
return
|
||||
}
|
||||
|
||||
// Content types: mod, litematic, resource_pack, shader_pack, world_save
|
||||
debug('routing: content import', { item_type: resolved.item_type })
|
||||
options.onImportStart?.('content', resolved)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
options.onError?.(message)
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
if (loadToken) loadingState?.end(loadToken)
|
||||
options.onImportEnd?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
if (!fileDrop) {
|
||||
debug('setup: fileDrop provider not available — native drops disabled')
|
||||
return
|
||||
}
|
||||
|
||||
let unlisten: () => void
|
||||
try {
|
||||
unlisten = await fileDrop.listenNativeFileDrop(handleNativeDrop)
|
||||
debug('setup: native file drop listener registered successfully')
|
||||
} catch (err) {
|
||||
debug('setup: failed to register native file drop listener', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (unmounted) {
|
||||
debug('setup: component unmounted before listener was ready, cleaning up')
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
|
||||
nativeFileDropUnlisten = unlisten
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void setup()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
isDragging.value = false
|
||||
isProcessing.value = false
|
||||
if (nativeFileDropUnlisten) {
|
||||
nativeFileDropUnlisten()
|
||||
nativeFileDropUnlisten = null
|
||||
}
|
||||
})
|
||||
|
||||
/** Ends the batch flow and clears the global processing flag. */
|
||||
function finishBatch() {
|
||||
isProcessing.value = false
|
||||
options.onImportEnd?.()
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
isProcessing,
|
||||
droppedFileName,
|
||||
finishBatch,
|
||||
}
|
||||
}
|
||||
42
packages/ui/src/composables/use-instance-context.ts
Normal file
42
packages/ui/src/composables/use-instance-context.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export interface InstanceContext {
|
||||
isInInstance: ComputedRef<boolean>
|
||||
instanceId: ComputedRef<string | null>
|
||||
currentPage: ComputedRef<string | null>
|
||||
}
|
||||
|
||||
export function useInstanceContext(): InstanceContext {
|
||||
const route = useRoute()
|
||||
|
||||
const instanceId = computed<string | null>(() => {
|
||||
const param = route.params.id
|
||||
if (param && typeof param === 'string') return param
|
||||
|
||||
const query = route.query.i
|
||||
if (query && typeof query === 'string') return query
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isInInstance = computed<boolean>(() => {
|
||||
// /instance/:id and all subroutes (Mods, Files, Worlds, Screenshots, Logs)
|
||||
if (route.path.startsWith('/instance/')) return true
|
||||
|
||||
// /browse/:type?i=:id
|
||||
if (route.path.startsWith('/browse/') && route.query.i) return true
|
||||
|
||||
// /project/:id?i=:id
|
||||
if (route.path.startsWith('/project/') && route.query.i) return true
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const currentPage = computed<string | null>(() => {
|
||||
const segments = route.path.split('/').filter(Boolean)
|
||||
return segments[0] ?? null
|
||||
})
|
||||
|
||||
return { isInInstance, instanceId, currentPage }
|
||||
}
|
||||
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal file
43
packages/ui/src/composables/use-loading-bar-token.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
import { injectLoadingState } from '#ui/providers/loading-state'
|
||||
|
||||
/**
|
||||
* Register a `LoadingBar` token for as long as `pending` is truthy.
|
||||
*
|
||||
* Use this when the component that owns the load is not the natural place
|
||||
* to mount a `<ReadyTransition>` (e.g. a page root with a complex v-if
|
||||
* cascade where wrapping the template is awkward). `<ReadyTransition>`
|
||||
* remains the preferred API when it fits.
|
||||
*
|
||||
* Safe to call without a provider mounted; becomes a no-op.
|
||||
*/
|
||||
export function useLoadingBarToken(pending: Ref<boolean>): void {
|
||||
const loadingState = injectLoadingState(null)
|
||||
if (!loadingState) return
|
||||
|
||||
let token: symbol | null = null
|
||||
|
||||
function release() {
|
||||
if (token) {
|
||||
loadingState.end(token)
|
||||
token = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
pending,
|
||||
(now) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (now && !token) {
|
||||
token = loadingState.begin()
|
||||
} else if (!now) {
|
||||
release()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(release)
|
||||
}
|
||||
61
packages/ui/src/composables/use-loading-state-core.ts
Normal file
61
packages/ui/src/composables/use-loading-state-core.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import type { LoadingStateProvider } from '#ui/providers/loading-state'
|
||||
|
||||
export interface LoadingStateCoreOptions {
|
||||
/** Initial value of the host kill-switch. Default: true. */
|
||||
barEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a token-based `LoadingStateProvider` implementation.
|
||||
*
|
||||
* Multiple `ReadyTransition` instances (or any caller) can hold tokens at the
|
||||
* same time; the bar stays visible while at least one is live. `end(token)`
|
||||
* is idempotent so a stale token release after unmount is harmless.
|
||||
*
|
||||
* SSR safe: timers and DOM access are deferred to component code; this core
|
||||
* is pure reactive state.
|
||||
*/
|
||||
export function createLoadingStateCore(opts: LoadingStateCoreOptions = {}): LoadingStateProvider {
|
||||
const tokens = shallowRef<Set<symbol>>(new Set())
|
||||
const barEnabled = ref(opts.barEnabled ?? true)
|
||||
const pending = computed(() => tokens.value.size > 0)
|
||||
|
||||
function begin(): symbol {
|
||||
const token = Symbol('loading-state-token')
|
||||
const next = new Set(tokens.value)
|
||||
next.add(token)
|
||||
tokens.value = next
|
||||
return token
|
||||
}
|
||||
|
||||
function end(token: symbol): void {
|
||||
if (!tokens.value.has(token)) return
|
||||
const next = new Set(tokens.value)
|
||||
next.delete(token)
|
||||
tokens.value = next
|
||||
}
|
||||
|
||||
function beginManual(durationMs = 500): void {
|
||||
const token = begin()
|
||||
if (typeof window === 'undefined') {
|
||||
end(token)
|
||||
return
|
||||
}
|
||||
window.setTimeout(() => end(token), durationMs)
|
||||
}
|
||||
|
||||
function setEnabled(enabled: boolean): void {
|
||||
barEnabled.value = enabled
|
||||
}
|
||||
|
||||
return {
|
||||
pending,
|
||||
barEnabled,
|
||||
begin,
|
||||
end,
|
||||
beginManual,
|
||||
setEnabled,
|
||||
}
|
||||
}
|
||||
24
packages/ui/src/composables/use-ready-state.ts
Normal file
24
packages/ui/src/composables/use-ready-state.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { DefaultError, UseQueryReturnType } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/** Subset of {@link UseQueryReturnType} passed to {@link useReadyState}. */
|
||||
export type ReadyStateQuery<TData, TError = DefaultError> = Pick<
|
||||
UseQueryReturnType<TData, TError>,
|
||||
'isLoading' | 'data'
|
||||
>
|
||||
|
||||
/**
|
||||
* Returns true while a query is loading for the FIRST time (no cached data yet).
|
||||
*
|
||||
* Excludes background refetches and refetch-on-window-focus by design — those
|
||||
* have `isLoading === false` once data exists in the cache, so `ReadyTransition`
|
||||
* stays open and the loading bar stays silent.
|
||||
*
|
||||
* Pair with `<ReadyTransition :pending="var which is useReadyState(query)" />`.
|
||||
*/
|
||||
export function useReadyState<TData, TError = DefaultError>(
|
||||
query: ReadyStateQuery<TData, TError>,
|
||||
): Readonly<Ref<boolean>> {
|
||||
return computed(() => query.isLoading.value && query.data.value === undefined)
|
||||
}
|
||||
134
packages/ui/src/composables/use-server-image.ts
Normal file
134
packages/ui/src/composables/use-server-image.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
|
||||
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
|
||||
|
||||
type UseServerImageOptions = {
|
||||
enabled?: ComputedRef<boolean> | boolean
|
||||
size?: number
|
||||
includeProjectFallback?: boolean
|
||||
}
|
||||
|
||||
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx.drawImage(img, 0, 0, size, size)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
URL.revokeObjectURL(img.src)
|
||||
resolve(dataURL)
|
||||
}
|
||||
img.src = URL.createObjectURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
function getStatusCode(error: unknown): number | undefined {
|
||||
const err = error as { statusCode?: number; response?: { status?: number } }
|
||||
return err.statusCode ?? err.response?.status
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return getStatusCode(error) === 404
|
||||
}
|
||||
|
||||
export function useServerImage(
|
||||
serverId: string,
|
||||
upstream: UpstreamRef,
|
||||
options: UseServerImageOptions = {},
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const localImage = ref<string | null | undefined>(undefined)
|
||||
const iconSize = options.size ?? 512
|
||||
const includeProjectFallback = options.includeProjectFallback ?? false
|
||||
|
||||
const queryKey = computed(
|
||||
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
|
||||
)
|
||||
|
||||
const isEnabled = computed(() => {
|
||||
const explicitEnabled =
|
||||
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
|
||||
return !!serverId && (explicitEnabled ?? true)
|
||||
})
|
||||
|
||||
const { data: remoteImage, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
if (!serverId) return null
|
||||
|
||||
try {
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFileWithAuth(
|
||||
fsAuth,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Server image fetch failed:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!includeProjectFallback || !upstream.value?.project_id) return null
|
||||
|
||||
try {
|
||||
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
|
||||
if (!project.icon_url) return null
|
||||
const response = await fetch(project.icon_url)
|
||||
if (!response.ok) return null
|
||||
const blob = await response.blob()
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
console.debug('Project icon fallback failed:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
enabled: isEnabled,
|
||||
})
|
||||
|
||||
const image = computed(() => {
|
||||
if (localImage.value === null) return undefined
|
||||
const remote = remoteImage.value
|
||||
if (remote === null) return undefined
|
||||
return localImage.value ?? remote
|
||||
})
|
||||
|
||||
function setImage(nextImage: string | null | undefined) {
|
||||
localImage.value = nextImage
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
localImage.value = null
|
||||
}
|
||||
|
||||
function resetLocalOverride() {
|
||||
localImage.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
image,
|
||||
queryKey,
|
||||
refetch,
|
||||
setImage,
|
||||
clearImage,
|
||||
resetLocalOverride,
|
||||
}
|
||||
}
|
||||
214
packages/ui/src/composables/virtual-scroll.ts
Normal file
214
packages/ui/src/composables/virtual-scroll.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
export interface ScrollViewportOptions {
|
||||
onScroll?: () => void
|
||||
onResize?: () => void
|
||||
}
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
bufferSize?: number
|
||||
initialItemCount?: number
|
||||
enabled?: Ref<boolean>
|
||||
onNearEnd?: () => void
|
||||
nearEndThreshold?: number
|
||||
}
|
||||
|
||||
export function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
|
||||
if (!element) return window
|
||||
|
||||
let current: HTMLElement | null = element.parentElement
|
||||
while (current) {
|
||||
const { overflowY } = getComputedStyle(current)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export function getScrollTop(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.scrollY : container.scrollTop
|
||||
}
|
||||
|
||||
export function getViewportHeight(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.innerHeight : container.clientHeight
|
||||
}
|
||||
|
||||
export function useScrollViewport(options: ScrollViewportOptions = {}) {
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
const containerOffset = ref(0)
|
||||
const relativeScrollTop = computed(() => Math.max(0, scrollTop.value - containerOffset.value))
|
||||
|
||||
function updateContainerOffset() {
|
||||
const listEl = listContainer.value
|
||||
const container = scrollContainer.value
|
||||
if (!listEl || !container) return
|
||||
|
||||
if (container instanceof Window) {
|
||||
containerOffset.value = listEl.getBoundingClientRect().top + window.scrollY
|
||||
} else {
|
||||
const listRect = listEl.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
containerOffset.value = listRect.top - containerRect.top + container.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
function syncScrollState() {
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
scrollTop.value = getScrollTop(container)
|
||||
viewportHeight.value = getViewportHeight(container)
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
function resetScrollState() {
|
||||
scrollTop.value = 0
|
||||
viewportHeight.value = 0
|
||||
containerOffset.value = 0
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
options.onScroll?.()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
syncScrollState()
|
||||
options.onResize?.()
|
||||
}
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
syncScrollState()
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
if (!(container instanceof Window)) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncScrollState()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
resetScrollState,
|
||||
containerOffset,
|
||||
listContainer,
|
||||
relativeScrollTop,
|
||||
scrollContainer,
|
||||
scrollTop,
|
||||
syncScrollState,
|
||||
updateContainerOffset,
|
||||
viewportHeight,
|
||||
}
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
|
||||
const {
|
||||
itemHeight,
|
||||
bufferSize = 5,
|
||||
initialItemCount = 20,
|
||||
enabled,
|
||||
onNearEnd,
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
relativeScrollTop,
|
||||
resetScrollState,
|
||||
scrollContainer,
|
||||
syncScrollState,
|
||||
viewportHeight,
|
||||
} = useScrollViewport({
|
||||
onScroll: checkNearEnd,
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (enabled && !enabled.value) {
|
||||
return { start: 0, end: items.value.length }
|
||||
}
|
||||
|
||||
if (!listContainer.value || !scrollContainer.value) {
|
||||
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
|
||||
}
|
||||
|
||||
const start = Math.floor(relativeScrollTop.value / itemHeight)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
|
||||
const rangeSize = visibleCount + bufferSize * 2
|
||||
|
||||
const rangeStart = Math.min(
|
||||
Math.max(0, start - bufferSize),
|
||||
Math.max(0, items.value.length - rangeSize),
|
||||
)
|
||||
const rangeEnd = Math.min(items.value.length, rangeStart + rangeSize)
|
||||
|
||||
return {
|
||||
start: rangeStart,
|
||||
end: rangeEnd,
|
||||
}
|
||||
})
|
||||
|
||||
const visibleTop = computed(() =>
|
||||
enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight,
|
||||
)
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
items.value.slice(visibleRange.value.start, visibleRange.value.end),
|
||||
)
|
||||
|
||||
function checkNearEnd() {
|
||||
if (!onNearEnd || !listContainer.value || !viewportHeight.value) return
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom
|
||||
const remainingScroll = containerBottom - viewportHeight.value
|
||||
|
||||
if (remainingScroll < viewportHeight.value * nearEndThreshold) {
|
||||
onNearEnd()
|
||||
}
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
syncScrollState()
|
||||
})
|
||||
|
||||
return {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop,
|
||||
visibleItems,
|
||||
resetScrollState,
|
||||
syncScrollState,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user