feat:移除了弹窗,服务器添加sls
This commit is contained in:
@ -0,0 +1,297 @@
|
||||
<!-- 由 S4 集成 -->
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types.d.ts'
|
||||
import {
|
||||
get_instance_worlds,
|
||||
isSingleplayerWorld,
|
||||
type SingleplayerWorld,
|
||||
sortWorlds,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
export type RecipeWorldInstallTarget = {
|
||||
instanceId: string
|
||||
worldPath: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [target: RecipeWorldInstallTarget]
|
||||
saveAs: []
|
||||
}>()
|
||||
|
||||
withDefaults(defineProps<{ showSaveAs?: boolean }>(), {
|
||||
showSaveAs: true,
|
||||
})
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const selectedInstance = ref<GameInstance | null>(null)
|
||||
const worlds = ref<SingleplayerWorld[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const worldError = ref('')
|
||||
const installingWorldPath = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.recipe-generator.instance-export.title',
|
||||
defaultMessage: 'Install datapack into world',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.lab.recipe-generator.instance-export.choose-instance',
|
||||
defaultMessage: 'Choose the instance that contains the world',
|
||||
},
|
||||
chooseWorld: {
|
||||
id: 'app.lab.recipe-generator.instance-export.choose-world',
|
||||
defaultMessage: 'Choose a singleplayer world',
|
||||
},
|
||||
back: {
|
||||
id: 'app.lab.recipe-generator.instance-export.back',
|
||||
defaultMessage: 'Back to instances',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.lab.recipe-generator.instance-export.no-instances',
|
||||
defaultMessage: 'No installed instances are available.',
|
||||
},
|
||||
noWorlds: {
|
||||
id: 'app.lab.recipe-generator.instance-export.no-worlds',
|
||||
defaultMessage: 'This instance has no singleplayer worlds yet.',
|
||||
},
|
||||
installWorld: {
|
||||
id: 'app.lab.recipe-generator.instance-export.install-world',
|
||||
defaultMessage: 'Install datapack into {name}',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.lab.recipe-generator.instance-export.last-played',
|
||||
defaultMessage: 'Played {ago}',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'app.lab.recipe-generator.instance-export.never-played',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
saveAs: {
|
||||
id: 'app.lab.recipe-generator.instance-export.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
})
|
||||
|
||||
async function show(instanceId?: string) {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
error.value = ''
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
loading.value = true
|
||||
modal.value?.show()
|
||||
try {
|
||||
const loaded = await list()
|
||||
instances.value = loaded
|
||||
.filter((instance) => instance.install_stage === 'installed')
|
||||
.sort((left, right) => {
|
||||
const lastPlayed =
|
||||
Number(new Date(right.last_played ?? 0)) - Number(new Date(left.last_played ?? 0))
|
||||
return lastPlayed || left.name.localeCompare(right.name, locale.value)
|
||||
})
|
||||
const initialInstance = instances.value.find((instance) => instance.id === instanceId)
|
||||
if (initialInstance) {
|
||||
await openInstance(initialInstance)
|
||||
}
|
||||
} catch (caught) {
|
||||
instances.value = []
|
||||
error.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openInstance(instance: GameInstance) {
|
||||
selectedInstance.value = instance
|
||||
worlds.value = []
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const loaded = await get_instance_worlds(instance.id)
|
||||
sortWorlds(loaded)
|
||||
worlds.value = loaded.filter(isSingleplayerWorld)
|
||||
} catch (caught) {
|
||||
worlds.value = []
|
||||
worldError.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToInstances() {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
}
|
||||
|
||||
async function installWorld(world: SingleplayerWorld) {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance || installingWorldPath.value) return
|
||||
installingWorldPath.value = world.path
|
||||
emit('select', { instanceId: instance.id, worldPath: world.path })
|
||||
modal.value?.hide()
|
||||
installingWorldPath.value = null
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
emit('saveAs')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(620px, calc(100vw - 2rem))"
|
||||
max-width="620px"
|
||||
scrollable
|
||||
max-content-height="min(38rem, 76vh)"
|
||||
actions-divider
|
||||
>
|
||||
<div class="flex min-h-[18rem] min-w-0 flex-col gap-4">
|
||||
<template v-if="!selectedInstance">
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.chooseInstance) }}</p>
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="instances.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="instance in instances" :key="instance.id" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
@click="openInstance(instance)"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ instance.name }}</strong>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ instance.game_version }} · {{ instance.loader }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button type="button" @click="backToInstances">
|
||||
<ChevronLeftIcon />{{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<strong class="min-w-0 truncate text-contrast">{{ selectedInstance.name }}</strong>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.chooseWorld) }}</p>
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="worldError"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ worldError }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="worlds.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noWorlds) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="world in worlds" :key="world.path" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="installingWorldPath !== null"
|
||||
:aria-label="formatMessage(messages.installWorld, { name: world.name })"
|
||||
@click="installWorld(world)"
|
||||
>
|
||||
<Avatar v-if="world.icon" class="size-10 shrink-0 rounded-lg" :src="world.icon" />
|
||||
<span
|
||||
v-else
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary"
|
||||
>
|
||||
<WorldIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ world.name }}</strong>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
world.last_played
|
||||
? formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||
})
|
||||
: formatMessage(messages.neverPlayed)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<SpinnerIcon
|
||||
v-if="installingWorldPath === world.path"
|
||||
class="size-5 shrink-0 animate-spin text-secondary"
|
||||
/>
|
||||
<ChevronRightIcon v-else class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div v-if="showSaveAs" class="flex justify-end">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="saveAs">
|
||||
<SaveIcon />{{ formatMessage(messages.saveAs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, StyledInput, useVIntl, useVirtualScroll } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
import RecipeSlotDragLayer from './RecipeSlotDragLayer.vue'
|
||||
|
||||
export type PaletteEntry = {
|
||||
key: string
|
||||
name: string
|
||||
id: string
|
||||
display: SlotDisplay
|
||||
value: SlotValue
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: PaletteEntry[]
|
||||
atlas: TextureAtlas
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [value: SlotValue]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const search = ref('')
|
||||
const debouncedSearch = ref('')
|
||||
let searchTimer: ReturnType<typeof window.setTimeout> | undefined
|
||||
let lastPickKey = ''
|
||||
let lastPickAt = 0
|
||||
const draggingKey = ref('')
|
||||
let suppressClicksUntil = 0
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
const isTauriRuntime = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const PALETTE_MIN_COLUMN_WIDTH = 72
|
||||
const PALETTE_ROW_GAP = 6.4
|
||||
const PALETTE_ROW_HEIGHT = 64 + PALETTE_ROW_GAP
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: (moved: boolean) => void,
|
||||
) => void
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.items.search-placeholder',
|
||||
defaultMessage: 'Search items',
|
||||
},
|
||||
loading: { id: 'app.lab.recipe-generator.items.loading', defaultMessage: 'Loading items' },
|
||||
empty: {
|
||||
id: 'app.lab.recipe-generator.items.empty',
|
||||
defaultMessage: 'No items match your search.',
|
||||
},
|
||||
addItem: { id: 'app.lab.recipe-generator.items.add', defaultMessage: 'Add to recipe' },
|
||||
})
|
||||
|
||||
watch(search, (value) => {
|
||||
if (searchTimer) window.clearTimeout(searchTimer)
|
||||
searchTimer = window.setTimeout(() => {
|
||||
debouncedSearch.value = value
|
||||
}, 120)
|
||||
})
|
||||
|
||||
const fuse = computed(
|
||||
() =>
|
||||
new Fuse(props.entries, {
|
||||
keys: ['name', 'id'],
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const visibleEntries = computed(() => {
|
||||
const query = debouncedSearch.value.trim()
|
||||
if (!query) return props.entries
|
||||
return fuse.value.search(query).map((result) => result.item)
|
||||
})
|
||||
|
||||
const gridScroller = ref<HTMLElement | null>(null)
|
||||
const columns = ref(1)
|
||||
let gridObserver: ResizeObserver | null = null
|
||||
|
||||
function updateColumns() {
|
||||
const element = gridScroller.value
|
||||
if (!element) return
|
||||
const availableWidth = Math.max(0, element.clientWidth - 6)
|
||||
columns.value = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + PALETTE_ROW_GAP) / (PALETTE_MIN_COLUMN_WIDTH + PALETTE_ROW_GAP)),
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
gridScroller,
|
||||
(element) => {
|
||||
gridObserver?.disconnect()
|
||||
gridObserver = null
|
||||
if (!element) return
|
||||
updateColumns()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
gridObserver = new ResizeObserver(updateColumns)
|
||||
gridObserver.observe(element)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridObserver?.disconnect()
|
||||
gridObserver = null
|
||||
})
|
||||
|
||||
const paletteRows = computed<PaletteEntry[][]>(() => {
|
||||
const count = Math.max(1, columns.value)
|
||||
const rows: PaletteEntry[][] = []
|
||||
for (let index = 0; index < visibleEntries.value.length; index += count) {
|
||||
rows.push(visibleEntries.value.slice(index, index + count))
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleTop,
|
||||
visibleItems: visibleRows,
|
||||
} = useVirtualScroll(paletteRows, {
|
||||
itemHeight: PALETTE_ROW_HEIGHT,
|
||||
bufferSize: 4,
|
||||
})
|
||||
|
||||
function rowKey(row: PaletteEntry[]) {
|
||||
return row[0]?.key ?? row.length
|
||||
}
|
||||
|
||||
function pick(value: SlotValue) {
|
||||
const key = JSON.stringify(value)
|
||||
const now = Date.now()
|
||||
if (key === lastPickKey && now - lastPickAt < 300) return
|
||||
lastPickKey = key
|
||||
lastPickAt = now
|
||||
emit('pick', value)
|
||||
}
|
||||
|
||||
function pickFromClick(event: MouseEvent, value: SlotValue) {
|
||||
if (event.detail > 1 || Date.now() < suppressClicksUntil) return
|
||||
pick(value)
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, entry: PaletteEntry) {
|
||||
if (isTauriRuntime) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
const payload = JSON.stringify(entry.value)
|
||||
draggingKey.value = entry.key
|
||||
dataTransfer.effectAllowed = 'copy'
|
||||
dataTransfer.setData(RECIPE_SLOT_MIME_TYPE, payload)
|
||||
dataTransfer.setData('text/plain', payload)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingKey.value = ''
|
||||
suppressClicksUntil = Date.now() + 350
|
||||
}
|
||||
|
||||
function startPointerDrag(event: PointerEvent, entry: PaletteEntry, startDrag: StartDrag) {
|
||||
if (!isTauriRuntime || event.button !== 0) return
|
||||
draggingKey.value = entry.key
|
||||
startDrag(event, entry.value, entry.display, props.atlas, (moved) => {
|
||||
draggingKey.value = ''
|
||||
if (moved) suppressClicksUntil = Date.now() + 350
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeSlotDragLayer v-slot="{ startDrag }">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2 p-3">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
clearable
|
||||
class="w-full shrink-0"
|
||||
/>
|
||||
<div v-if="loading" class="flex min-h-24 items-center justify-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!visibleEntries.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else ref="gridScroller" class="recipe-palette-grid">
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="recipe-palette-virtual"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 grid grid-auto-rows-[4rem] gap-[0.4rem] p-[0.1rem_0.25rem_0.25rem_0.1rem]"
|
||||
:style="{
|
||||
top: `${visibleTop}px`,
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}"
|
||||
>
|
||||
<template v-for="row in visibleRows" :key="rowKey(row)">
|
||||
<button
|
||||
v-for="entry in row"
|
||||
:key="entry.key"
|
||||
type="button"
|
||||
:draggable="!isTauriRuntime"
|
||||
class="recipe-palette-item"
|
||||
:class="{ 'is-dragging': draggingKey === entry.key }"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
:title="`${formatMessage(messages.addItem)}: ${entry.name}`"
|
||||
:aria-label="`${formatMessage(messages.addItem)}: ${entry.name}`"
|
||||
@click="pickFromClick($event, entry.value)"
|
||||
@pointerdown="startPointerDrag($event, entry, startDrag)"
|
||||
@dragstart="onDragStart($event, entry)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RecipeItemIcon
|
||||
:display="entry.display"
|
||||
:atlas="atlas"
|
||||
:size="34"
|
||||
:show-count="false"
|
||||
/>
|
||||
<span class="recipe-palette-name">{{ entry.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RecipeSlotDragLayer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-palette-grid {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.recipe-palette-virtual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-palette-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0.2rem 0.1rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: grab;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.recipe-palette-item:hover,
|
||||
.recipe-palette-item:focus-visible {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-surface-3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-palette-item:active {
|
||||
cursor: grabbing;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.recipe-palette-item.is-dragging {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-palette-name {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, ExternalIcon, ImageIcon, InfoIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = useTemplateRef<InstanceType<typeof ModalWrapper>>('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.recipe-generator.copyright.title',
|
||||
defaultMessage: 'Copyright and attribution',
|
||||
},
|
||||
tagsHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.tags-heading',
|
||||
defaultMessage: 'Vanilla tags',
|
||||
},
|
||||
tagsBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.tags-body',
|
||||
defaultMessage:
|
||||
'Expanded item tags are sourced from the crafting generator by destruc7i0n, provided under the MIT License.',
|
||||
},
|
||||
viewTags: {
|
||||
id: 'app.lab.recipe-generator.copyright.view-tags',
|
||||
defaultMessage: 'View vanilla tags',
|
||||
},
|
||||
texturesHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.textures-heading',
|
||||
defaultMessage: 'Item textures and metadata',
|
||||
},
|
||||
texturesBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.textures-body',
|
||||
defaultMessage:
|
||||
'Item identifiers, readable names, and icons are sourced from minecraft-textures by destruc7i0n, provided under the GNU General Public License v3.',
|
||||
},
|
||||
viewTextures: {
|
||||
id: 'app.lab.recipe-generator.copyright.view-textures',
|
||||
defaultMessage: 'View minecraft-textures',
|
||||
},
|
||||
disclaimerHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.disclaimer-heading',
|
||||
defaultMessage: 'Unofficial tool',
|
||||
},
|
||||
disclaimerBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.disclaimer-body',
|
||||
defaultMessage:
|
||||
'Minecraft assets are Copyright Mojang Studios / Microsoft and are used only to identify compatible content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (event?: MouseEvent) => modal.value?.show(event),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="copyright-notice">
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<CodeIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.tagsHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.tagsBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://github.com/destruc7i0n/crafting')">
|
||||
{{ formatMessage(messages.viewTags) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<ImageIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.texturesHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.texturesBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://github.com/destruc7i0n/minecraft-textures')">
|
||||
{{ formatMessage(messages.viewTextures) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.disclaimerHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.disclaimerBody) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copyright-notice {
|
||||
display: flex;
|
||||
width: min(34rem, calc(100vw - 3rem));
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notice-section:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.notice-section > svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notice-section h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notice-section p {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { countFontSize, countInset, countShadow } from '@/lab/recipe-generator/count-display'
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
display: SlotDisplay | null
|
||||
atlas: TextureAtlas
|
||||
size?: number
|
||||
showCount?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 32,
|
||||
showCount: true,
|
||||
},
|
||||
)
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const region = computed(() => {
|
||||
const texture = props.display?.texture
|
||||
return texture ? props.atlas.layout[texture] : undefined
|
||||
})
|
||||
|
||||
const contentSize = computed(() => Math.max(1, props.size - 2))
|
||||
|
||||
const countStyle = computed(() => {
|
||||
if (!props.display?.count || props.display.count <= 1) return undefined
|
||||
const inset = countInset(props.size)
|
||||
return {
|
||||
fontSize: `${countFontSize(props.size)}px`,
|
||||
right: `${inset}px`,
|
||||
bottom: `${inset}px`,
|
||||
textShadow: countShadow(props.size),
|
||||
}
|
||||
})
|
||||
|
||||
const imageCache = new Map<string, Promise<HTMLImageElement>>()
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
const cached = imageCache.get(url)
|
||||
if (cached) return cached
|
||||
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error(`Unable to load image: ${url}`))
|
||||
image.src = url
|
||||
})
|
||||
imageCache.set(url, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
let drawToken = 0
|
||||
|
||||
async function drawIcon() {
|
||||
const canvas = canvasRef.value
|
||||
const display = props.display
|
||||
if (!canvas || !display?.texture) return
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
const token = ++drawToken
|
||||
const size = contentSize.value
|
||||
if (canvas.width !== size) canvas.width = size
|
||||
if (canvas.height !== size) canvas.height = size
|
||||
context.clearRect(0, 0, size, size)
|
||||
context.imageSmoothingEnabled = false
|
||||
|
||||
try {
|
||||
const currentRegion = region.value
|
||||
const image = await loadImage(currentRegion ? props.atlas.url : display.texture)
|
||||
if (token !== drawToken || canvas !== canvasRef.value) return
|
||||
const sourceX = currentRegion?.[0] ?? 0
|
||||
const sourceY = currentRegion?.[1] ?? 0
|
||||
const sourceWidth = currentRegion?.[2] ?? image.naturalWidth
|
||||
const sourceHeight = currentRegion?.[3] ?? image.naturalHeight
|
||||
if (!sourceWidth || !sourceHeight) return
|
||||
const scale = Math.min(size / sourceWidth, size / sourceHeight)
|
||||
const drawWidth = Math.max(1, Math.round(sourceWidth * scale))
|
||||
const drawHeight = Math.max(1, Math.round(sourceHeight * scale))
|
||||
const drawX = Math.round((size - drawWidth) / 2)
|
||||
const drawY = Math.round((size - drawHeight) / 2)
|
||||
context.drawImage(
|
||||
image,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
drawX,
|
||||
drawY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
)
|
||||
} catch {
|
||||
// Missing textures render as an empty slot.
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[canvasRef, () => props.display?.texture, () => props.atlas.url, () => props.size],
|
||||
drawIcon,
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-block flex-none overflow-hidden border border-surface-5 box-border"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
:title="display?.label"
|
||||
>
|
||||
<canvas
|
||||
v-if="display?.texture"
|
||||
ref="canvasRef"
|
||||
class="recipe-item-canvas"
|
||||
:width="contentSize"
|
||||
:height="contentSize"
|
||||
></canvas>
|
||||
<span v-else class="recipe-item-empty" aria-hidden="true"></span>
|
||||
<span
|
||||
v-if="showCount && display?.count && display.count > 1"
|
||||
class="absolute text-white font-bold leading-none pointer-events-none"
|
||||
:style="countStyle"
|
||||
>{{ display.count }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-item-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.recipe-item-empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-conic-gradient(var(--surface-5) 0% 25%, var(--surface-3) 0% 50%);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -0,0 +1,200 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useResultCountWheel } from '@/composables/lab/useResultCountWheel'
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { RecipeSlot, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
recipeSlot: RecipeSlot
|
||||
value: SlotValue | undefined
|
||||
display: SlotDisplay | null
|
||||
atlas: TextureAtlas
|
||||
count?: number
|
||||
countEditable?: boolean
|
||||
result?: boolean
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
countEditable: false,
|
||||
result: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
dropValue: [value: SlotValue]
|
||||
updateCount: [count: number]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const dragDepth = ref(0)
|
||||
|
||||
const messages = defineMessages({
|
||||
emptySlot: { id: 'app.lab.recipe-generator.slots.empty', defaultMessage: 'Empty slot' },
|
||||
})
|
||||
|
||||
const dragActive = computed(() => dragDepth.value > 0)
|
||||
const slotLabel = computed(() => `${formatMessage(messages.emptySlot)} ${props.recipeSlot}`)
|
||||
const { hint: wheelHint, onWheel: onResultWheel } = useResultCountWheel({
|
||||
getSlot: () => (props.countEditable ? props.recipeSlot : null),
|
||||
getValue: () => props.value,
|
||||
getCount: () => props.count ?? 1,
|
||||
setCount: (count) => emit('updateCount', count),
|
||||
})
|
||||
|
||||
function hasRecipePayload(event: DragEvent) {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types) return false
|
||||
const typeList = Array.from(types)
|
||||
return (
|
||||
!typeList.includes('Files') &&
|
||||
(typeList.includes(RECIPE_SLOT_MIME_TYPE) || typeList.includes('text/plain'))
|
||||
)
|
||||
}
|
||||
|
||||
function isSlotValue(value: unknown): value is SlotValue {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const candidate = value as { kind?: unknown; id?: unknown; uid?: unknown }
|
||||
switch (candidate.kind) {
|
||||
case 'item':
|
||||
case 'vanilla_tag':
|
||||
return typeof candidate.id === 'string'
|
||||
case 'custom_item':
|
||||
case 'custom_tag':
|
||||
return typeof candidate.uid === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseSlotValue(raw: string): SlotValue | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return isSlotValue(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function onSlotDropEvent(event: Event) {
|
||||
const detail = (event as CustomEvent<{ value?: unknown }>).detail
|
||||
if (!detail || !isSlotValue(detail.value)) return
|
||||
emit('dropValue', detail.value)
|
||||
}
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
if (!hasRecipePayload(event)) return
|
||||
dragDepth.value += 1
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
if (!hasRecipePayload(event)) {
|
||||
dataTransfer.dropEffect = 'none'
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
if (!hasRecipePayload(event)) return
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragDepth.value = 0
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer || !hasRecipePayload(event)) return
|
||||
const raw = dataTransfer.getData(RECIPE_SLOT_MIME_TYPE) || dataTransfer.getData('text/plain')
|
||||
const value = parseSlotValue(raw)
|
||||
if (!value) return
|
||||
event.preventDefault()
|
||||
emit('dropValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 flex-col items-center gap-[0.35rem]"
|
||||
:class="{ 'is-drag-target': dragActive }"
|
||||
:data-recipe-slot="recipeSlot"
|
||||
@axolotl-recipe-slot-drop="onSlotDropEvent"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<button
|
||||
v-tooltip="wheelHint"
|
||||
type="button"
|
||||
class="recipe-slot-button"
|
||||
:class="{ 'recipe-result-button': result }"
|
||||
:title="wheelHint ?? slotLabel"
|
||||
:aria-label="wheelHint ?? slotLabel"
|
||||
@click="emit('clear')"
|
||||
@wheel="onResultWheel(recipeSlot, $event)"
|
||||
>
|
||||
<RecipeItemIcon :display="display" :atlas="atlas" :size="48" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-button {
|
||||
display: flex;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
padding: 0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 rgb(0 0 0 / 20%),
|
||||
inset -1px -1px 0 rgb(255 255 255 / 10%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.recipe-slot-button:hover {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
|
||||
.recipe-slot-button:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.is-drag-target .recipe-slot-button {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-highlight);
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.recipe-result-button {
|
||||
border-color: color-mix(in srgb, var(--color-brand) 55%, var(--color-surface-5));
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.recipe-slot-button {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,187 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
|
||||
type DragFinish = (moved: boolean) => void
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: DragFinish,
|
||||
) => void
|
||||
|
||||
type ActiveDrag = {
|
||||
value: SlotValue
|
||||
display: SlotDisplay
|
||||
atlas: TextureAtlas
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
onFinish?: DragFinish
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { startDrag: StartDrag }) => unknown
|
||||
}>()
|
||||
|
||||
const drag = ref<ActiveDrag | null>(null)
|
||||
const ghostRef = ref<HTMLElement | null>(null)
|
||||
let hoveredSlot: HTMLElement | null = null
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let moved = false
|
||||
let frame: number | null = null
|
||||
let lastHitTestAt = 0
|
||||
|
||||
function startDrag(
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: DragFinish,
|
||||
) {
|
||||
if (event.button !== 0 || drag.value) return
|
||||
drag.value = {
|
||||
value,
|
||||
display,
|
||||
atlas,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
onFinish,
|
||||
}
|
||||
pointerX = event.clientX
|
||||
pointerY = event.clientY
|
||||
moved = false
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
try {
|
||||
target?.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// Pointer capture is optional; window listeners still track mouse drags.
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', handlePointerEnd)
|
||||
window.addEventListener('pointercancel', handlePointerCancel)
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
pointerX = event.clientX
|
||||
pointerY = event.clientY
|
||||
if (!moved) {
|
||||
moved = Math.abs(pointerX - current.startX) > 4 || Math.abs(pointerY - current.startY) > 4
|
||||
}
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
if (frame !== null) return
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null
|
||||
updateGhostPosition()
|
||||
updateHoveredSlot()
|
||||
})
|
||||
}
|
||||
|
||||
function updateGhostPosition() {
|
||||
const ghost = ghostRef.value
|
||||
if (!ghost) return
|
||||
ghost.style.transform = `translate3d(${pointerX}px, ${pointerY}px, 0) translate(-50%, -50%)`
|
||||
}
|
||||
|
||||
function updateHoveredSlot() {
|
||||
const now = performance.now()
|
||||
if (now - lastHitTestAt < 32) return
|
||||
lastHitTestAt = now
|
||||
const target = document.elementFromPoint(pointerX, pointerY)
|
||||
const next = target?.closest<HTMLElement>('[data-recipe-slot]') ?? null
|
||||
if (next === hoveredSlot) return
|
||||
hoveredSlot?.classList.remove('is-drag-target')
|
||||
next?.classList.add('is-drag-target')
|
||||
hoveredSlot = next
|
||||
}
|
||||
|
||||
function handlePointerEnd(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
cleanup()
|
||||
if (moved) {
|
||||
event.preventDefault()
|
||||
const target = document.elementFromPoint(pointerX, pointerY)
|
||||
const slot = target?.closest<HTMLElement>('[data-recipe-slot]')
|
||||
if (slot) {
|
||||
slot.dispatchEvent(
|
||||
new CustomEvent('axolotl-recipe-slot-drop', {
|
||||
detail: { value: current.value },
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
current.onFinish?.(moved)
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function handlePointerCancel(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
cleanup()
|
||||
current.onFinish?.(false)
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (frame !== null) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerEnd)
|
||||
window.removeEventListener('pointercancel', handlePointerCancel)
|
||||
hoveredSlot?.classList.remove('is-drag-target')
|
||||
hoveredSlot = null
|
||||
lastHitTestAt = 0
|
||||
}
|
||||
|
||||
onUnmounted(cleanup)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot :start-drag="startDrag" />
|
||||
<Teleport to="body">
|
||||
<div v-if="drag" ref="ghostRef" class="recipe-slot-drag-ghost">
|
||||
<RecipeItemIcon :display="drag.display" :atlas="drag.atlas" :size="48" :show-count="false" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-drag-ghost {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 30%);
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,178 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getSlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { RecipeSlot, RecipeSlotContext, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeSlotCell from './RecipeSlotCell.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
slots: readonly RecipeSlot[]
|
||||
values: Partial<Record<RecipeSlot, SlotValue>>
|
||||
ctx: RecipeSlotContext | null
|
||||
atlas: TextureAtlas
|
||||
variant?: 'crafting' | 'row'
|
||||
twoByTwo?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'row',
|
||||
twoByTwo: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSlot: [slot: RecipeSlot, value: SlotValue | undefined]
|
||||
updateCount: [slot: RecipeSlot, count: number]
|
||||
}>()
|
||||
|
||||
const TWO_BY_TWO_DISABLED_SLOTS = new Set<RecipeSlot>([
|
||||
'crafting.3',
|
||||
'crafting.6',
|
||||
'crafting.7',
|
||||
'crafting.8',
|
||||
'crafting.9',
|
||||
])
|
||||
|
||||
const gridSlots = computed(() => {
|
||||
const slots =
|
||||
props.variant === 'crafting'
|
||||
? props.slots.filter((slot) => slot !== 'crafting.result')
|
||||
: props.slots
|
||||
if (props.variant === 'crafting' && props.twoByTwo) {
|
||||
return slots.filter((slot) => !TWO_BY_TWO_DISABLED_SLOTS.has(slot))
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
function slotDisplay(slot: RecipeSlot) {
|
||||
return props.ctx ? getSlotDisplay(props.values[slot], props.ctx) : null
|
||||
}
|
||||
|
||||
function countFor(slot: RecipeSlot) {
|
||||
const value = props.values[slot]
|
||||
return value && (value.kind === 'item' || value.kind === 'custom_item') && value.count
|
||||
? value.count
|
||||
: 1
|
||||
}
|
||||
|
||||
function canEditCount(slot: RecipeSlot) {
|
||||
return slot === 'crafting.result' || slot === 'stonecutter.result'
|
||||
}
|
||||
|
||||
function updateSlot(slot: RecipeSlot, value: SlotValue | undefined) {
|
||||
emit('updateSlot', slot, value)
|
||||
}
|
||||
|
||||
function updateCount(slot: RecipeSlot, count: number) {
|
||||
emit('updateCount', slot, count)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recipe-slot-grid" :class="`is-${variant}`">
|
||||
<div v-if="variant === 'crafting'" class="recipe-crafting-editor">
|
||||
<div
|
||||
class="recipe-crafting-grid grid grid-cols-[repeat(3,3.75rem)] grid-auto-rows-[3.75rem] gap-[0.45rem] border border-surface-5 rounded-[var(--radius-md)] bg-surface-1 p-[0.6rem]"
|
||||
:class="{ 'is-two-by-two': twoByTwo }"
|
||||
>
|
||||
<RecipeSlotCell
|
||||
v-for="slot in gridSlots"
|
||||
:key="slot"
|
||||
:recipe-slot="slot"
|
||||
:value="values[slot]"
|
||||
:display="slotDisplay(slot)"
|
||||
:atlas="atlas"
|
||||
:count="countFor(slot)"
|
||||
:count-editable="canEditCount(slot)"
|
||||
@clear="updateSlot(slot, undefined)"
|
||||
@drop-value="updateSlot(slot, $event)"
|
||||
@update-count="updateCount(slot, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slots.includes('crafting.result')" class="recipe-result-column">
|
||||
<RecipeSlotCell
|
||||
:recipe-slot="'crafting.result'"
|
||||
:value="values['crafting.result']"
|
||||
:display="slotDisplay('crafting.result')"
|
||||
:atlas="atlas"
|
||||
:count="countFor('crafting.result')"
|
||||
count-editable
|
||||
result
|
||||
@clear="updateSlot('crafting.result', undefined)"
|
||||
@drop-value="updateSlot('crafting.result', $event)"
|
||||
@update-count="updateCount('crafting.result', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="recipe-slot-row">
|
||||
<RecipeSlotCell
|
||||
v-for="slot in slots"
|
||||
:key="slot"
|
||||
:recipe-slot="slot"
|
||||
:value="values[slot]"
|
||||
:display="slotDisplay(slot)"
|
||||
:atlas="atlas"
|
||||
:count="countFor(slot)"
|
||||
:count-editable="canEditCount(slot)"
|
||||
:result="slot === 'crafting.result' || slot === 'stonecutter.result'"
|
||||
@clear="updateSlot(slot, undefined)"
|
||||
@drop-value="updateSlot(slot, $event)"
|
||||
@update-count="updateCount(slot, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-grid {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipe-crafting-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.recipe-crafting-grid.is-two-by-two {
|
||||
grid-template-columns: repeat(2, 3.75rem);
|
||||
grid-auto-rows: 3.75rem;
|
||||
}
|
||||
|
||||
.recipe-result-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 1.5rem;
|
||||
border-left: 1px solid var(--color-surface-5);
|
||||
}
|
||||
|
||||
.recipe-slot-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.recipe-crafting-editor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-result-column {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.recipe-slot-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,495 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { defineMessages, StyledInput, useVIntl, useVirtualScroll } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getSlotDisplay, type SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { CustomTag, RecipeSlotContext, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
import RecipeSlotDragLayer from './RecipeSlotDragLayer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
vanillaTags: Record<string, string[]>
|
||||
customTags: CustomTag[]
|
||||
ctx: RecipeSlotContext
|
||||
atlas: TextureAtlas
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [value: SlotValue]
|
||||
addCustomTag: [tag: CustomTag]
|
||||
updateCustomTag: [tag: CustomTag]
|
||||
deleteCustomTag: [uid: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const tab = ref<'vanilla' | 'custom'>('vanilla')
|
||||
const search = ref('')
|
||||
const newTagId = ref('')
|
||||
const valueDrafts = ref<Record<string, string>>({})
|
||||
let lastPickKey = ''
|
||||
let lastPickAt = 0
|
||||
const draggingTagUid = ref('')
|
||||
let suppressClicksUntil = 0
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
const isTauriRuntime = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const TAG_ROW_HEIGHT = 44.8
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: (moved: boolean) => void,
|
||||
) => void
|
||||
|
||||
const messages = defineMessages({
|
||||
vanillaTab: { id: 'app.lab.recipe-generator.tags.vanilla', defaultMessage: 'Vanilla tags' },
|
||||
customTab: { id: 'app.lab.recipe-generator.tags.custom', defaultMessage: 'Custom tags' },
|
||||
searchPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.search-placeholder',
|
||||
defaultMessage: 'Search tags',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.lab.recipe-generator.tags.empty',
|
||||
defaultMessage: 'No tags match your search.',
|
||||
},
|
||||
addTag: { id: 'app.lab.recipe-generator.tags.add', defaultMessage: 'Add tag' },
|
||||
tagIdPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.id-placeholder',
|
||||
defaultMessage: 'namespace:tag_id',
|
||||
},
|
||||
tagValuesPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.values-placeholder',
|
||||
defaultMessage: 'One item or #tag per line',
|
||||
},
|
||||
deleteTag: { id: 'app.lab.recipe-generator.tags.delete', defaultMessage: 'Delete tag' },
|
||||
useTag: { id: 'app.lab.recipe-generator.tags.use', defaultMessage: 'Use in recipe' },
|
||||
noCustomTags: {
|
||||
id: 'app.lab.recipe-generator.tags.no-custom',
|
||||
defaultMessage: 'No custom tags yet.',
|
||||
},
|
||||
})
|
||||
|
||||
const vanillaList = computed(() => Object.keys(props.vanillaTags).sort())
|
||||
const fuse = computed(() => new Fuse(vanillaList.value, { threshold: 0.4, ignoreLocation: true }))
|
||||
const visibleVanillaTags = computed(() => {
|
||||
const query = search.value.trim()
|
||||
if (!query) return vanillaList.value
|
||||
return fuse.value.search(query).map((result) => result.item)
|
||||
})
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleTop,
|
||||
visibleItems: visibleVanillaRows,
|
||||
} = useVirtualScroll(visibleVanillaTags, {
|
||||
itemHeight: TAG_ROW_HEIGHT,
|
||||
bufferSize: 8,
|
||||
})
|
||||
|
||||
function vanillaDisplay(tagId: string) {
|
||||
return getSlotDisplay({ kind: 'vanilla_tag', id: tagId }, props.ctx)
|
||||
}
|
||||
|
||||
function pickTag(value: SlotValue) {
|
||||
const key = JSON.stringify(value)
|
||||
const now = Date.now()
|
||||
if (key === lastPickKey && now - lastPickAt < 300) return
|
||||
lastPickKey = key
|
||||
lastPickAt = now
|
||||
emit('pick', value)
|
||||
}
|
||||
|
||||
function pickFromClick(event: MouseEvent, value: SlotValue) {
|
||||
if (event.detail > 1 || Date.now() < suppressClicksUntil) return
|
||||
pickTag(value)
|
||||
}
|
||||
|
||||
function onTagDragStart(event: DragEvent, value: SlotValue) {
|
||||
if (isTauriRuntime) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
const payload = JSON.stringify(value)
|
||||
dataTransfer.effectAllowed = 'copy'
|
||||
dataTransfer.setData(RECIPE_SLOT_MIME_TYPE, payload)
|
||||
dataTransfer.setData('text/plain', payload)
|
||||
}
|
||||
|
||||
function onCustomTagDragStart(event: DragEvent, tag: CustomTag) {
|
||||
draggingTagUid.value = tag.uid
|
||||
onTagDragStart(event, { kind: 'custom_tag', uid: tag.uid })
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingTagUid.value = ''
|
||||
suppressClicksUntil = Date.now() + 350
|
||||
}
|
||||
|
||||
function startPointerDrag(
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
startDrag: StartDrag,
|
||||
dragKey?: string,
|
||||
) {
|
||||
if (!isTauriRuntime || event.button !== 0) return
|
||||
if (dragKey) draggingTagUid.value = dragKey
|
||||
startDrag(event, value, display, props.atlas, (moved) => {
|
||||
draggingTagUid.value = ''
|
||||
if (moved) suppressClicksUntil = Date.now() + 350
|
||||
})
|
||||
}
|
||||
|
||||
function customTagDisplay(tag: CustomTag): SlotDisplay {
|
||||
return getSlotDisplay({ kind: 'custom_tag', uid: tag.uid }, props.ctx)
|
||||
}
|
||||
|
||||
function addCustomTag() {
|
||||
const id = newTagId.value.trim()
|
||||
if (!id) return
|
||||
const tag: CustomTag = {
|
||||
uid: crypto.randomUUID(),
|
||||
id,
|
||||
values: [],
|
||||
}
|
||||
emit('addCustomTag', tag)
|
||||
valueDrafts.value[tag.uid] = ''
|
||||
newTagId.value = ''
|
||||
}
|
||||
|
||||
function commitValues(tag: CustomTag) {
|
||||
const draft = valueDrafts.value[tag.uid] ?? ''
|
||||
const values = draft
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) =>
|
||||
line.startsWith('#')
|
||||
? { type: 'tag' as const, id: line.slice(1) }
|
||||
: { type: 'item' as const, id: line },
|
||||
)
|
||||
emit('updateCustomTag', { ...tag, values })
|
||||
}
|
||||
|
||||
function draftText(tag: CustomTag) {
|
||||
return tag.values.map((entry) => (entry.type === 'tag' ? `#${entry.id}` : entry.id)).join('\n')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeSlotDragLayer v-slot="{ startDrag }">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2 p-3">
|
||||
<div
|
||||
class="recipe-tag-tabs flex gap-1 border border-surface-5 rounded-[var(--radius-sm)] bg-surface-3 p-[0.2rem]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'vanilla'"
|
||||
:class="{ active: tab === 'vanilla' }"
|
||||
@click="tab = 'vanilla'"
|
||||
>
|
||||
{{ formatMessage(messages.vanillaTab) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'custom'"
|
||||
:class="{ active: tab === 'custom' }"
|
||||
@click="tab = 'custom'"
|
||||
>
|
||||
{{ formatMessage(messages.customTab) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'vanilla'">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
clearable
|
||||
class="w-full shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-if="!visibleVanillaTags.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else class="recipe-tag-scroll">
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="recipe-tag-virtual"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="recipe-tag-window" :style="{ top: `${visibleTop}px` }">
|
||||
<button
|
||||
v-for="item in visibleVanillaRows"
|
||||
:key="item"
|
||||
type="button"
|
||||
:draggable="!isTauriRuntime"
|
||||
class="recipe-tag-row"
|
||||
:title="formatMessage(messages.useTag)"
|
||||
:aria-label="`${formatMessage(messages.useTag)}: ${item}`"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
@click="pickFromClick($event, { kind: 'vanilla_tag', id: item })"
|
||||
@pointerdown="
|
||||
startPointerDrag(
|
||||
$event,
|
||||
{ kind: 'vanilla_tag', id: item },
|
||||
vanillaDisplay(item),
|
||||
startDrag,
|
||||
)
|
||||
"
|
||||
@dragstart="onTagDragStart($event, { kind: 'vanilla_tag', id: item })"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RecipeItemIcon
|
||||
:display="vanillaDisplay(item)"
|
||||
:atlas="atlas"
|
||||
:size="26"
|
||||
:show-count="false"
|
||||
/>
|
||||
<span>{{ item }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
v-model="newTagId"
|
||||
:placeholder="formatMessage(messages.tagIdPlaceholder)"
|
||||
class="min-w-0 flex-1"
|
||||
@keydown.enter.prevent="addCustomTag"
|
||||
/>
|
||||
<button type="button" class="recipe-add-button" @click="addCustomTag">
|
||||
{{ formatMessage(messages.addTag) }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!customTags.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noCustomTags) }}
|
||||
</div>
|
||||
<div v-else class="recipe-custom-tag-list">
|
||||
<div
|
||||
v-for="tag in customTags"
|
||||
:key="tag.uid"
|
||||
class="recipe-custom-tag"
|
||||
:class="{ 'is-dragging': draggingTagUid === tag.uid }"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
:model-value="tag.id"
|
||||
size="small"
|
||||
class="min-w-0 flex-1"
|
||||
@update:model-value="emit('updateCustomTag', { ...tag, id: String($event) })"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="recipe-delete-button"
|
||||
:title="formatMessage(messages.deleteTag)"
|
||||
:aria-label="formatMessage(messages.deleteTag)"
|
||||
@click="emit('deleteCustomTag', tag.uid)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="recipe-add-button"
|
||||
:draggable="!isTauriRuntime"
|
||||
:title="formatMessage(messages.useTag)"
|
||||
:aria-label="formatMessage(messages.useTag)"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
@click="pickFromClick($event, { kind: 'custom_tag', uid: tag.uid })"
|
||||
@pointerdown="
|
||||
startPointerDrag(
|
||||
$event,
|
||||
{ kind: 'custom_tag', uid: tag.uid },
|
||||
customTagDisplay(tag),
|
||||
startDrag,
|
||||
tag.uid,
|
||||
)
|
||||
"
|
||||
@dragstart="onCustomTagDragStart($event, tag)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
:value="valueDrafts[tag.uid] ?? draftText(tag)"
|
||||
:placeholder="formatMessage(messages.tagValuesPlaceholder)"
|
||||
rows="2"
|
||||
class="recipe-tag-values w-full resize-y border border-surface-5 rounded-[var(--radius-sm)] bg-surface-2 p-[0.4rem] text-contrast font-mono text-xs leading-[1.4] outline-none"
|
||||
@input="valueDrafts[tag.uid] = ($event.target as HTMLTextAreaElement).value"
|
||||
@blur="commitValues(tag)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</RecipeSlotDragLayer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-tag-tabs button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 1px);
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-tag-tabs button.active {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.recipe-tag-scroll {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.recipe-tag-virtual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-tag-window {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.25rem 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.recipe-tag-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.recipe-tag-row[draggable='true'],
|
||||
.recipe-add-button[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.recipe-tag-row:hover,
|
||||
.recipe-tag-row:focus-visible {
|
||||
border-color: var(--color-brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-tag-row span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-custom-tag-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.1rem 0.25rem 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.recipe-custom-tag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-3);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.recipe-tag-row:active,
|
||||
.recipe-add-button:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.recipe-custom-tag.is-dragging {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-tag-values:focus {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-add-button,
|
||||
.recipe-delete-button {
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0 0.6rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recipe-add-button:hover {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-delete-button:hover {
|
||||
border-color: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.recipe-add-button svg,
|
||||
.recipe-delete-button svg {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/切石机.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/切石机.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 969 B |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/合成.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/合成.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/熔炼.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/熔炼.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/篝火.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/篝火.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/锻造.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/锻造.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user