feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB