-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
bab0cfb
commit 1ad82e2
Showing
4 changed files
with
499 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,391 @@ | ||
<script setup lang="ts"> | ||
import CalcField from '@/components/CalcField.vue' | ||
import { reactive, ref, watch } from 'vue' | ||
import { CdxButton, CdxSelect, type MenuItemData, CdxTable, CdxIcon } from '@wikimedia/codex' | ||
import { useI18n } from 'vue-i18n' | ||
import { colorMap, colorRgbMap } from '@/utils/color/java' | ||
import type { Color } from '@/utils/color' | ||
import { | ||
cdxIconDownTriangle, | ||
cdxIconTableAddRowAfter, | ||
cdxIconTrash, | ||
cdxIconUpTriangle, | ||
} from '@wikimedia/codex-icons' | ||
const props = defineProps<{ icon: 'banner' | 'shield' }>() | ||
const { t } = useI18n() | ||
interface Pattern { | ||
id: number | ||
name: keyof typeof patternName | ||
color: Color | ||
} | ||
const patternId: (keyof typeof patternName)[] = [ | ||
'stripe_bottom', | ||
'stripe_top', | ||
'stripe_left', | ||
'stripe_right', | ||
'stripe_center', | ||
'stripe_middle', | ||
'stripe_downright', | ||
'stripe_downleft', | ||
'small_stripes', | ||
'cross', | ||
'straight_cross', | ||
'diagonal_left', | ||
'diagonal_right', | ||
'diagonal_up_left', | ||
'diagonal_up_right', | ||
'half_vertical', | ||
'half_vertical_right', | ||
'half_horizontal', | ||
'half_horizontal_bottom', | ||
'square_bottom_left', | ||
'square_bottom_right', | ||
'square_top_left', | ||
'square_top_right', | ||
'triangle_bottom', | ||
'triangle_top', | ||
'triangles_bottom', | ||
'triangles_top', | ||
'circle', | ||
'rhombus', | ||
'border', | ||
'curly_border', | ||
'bricks', | ||
'gradient', | ||
'gradient_up', | ||
'creeper', | ||
'skull', | ||
'flower', | ||
'mojang', | ||
'globe', | ||
'piglin', | ||
'flow', | ||
'guster', | ||
] | ||
const patternName = { | ||
stripe_bottom: 'Base', | ||
stripe_top: 'Chief', | ||
stripe_left: 'Pale Dexter', | ||
stripe_right: 'Pale Sinister', | ||
stripe_center: 'Pale', | ||
stripe_middle: 'Fess', | ||
stripe_downright: 'Bend', | ||
stripe_downleft: 'Bend Sinister', | ||
small_stripes: 'Paly', | ||
cross: 'Saltire', | ||
straight_cross: 'Cross', | ||
diagonal_left: 'Per Bend Sinister', | ||
diagonal_right: 'Per Bend', | ||
diagonal_up_left: 'Per Bend Inverted', | ||
diagonal_up_right: 'Per Bend Sinister Inverted', | ||
half_vertical: 'Per Pale', | ||
half_vertical_right: 'Per Pale Inverted', | ||
half_horizontal: 'Per Fess', | ||
half_horizontal_bottom: 'Per Fess Inverted', | ||
square_bottom_left: 'Base Dexter Canton', | ||
square_bottom_right: 'Base Sinister Canton', | ||
square_top_left: 'Chief Dexter Canton', | ||
square_top_right: 'Chief Sinister Canton', | ||
triangle_bottom: 'Chevron', | ||
triangle_top: 'Inverted Chevron', | ||
triangles_bottom: 'Base Indented', | ||
triangles_top: 'Chief Indented', | ||
circle: 'Roundel', | ||
rhombus: 'Lozenge', | ||
border: 'Bordure', | ||
curly_border: 'Bordure Indented', | ||
bricks: 'Field Masoned', | ||
gradient: 'Gradient', | ||
gradient_up: 'Base Gradient', | ||
creeper: 'Creeper Charge', | ||
skull: 'Skull Charge', | ||
flower: 'Flower Charge', | ||
mojang: 'Thing', | ||
globe: 'Globe', | ||
piglin: 'Snout', | ||
flow: 'Flow', | ||
guster: 'Guster', | ||
} | ||
const activePatterns = reactive<Pattern[]>([ | ||
{ | ||
id: 0, | ||
name: 'mojang', | ||
color: 'black', | ||
}, | ||
]) | ||
function updatePattern(index: number, pattern: keyof typeof patternName) { | ||
activePatterns[index].name = pattern | ||
} | ||
function updatePatternIds() { | ||
activePatterns.forEach((pattern, index) => { | ||
pattern.id = index | ||
}) | ||
} | ||
function newLayer() { | ||
activePatterns.push({ | ||
id: activePatterns.length, | ||
name: 'mojang', | ||
color: 'black', | ||
}) | ||
} | ||
const patternMenuItems: MenuItemData[] = patternId.map((pattern) => ({ | ||
value: pattern, | ||
label: t(`banner.pattern.${pattern}`), | ||
thumbnail: { | ||
url: `https://minecraft.wiki/images/SlotSprite_${patternName[pattern].replace(/ /g, '_')}.png`, | ||
}, | ||
})) | ||
const colorMenuItems: MenuItemData[] = Object.entries(colorMap).map((color) => ({ | ||
value: color[0], | ||
label: t(`banner.color.${color[0]}`), | ||
icon: ` | ||
<rect width="20" height="20" fill="#${color[1].toString(16)}" stroke="#ffffff" stroke-width="2" /> | ||
`, | ||
})) | ||
function updateColor(index: number, color: Color) { | ||
activePatterns[index].color = color | ||
} | ||
const baseColor = ref<Color>('white') | ||
const canvasRef = ref<HTMLCanvasElement | null>(null) | ||
function loadImage(src: string) { | ||
return new Promise<HTMLImageElement>((resolve, reject) => { | ||
const img = new Image() | ||
img.crossOrigin = 'anonymous' | ||
img.addEventListener('load', () => resolve(img)) | ||
img.addEventListener('error', reject) | ||
img.src = src | ||
}) | ||
} | ||
async function promiseAllObject<T>(obj: Record<string, Promise<T>>) { | ||
const values = await Promise.all(Object.values(obj)) | ||
return Object.fromEntries(Object.keys(obj).map((key, i) => [key, values[i]])) | ||
} | ||
function imageToImageData(image: HTMLImageElement) { | ||
const context = Object.assign(document.createElement('canvas'), { | ||
width: 20, | ||
height: 40, | ||
}).getContext('2d') | ||
if (!context) throw new Error('Could not create canvas context') | ||
context.imageSmoothingEnabled = false | ||
context.drawImage(image, 1, 1, 20, 40, 0, 0, 20, 40) | ||
return context.getImageData(0, 0, 20, 40) | ||
} | ||
watch([activePatterns, baseColor, canvasRef], async ([patterns, color, canvas]) => { | ||
const baseColor = colorRgbMap[color] | ||
if (!canvas) return | ||
const ctx = canvas.getContext('2d', { | ||
willReadFrequently: true, | ||
}) | ||
if (!ctx) return | ||
ctx.clearRect(0, 0, canvas.width, canvas.height) | ||
const images = await promiseAllObject({ | ||
base: loadImage( | ||
'https://minecraft.wiki/images/Banner_base_(texture)_JE1_BE1.png?format=original', | ||
), | ||
...Object.fromEntries( | ||
patterns.map((pattern) => [ | ||
pattern.name, | ||
loadImage( | ||
`https://minecraft.wiki/images/Banner_${pattern.name}_(texture)_JE1_BE1.png?format=original`, | ||
), | ||
]), | ||
), | ||
}) | ||
ctx.drawImage(images.base, 1, 1, 20, 40, 0, 0, 20, 40) | ||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) | ||
const data = imageData.data | ||
for (let i = 0; i < data.length; i += 4) { | ||
data[i] = (data[i] * baseColor[0]) / 255 | ||
data[i + 1] = (data[i + 1] * baseColor[1]) / 255 | ||
data[i + 2] = (data[i + 2] * baseColor[2]) / 255 | ||
} | ||
for (const pattern of patterns) { | ||
const patternImage = images[pattern.name] | ||
const patternData = imageToImageData(patternImage) | ||
const patternColor = colorRgbMap[pattern.color] | ||
const patternDataArray = patternData.data | ||
for (let i = 0; i < patternDataArray.length; i += 4) { | ||
const red2 = (patternColor[0] / 255) * (patternDataArray[i] / 255) | ||
const green2 = (patternColor[1] / 255) * (patternDataArray[i + 1] / 255) | ||
const blue2 = (patternColor[2] / 255) * (patternDataArray[i + 2] / 255) | ||
const alpha2 = patternDataArray[i + 3] / 255 | ||
data[i] = (red2 * alpha2 + (data[i] / 255) * (1 - alpha2)) * 255 | ||
data[i + 1] = (green2 * alpha2 + (data[i + 1] / 255) * (1 - alpha2)) * 255 | ||
data[i + 2] = (blue2 * alpha2 + (data[i + 2] / 255) * (1 - alpha2)) * 255 | ||
} | ||
} | ||
ctx.putImageData(imageData, 0, 0) | ||
}) | ||
</script> | ||
<template> | ||
<CalcField> | ||
<template #heading>{{ t('banner.title', { type: t(`banner.icon.${props.icon}`) }) }}</template> | ||
<div class="flex flex-row flex-wrap items-stretch gap-3"> | ||
<canvas | ||
ref="canvasRef" | ||
width="20" | ||
height="40" | ||
class="w-[200px] h-[400px] pixel-image" | ||
></canvas> | ||
|
||
<div class="overflow-auto flex-1 max-h-[400px]"> | ||
<CdxTable | ||
class="min-h-full" | ||
:caption="t('banner.layers')" | ||
use-row-headers | ||
:data="activePatterns" | ||
:columns="[ | ||
{ id: 'id', label: t('banner.layer'), textAlign: 'number' }, | ||
{ id: 'name', label: t('banner.pattern') }, | ||
{ id: 'color', label: t('banner.color') }, | ||
{ id: 'actions', label: t('banner.actions') }, | ||
]" | ||
> | ||
<template #header> | ||
<CdxButton @click="newLayer" variant="primary"> | ||
<CdxIcon :icon="cdxIconTableAddRowAfter" /> | ||
{{ t('banner.new') }} | ||
</CdxButton> | ||
</template> | ||
|
||
<template #item-name="{ item, row }: { item: keyof typeof patternName; row: Pattern }"> | ||
<CdxSelect | ||
class="long-handle" | ||
:menu-items="patternMenuItems" | ||
@update:selected=" | ||
(selected: keyof typeof patternName) => updatePattern(row.id, selected) | ||
" | ||
:selected="item" | ||
> | ||
<template #menu-item="{ menuItem }"> | ||
<div class="flex items-center"> | ||
<img | ||
class="pixel-image -m-2" | ||
width="45" | ||
height="45" | ||
loading="lazy" | ||
:src="menuItem.thumbnail.url" | ||
/> | ||
<span>{{ menuItem.label }}</span> | ||
</div> | ||
</template> | ||
<template #label="{ selectedMenuItem }"> | ||
<div class="flex items-center"> | ||
<img | ||
class="pixel-image -m-2 -ml-3" | ||
width="40" | ||
height="40" | ||
loading="lazy" | ||
:src="selectedMenuItem.thumbnail.url" | ||
/> | ||
<span>{{ selectedMenuItem.label }}</span> | ||
</div> | ||
</template> | ||
</CdxSelect> | ||
</template> | ||
|
||
<template #item-color="{ item, row }: { item: Color; row: Pattern }"> | ||
<CdxSelect | ||
:menu-items="colorMenuItems" | ||
@update:selected="(selected: Color) => updateColor(row.id, selected)" | ||
:selected="item" | ||
/> | ||
</template> | ||
|
||
<template #item-actions="{ item, row }: { item: Color; row: Pattern }"> | ||
<div class="flex"> | ||
<div class="flex flex-col justify-evenly"> | ||
<CdxButton | ||
v-if="row.id !== 0" | ||
class="min-h-0" | ||
weight="quiet" | ||
:aria-label="t('banner.move_up')" | ||
@click=" | ||
() => { | ||
const index = row.id | ||
const temp = activePatterns[index] | ||
activePatterns[index] = activePatterns[index - 1] | ||
activePatterns[index - 1] = temp | ||
updatePatternIds() | ||
} | ||
" | ||
> | ||
<CdxIcon size="x-small" :icon="cdxIconUpTriangle" /> | ||
</CdxButton> | ||
<CdxButton | ||
v-if="row.id !== activePatterns.length - 1" | ||
class="min-h-0" | ||
weight="quiet" | ||
:aria-label="t('banner.move_down')" | ||
@click=" | ||
() => { | ||
const index = row.id | ||
const temp = activePatterns[index] | ||
activePatterns[index] = activePatterns[index + 1] | ||
activePatterns[index + 1] = temp | ||
updatePatternIds() | ||
} | ||
" | ||
> | ||
<CdxIcon size="x-small" :icon="cdxIconDownTriangle" /> | ||
</CdxButton> | ||
</div> | ||
|
||
<CdxButton | ||
weight="quiet" | ||
action="destructive" | ||
:aria-label="t('banner.remove')" | ||
@click=" | ||
() => { | ||
activePatterns.splice(row.id, 1) | ||
updatePatternIds() | ||
} | ||
" | ||
> | ||
<CdxIcon :icon="cdxIconTrash" /> | ||
</CdxButton> | ||
</div> | ||
</template> | ||
</CdxTable> | ||
</div> | ||
</div> | ||
|
||
<CdxSelect $selected="baseColor" class="w-[200px] mt-3" :menu-items="colorMenuItems" /> | ||
</CalcField> | ||
</template> | ||
<style lang="less"> | ||
.cdx-select-vue__handle { | ||
min-width: 175px; | ||
display: flex; | ||
align-items: center; | ||
height: 36px; | ||
.long-handle & { | ||
min-width: 230px; | ||
} | ||
} | ||
.cdx-table__table-wrapper { | ||
overflow: visible; | ||
} | ||
</style> |
Oops, something went wrong.