Files
buildpath/frontend/components/rune/RuneTooltip.vue
Valentin Haudiquet 686962b678
All checks were successful
pipeline / lint-and-format (push) Successful in 4m18s
pipeline / build-and-push-images (push) Successful in 1m21s
feat/frontend: add tooltips for runes
2026-04-30 13:35:28 +02:00

367 lines
8.6 KiB
Vue

<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Perk } from '~/types/cdragon'
interface Props {
perk: Perk | null
show: boolean
x: number
y: number
}
const props = defineProps<Props>()
// Parse the long description to extract styled content
// Rune descriptions use HTML-like tags including special lol-uikit tags
interface TextSegment {
type: string
content: string
color?: string
}
function parseRuneDescription(description: string | undefined): TextSegment[] {
if (!description) return []
const segments: TextSegment[] = []
// Pattern to match various tag types:
// - Simple tags: <status>, <keyword>, </status>
// - Tags with attributes: <font color='#FF8000'>, <lol-uikit-tooltipped-keyword ...>
// - Self-closing or complex tags
const tagPattern = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g
let lastIndex = 0
const tagStack: Array<{ type: string; color?: string }> = []
let match
while ((match = tagPattern.exec(description)) !== null) {
// Add any text before this tag
if (match.index > lastIndex) {
const text = description.slice(lastIndex, match.index)
if (text) {
const currentStyle = tagStack.length > 0 ? tagStack[tagStack.length - 1] : { type: 'text' }
segments.push({
type: currentStyle.type,
content: text,
color: currentStyle.color
})
}
}
const [fullMatch, isClosing, tagName, attributes] = match
// Normalize tag name (handle lol-uikit-tooltipped-keyword -> keyword)
let normalizedTag = tagName
if (tagName === 'lol-uikit-tooltipped-keyword') {
normalizedTag = 'keyword'
} else if (tagName === 'lol-uikit-tooltipped-link') {
normalizedTag = 'keyword'
}
if (!isClosing) {
// Opening tag - extract color if present
const colorMatch = attributes.match(/color=['"]([^'"]+)['"]/i)
const color = colorMatch ? colorMatch[1] : undefined
tagStack.push({ type: normalizedTag, color })
} else {
// Closing tag
tagStack.pop()
}
lastIndex = match.index + fullMatch.length
}
// Add any remaining text
if (lastIndex < description.length) {
const text = description.slice(lastIndex)
if (text) {
const currentStyle = tagStack.length > 0 ? tagStack[tagStack.length - 1] : { type: 'text' }
segments.push({
type: currentStyle.type,
content: text,
color: currentStyle.color
})
}
}
return segments
}
// Get CSS class for text segment type
function getSegmentClass(segment: TextSegment): string {
const classMap: Record<string, string> = {
text: '',
highlight: 'stat-highlight',
passive: 'tag-passive',
active: 'tag-active',
keyword: 'tag-keyword',
keywordMajor: 'tag-keyword-major',
keywordStealth: 'tag-keyword-stealth',
status: 'tag-status',
speed: 'tag-speed',
scaleMana: 'tag-scaling',
scaleHealth: 'tag-scaling',
scaleAP: 'tag-scaling',
scaleAD: 'tag-scaling',
scaleArmor: 'tag-scaling',
scaleMR: 'tag-scaling',
scaleLevel: 'tag-scaling',
scaleBonusHealth: 'tag-scaling',
scaleBonusMana: 'tag-scaling',
scaleMaxHealth: 'tag-scaling',
spellName: 'tag-spellname',
unique: 'tag-unique',
rarityMythic: 'tag-rarity-mythic',
rarityLegendary: 'tag-rarity-legendary',
rarityGeneric: 'tag-rarity-generic',
magicDamage: 'tag-magic-damage',
physicalDamage: 'tag-physical-damage',
trueDamage: 'tag-true-damage',
healing: 'tag-healing',
shield: 'tag-shield',
attention: 'stat-highlight',
onHit: 'tag-onhit',
color: ''
}
return classMap[segment.type] || ''
}
// Render text segments to HTML
function renderSegments(segments: TextSegment[]): string {
return segments
.map(segment => {
const cssClass = getSegmentClass(segment)
// If segment has a color, use it directly (handles <font color='...'> tags)
if (segment.color) {
return `<span style="color: ${segment.color}">${segment.content}</span>`
}
if (cssClass) {
return `<span class="${cssClass}">${segment.content}</span>`
}
return segment.content
})
.join('')
}
// Parsed description
const parsedLongDesc = computed<TextSegment[]>(() => {
return parseRuneDescription(props.perk?.longDesc)
})
const hasLongDesc = computed(() => {
return parsedLongDesc.value.length > 0
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show && perk"
class="rune-tooltip"
:style="{
left: x + 'px',
top: y + 'px'
}"
@mouseenter.stop
>
<!-- Header -->
<div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(perk.iconPath)" />
<div class="tooltip-title">
<h3>{{ perk.name || 'Unknown Rune' }}</h3>
</div>
</div>
<!-- Long Description (detailed) -->
<div v-if="hasLongDesc" class="tooltip-long-desc">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderSegments(parsedLongDesc)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.rune-tooltip {
position: fixed;
z-index: 1000;
background: var(--tooltip-bg);
border: 1px solid var(--tooltip-border);
border-radius: 8px;
padding: 12px;
max-width: 320px;
min-width: 250px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none;
font-family: 'Inter', sans-serif;
}
/* Header */
.tooltip-header {
display: flex;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
margin-bottom: 10px;
}
.tooltip-icon {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
}
.tooltip-title {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.tooltip-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--tooltip-text);
line-height: 1.2;
}
/* Long Description */
.tooltip-long-desc {
font-size: 0.8rem;
color: var(--tooltip-text-dim);
line-height: 1.5;
}
/* Text segment styles */
.tooltip-long-desc :deep(.stat-highlight) {
color: var(--tooltip-highlight);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-passive) {
color: var(--tooltip-effect-passive);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-active) {
color: var(--tooltip-effect-active);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-keyword) {
color: var(--tooltip-keyword);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-keyword-major) {
color: var(--tooltip-keyword-major);
font-weight: 700;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-keyword-stealth) {
color: var(--tooltip-keyword-stealth);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-status) {
color: var(--tooltip-status);
font-weight: 500;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-speed),
.tooltip-long-desc :deep(.tag-scaling) {
color: var(--tooltip-scaling);
font-style: italic;
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-healing) {
color: var(--tooltip-healing);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-shield) {
color: var(--tooltip-shield);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-magic-damage) {
color: var(--tooltip-magic-damage);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-physical-damage) {
color: var(--tooltip-physical-damage);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-true-damage) {
color: var(--tooltip-true-damage);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.2);
color: var(--tooltip-onhit);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-spellname) {
color: var(--tooltip-spellname);
font-weight: 600;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-unique) {
color: var(--tooltip-effect-unique);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.tooltip-long-desc :deep(.tag-rarity-mythic) {
color: var(--tooltip-effect-mythic);
font-weight: 700;
font-size: 0.85rem;
}
.tooltip-long-desc :deep(.tag-rarity-legendary) {
color: var(--tooltip-effect-legendary);
font-weight: 600;
font-size: 0.85rem;
}
.tooltip-long-desc :deep(.tag-rarity-generic) {
color: var(--tooltip-effect-epic);
font-weight: 500;
font-size: 0.85rem;
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>