1258 lines
38 KiB
Vue
1258 lines
38 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ArrowDownIcon,
|
|
ArrowUpIcon,
|
|
AsteriskIcon,
|
|
BoldIcon,
|
|
ClipboardCopyIcon,
|
|
DownloadIcon,
|
|
ItalicIcon,
|
|
PlusIcon,
|
|
RefreshCwIcon,
|
|
StrikethroughIcon,
|
|
TrashIcon,
|
|
UnderlineIcon,
|
|
UploadIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Accordion,
|
|
ButtonStyled,
|
|
defineMessages,
|
|
DropdownSelect,
|
|
injectNotificationManager,
|
|
StyledInput,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { open, save } from '@tauri-apps/plugin-dialog'
|
|
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
|
import {
|
|
type Component,
|
|
computed,
|
|
onMounted,
|
|
onUnmounted,
|
|
ref,
|
|
shallowRef,
|
|
useTemplateRef,
|
|
watch,
|
|
} from 'vue'
|
|
|
|
import minecraftPreviewFont from '@/assets/lab/Minecraft_AE.ttf'
|
|
import minecraftPreviewBackground from '@/assets/lab/minecraft-preview.png'
|
|
import {
|
|
type GradientPreset,
|
|
loadGradientTextState,
|
|
parseGradientPresets,
|
|
saveGradientTextState,
|
|
serializeGradientPresets,
|
|
} from '@/lab/gradient-text/gradient-storage'
|
|
import {
|
|
buildGradientCharacters,
|
|
DEFAULT_GRADIENT_COLORS,
|
|
generateGradientOutput,
|
|
getMinecraftTextShadow,
|
|
type GradientFormatAdapter,
|
|
gradientFormatAdapters,
|
|
type GradientFormatId,
|
|
type GradientTextDocument,
|
|
type GradientTextRun,
|
|
normalizeGradientDocument,
|
|
normalizeHexColor,
|
|
parseGradientColors,
|
|
randomGradientColor,
|
|
TEXT_FORMATS,
|
|
type TextFormat,
|
|
} from '@/lab/gradient-text/gradient-text'
|
|
|
|
const { addNotification, handleError } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
const editor = useTemplateRef<HTMLDivElement>('editor')
|
|
const newColorPicker = useTemplateRef<HTMLInputElement>('newColorPicker')
|
|
const activeTextFormats = ref<TextFormat[]>([])
|
|
const editorSelection = shallowRef<Range | null>(null)
|
|
const obfuscationFrame = ref(0)
|
|
const initialState = loadGradientTextState()
|
|
const textDocument = ref<GradientTextDocument>(initialState.document)
|
|
const colors = ref([...initialState.colors])
|
|
const colorInputValues = ref([...initialState.colors])
|
|
const adapterId = ref<GradientFormatId>(initialState.adapterId)
|
|
const vanillaCharacter = ref<'&' | '§'>(initialState.vanillaCharacter)
|
|
const simplifyGradients = ref(initialState.simplifyGradients)
|
|
const presets = ref<GradientPreset[]>(initialState.presets)
|
|
const importValue = ref('')
|
|
const importError = ref('')
|
|
const presetName = ref('')
|
|
let obfuscationTimer: ReturnType<typeof window.setInterval> | undefined
|
|
|
|
const OBFUSCATED_PREVIEW_GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
|
|
|
|
const messages = defineMessages({
|
|
title: { id: 'app.lab.gradient-text.title', defaultMessage: 'Gradient text generator' },
|
|
inputTitle: { id: 'app.lab.gradient-text.input.title', defaultMessage: 'Text' },
|
|
inputPlaceholder: {
|
|
id: 'app.lab.gradient-text.input.placeholder',
|
|
defaultMessage: 'Type your Minecraft text here',
|
|
},
|
|
colorsTitle: { id: 'app.lab.gradient-text.colors.title', defaultMessage: 'Colors' },
|
|
colorStops: { id: 'app.lab.gradient-text.colors.stops', defaultMessage: '{count} color stops' },
|
|
addColor: { id: 'app.lab.gradient-text.colors.add', defaultMessage: 'Add color' },
|
|
randomizeColors: {
|
|
id: 'app.lab.gradient-text.colors.randomize',
|
|
defaultMessage: 'Randomize colors',
|
|
},
|
|
removeColor: { id: 'app.lab.gradient-text.colors.remove', defaultMessage: 'Remove color' },
|
|
moveColorUp: { id: 'app.lab.gradient-text.colors.move-up', defaultMessage: 'Move color up' },
|
|
moveColorDown: {
|
|
id: 'app.lab.gradient-text.colors.move-down',
|
|
defaultMessage: 'Move color down',
|
|
},
|
|
importColors: { id: 'app.lab.gradient-text.colors.import', defaultMessage: 'Import colors' },
|
|
importColorsPlaceholder: {
|
|
id: 'app.lab.gradient-text.colors.import-placeholder',
|
|
defaultMessage: 'Paste HEX, RGB, or a CSS gradient',
|
|
},
|
|
invalidColors: {
|
|
id: 'app.lab.gradient-text.colors.invalid',
|
|
defaultMessage: 'Use at least one valid color.',
|
|
},
|
|
formatTitle: { id: 'app.lab.gradient-text.format.title', defaultMessage: 'Output format' },
|
|
vanillaCharacter: {
|
|
id: 'app.lab.gradient-text.format.vanilla-character',
|
|
defaultMessage: 'Color character',
|
|
},
|
|
simplify: {
|
|
id: 'app.lab.gradient-text.format.simplify',
|
|
defaultMessage: 'Simplify gradient tags',
|
|
},
|
|
previewTitle: { id: 'app.lab.gradient-text.preview.title', defaultMessage: 'Preview' },
|
|
outputTitle: { id: 'app.lab.gradient-text.output.title', defaultMessage: 'Output' },
|
|
copy: { id: 'app.lab.gradient-text.output.copy', defaultMessage: 'Copy output' },
|
|
copied: { id: 'app.lab.gradient-text.output.copied', defaultMessage: 'Output copied' },
|
|
export: { id: 'app.lab.gradient-text.output.export', defaultMessage: 'Export output' },
|
|
presetsTitle: { id: 'app.lab.gradient-text.presets.title', defaultMessage: 'Presets' },
|
|
presetCount: { id: 'app.lab.gradient-text.presets.count', defaultMessage: '{count} saved' },
|
|
presetName: { id: 'app.lab.gradient-text.presets.name', defaultMessage: 'Preset name' },
|
|
savePreset: { id: 'app.lab.gradient-text.presets.save', defaultMessage: 'Save preset' },
|
|
applyPreset: { id: 'app.lab.gradient-text.presets.apply', defaultMessage: 'Apply preset' },
|
|
deletePreset: { id: 'app.lab.gradient-text.presets.delete', defaultMessage: 'Delete preset' },
|
|
importPresets: { id: 'app.lab.gradient-text.presets.import', defaultMessage: 'Import presets' },
|
|
exportPresets: { id: 'app.lab.gradient-text.presets.export', defaultMessage: 'Export presets' },
|
|
downloadTemplate: {
|
|
id: 'app.lab.gradient-text.presets.template',
|
|
defaultMessage: 'Download template',
|
|
},
|
|
presetsImported: {
|
|
id: 'app.lab.gradient-text.presets.imported',
|
|
defaultMessage: 'Imported {count} presets',
|
|
},
|
|
invalidPresetFile: {
|
|
id: 'app.lab.gradient-text.presets.invalid-file',
|
|
defaultMessage: 'This file does not contain valid presets.',
|
|
},
|
|
bold: { id: 'app.lab.gradient-text.format.bold', defaultMessage: 'Bold' },
|
|
italic: { id: 'app.lab.gradient-text.format.italic', defaultMessage: 'Italic' },
|
|
underlined: { id: 'app.lab.gradient-text.format.underlined', defaultMessage: 'Underline' },
|
|
strikethrough: {
|
|
id: 'app.lab.gradient-text.format.strikethrough',
|
|
defaultMessage: 'Strikethrough',
|
|
},
|
|
obfuscated: { id: 'app.lab.gradient-text.format.obfuscated', defaultMessage: 'Obfuscated' },
|
|
adapterVanilla: { id: 'app.lab.gradient-text.adapter.vanilla', defaultMessage: 'Vanilla' },
|
|
adapterVanillaCompatible: {
|
|
id: 'app.lab.gradient-text.adapter.vanilla-compatible',
|
|
defaultMessage: 'Vanilla compatible',
|
|
},
|
|
adapterStandard: { id: 'app.lab.gradient-text.adapter.standard', defaultMessage: 'Standard HEX' },
|
|
adapterCmi: { id: 'app.lab.gradient-text.adapter.cmi', defaultMessage: 'CMI' },
|
|
adapterMiniMessage: {
|
|
id: 'app.lab.gradient-text.adapter.minimessage',
|
|
defaultMessage: 'MiniMessage',
|
|
},
|
|
adapterMiniMessageGradient: {
|
|
id: 'app.lab.gradient-text.adapter.minimessage-gradient',
|
|
defaultMessage: 'MiniMessage gradient',
|
|
},
|
|
adapterMineDown: { id: 'app.lab.gradient-text.adapter.minedown', defaultMessage: 'MineDown' },
|
|
adapterSnbt: { id: 'app.lab.gradient-text.adapter.snbt', defaultMessage: 'Stringified NBT' },
|
|
adapterTrChat: { id: 'app.lab.gradient-text.adapter.trchat', defaultMessage: 'TrChat' },
|
|
adapterTabooLib: { id: 'app.lab.gradient-text.adapter.taboolib', defaultMessage: 'TabooLib' },
|
|
adapterTabooLibGradient: {
|
|
id: 'app.lab.gradient-text.adapter.taboolib-gradient',
|
|
defaultMessage: 'TabooLib gradient',
|
|
},
|
|
adapterRoseGarden: {
|
|
id: 'app.lab.gradient-text.adapter.rosegarden-gradient',
|
|
defaultMessage: 'RoseGarden gradient',
|
|
},
|
|
adapterChatColors: {
|
|
id: 'app.lab.gradient-text.adapter.chat-colors',
|
|
defaultMessage: 'Chat Colors',
|
|
},
|
|
adapterMotd: { id: 'app.lab.gradient-text.adapter.motd', defaultMessage: 'MOTD' },
|
|
adapterBbcode: { id: 'app.lab.gradient-text.adapter.bbcode', defaultMessage: 'BBCode' },
|
|
adapterJson: { id: 'app.lab.gradient-text.adapter.json', defaultMessage: 'JSON text component' },
|
|
adapterHtml: { id: 'app.lab.gradient-text.adapter.html', defaultMessage: 'HTML' },
|
|
adapterCsv: { id: 'app.lab.gradient-text.adapter.csv', defaultMessage: 'CSV' },
|
|
adapterTerraria: { id: 'app.lab.gradient-text.adapter.terraria', defaultMessage: 'Terraria' },
|
|
})
|
|
|
|
const adapterMessages: Record<GradientFormatId, (typeof messages)[keyof typeof messages]> = {
|
|
vanilla: messages.adapterVanilla,
|
|
'vanilla-compatible': messages.adapterVanillaCompatible,
|
|
standard: messages.adapterStandard,
|
|
cmi: messages.adapterCmi,
|
|
minimessage: messages.adapterMiniMessage,
|
|
'minimessage-gradient': messages.adapterMiniMessageGradient,
|
|
minedown: messages.adapterMineDown,
|
|
snbt: messages.adapterSnbt,
|
|
trchat: messages.adapterTrChat,
|
|
taboolib: messages.adapterTabooLib,
|
|
'taboolib-gradient': messages.adapterTabooLibGradient,
|
|
'rosegarden-gradient': messages.adapterRoseGarden,
|
|
'chat-colors': messages.adapterChatColors,
|
|
motd: messages.adapterMotd,
|
|
bbcode: messages.adapterBbcode,
|
|
json: messages.adapterJson,
|
|
html: messages.adapterHtml,
|
|
csv: messages.adapterCsv,
|
|
terraria: messages.adapterTerraria,
|
|
}
|
|
|
|
const textFormatIcons: Record<TextFormat, Component> = {
|
|
bold: BoldIcon,
|
|
italic: ItalicIcon,
|
|
underlined: UnderlineIcon,
|
|
strikethrough: StrikethroughIcon,
|
|
obfuscated: AsteriskIcon,
|
|
}
|
|
const currentAdapter = computed(
|
|
() =>
|
|
gradientFormatAdapters.find((adapter) => adapter.id === adapterId.value) ??
|
|
gradientFormatAdapters[0],
|
|
)
|
|
const output = computed(() =>
|
|
generateGradientOutput(textDocument.value, colors.value, adapterId.value, {
|
|
vanillaCharacter: vanillaCharacter.value,
|
|
simplifyGradients: simplifyGradients.value,
|
|
}),
|
|
)
|
|
const previewLines = computed(() => {
|
|
const lines: ReturnType<typeof buildGradientCharacters>[] = [[]]
|
|
for (const character of buildGradientCharacters(textDocument.value, colors.value)) {
|
|
if (character.newline) lines.push([])
|
|
else lines[lines.length - 1].push(character)
|
|
}
|
|
return lines
|
|
})
|
|
|
|
watch(
|
|
[textDocument, colors, adapterId, vanillaCharacter, simplifyGradients, presets],
|
|
() => {
|
|
saveGradientTextState({
|
|
version: 1,
|
|
document: textDocument.value,
|
|
colors: colors.value,
|
|
adapterId: adapterId.value,
|
|
vanillaCharacter: vanillaCharacter.value,
|
|
simplifyGradients: simplifyGradients.value,
|
|
presets: presets.value,
|
|
})
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
onMounted(() => {
|
|
writeDocumentToEditor(textDocument.value)
|
|
document.addEventListener('selectionchange', syncTextFormatState)
|
|
obfuscationTimer = window.setInterval(() => {
|
|
obfuscationFrame.value += 1
|
|
}, 120)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('selectionchange', syncTextFormatState)
|
|
if (obfuscationTimer) window.clearInterval(obfuscationTimer)
|
|
})
|
|
|
|
function onEditorInput() {
|
|
textDocument.value = readDocumentFromEditor()
|
|
syncTextFormatState()
|
|
}
|
|
|
|
function formatText(format: TextFormat) {
|
|
const range = getEditorRange()
|
|
if (!range) return
|
|
restoreEditorSelection(range)
|
|
|
|
if (format === 'obfuscated') {
|
|
toggleObfuscatedFormat(range)
|
|
return
|
|
}
|
|
|
|
const command =
|
|
format === 'bold'
|
|
? 'bold'
|
|
: format === 'italic'
|
|
? 'italic'
|
|
: format === 'underlined'
|
|
? 'underline'
|
|
: format === 'strikethrough'
|
|
? 'strikeThrough'
|
|
: null
|
|
if (command) document.execCommand(command)
|
|
onEditorInput()
|
|
}
|
|
|
|
function toggleObfuscatedFormat(range: Range) {
|
|
const existingMarker = getObfuscatedMarker(range.startContainer)
|
|
if (existingMarker && existingMarker === getObfuscatedMarker(range.endContainer)) {
|
|
existingMarker.replaceWith(...Array.from(existingMarker.childNodes))
|
|
onEditorInput()
|
|
return
|
|
}
|
|
|
|
const marker = document.createElement('span')
|
|
marker.dataset.gradientFormat = 'obfuscated'
|
|
marker.className = 'lab-editor-obfuscated'
|
|
|
|
if (range.collapsed) marker.textContent = '\u200B'
|
|
else marker.append(range.extractContents())
|
|
range.insertNode(marker)
|
|
|
|
const markerRange = document.createRange()
|
|
if (range.collapsed && marker.firstChild) markerRange.setStart(marker.firstChild, 1)
|
|
else markerRange.selectNodeContents(marker)
|
|
restoreEditorSelection(markerRange)
|
|
onEditorInput()
|
|
}
|
|
|
|
function getEditorRange(): Range | null {
|
|
const root = editor.value
|
|
const selection = window.getSelection()
|
|
if (root && selection?.rangeCount) {
|
|
const range = selection.getRangeAt(0)
|
|
if (root.contains(range.commonAncestorContainer)) return range.cloneRange()
|
|
}
|
|
return editorSelection.value?.cloneRange() ?? null
|
|
}
|
|
|
|
function restoreEditorSelection(range: Range) {
|
|
const selection = window.getSelection()
|
|
if (!selection) return
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
editor.value?.focus({ preventScroll: true })
|
|
}
|
|
|
|
function getObfuscatedMarker(node: Node): HTMLElement | null {
|
|
const root = editor.value
|
|
let current = node instanceof HTMLElement ? node : node.parentElement
|
|
while (current && current !== root) {
|
|
if (current.dataset.gradientFormat === 'obfuscated') return current
|
|
current = current.parentElement
|
|
}
|
|
return null
|
|
}
|
|
|
|
function syncTextFormatState() {
|
|
const root = editor.value
|
|
const selection = window.getSelection()
|
|
if (!root || !selection?.rangeCount) return
|
|
|
|
const range = selection.getRangeAt(0)
|
|
if (!root.contains(range.commonAncestorContainer)) return
|
|
editorSelection.value = range.cloneRange()
|
|
activeTextFormats.value = TEXT_FORMATS.filter((format) => {
|
|
if (format === 'obfuscated') {
|
|
const startMarker = getObfuscatedMarker(range.startContainer)
|
|
return Boolean(startMarker && startMarker === getObfuscatedMarker(range.endContainer))
|
|
}
|
|
|
|
const command =
|
|
format === 'bold'
|
|
? 'bold'
|
|
: format === 'italic'
|
|
? 'italic'
|
|
: format === 'underlined'
|
|
? 'underline'
|
|
: 'strikeThrough'
|
|
return document.queryCommandState(command)
|
|
})
|
|
}
|
|
|
|
function previewCharacter(character: string, formats: TextFormat[], index: number): string {
|
|
if (!formats.includes('obfuscated') || !character.trim()) return character
|
|
const position = (character.codePointAt(0) ?? 0) + index * 17 + obfuscationFrame.value
|
|
return OBFUSCATED_PREVIEW_GLYPHS[position % OBFUSCATED_PREVIEW_GLYPHS.length]
|
|
}
|
|
|
|
function onEditorPaste(event: ClipboardEvent) {
|
|
event.preventDefault()
|
|
const text = event.clipboardData?.getData('text/plain') ?? ''
|
|
document.execCommand('insertText', false, text)
|
|
onEditorInput()
|
|
}
|
|
|
|
function replaceColors(value: string[]) {
|
|
colors.value = [...value]
|
|
colorInputValues.value = [...value]
|
|
}
|
|
|
|
function updateColor(index: number, value: string) {
|
|
colorInputValues.value[index] = value
|
|
const color = normalizeHexColor(value)
|
|
if (color) colors.value[index] = color
|
|
}
|
|
|
|
function openNewColorPicker() {
|
|
newColorPicker.value?.click()
|
|
}
|
|
|
|
function addPickedColor(value: string) {
|
|
const color = normalizeHexColor(value)
|
|
if (color) replaceColors([...colors.value, color])
|
|
}
|
|
|
|
function randomizeColors() {
|
|
replaceColors(colors.value.map(() => randomGradientColor()))
|
|
}
|
|
|
|
function removeColor(index: number) {
|
|
if (colors.value.length <= 1) return
|
|
const nextColors = [...colors.value]
|
|
nextColors.splice(index, 1)
|
|
replaceColors(nextColors)
|
|
}
|
|
|
|
function moveColor(index: number, direction: -1 | 1) {
|
|
const destination = index + direction
|
|
if (destination < 0 || destination >= colors.value.length) return
|
|
const nextColors = [...colors.value]
|
|
const [color] = nextColors.splice(index, 1)
|
|
nextColors.splice(destination, 0, color)
|
|
replaceColors(nextColors)
|
|
}
|
|
|
|
function applyImportedColors() {
|
|
const parsed = parseGradientColors(importValue.value)
|
|
if (!parsed.length) {
|
|
importError.value = formatMessage(messages.invalidColors)
|
|
return
|
|
}
|
|
replaceColors(parsed)
|
|
importValue.value = ''
|
|
importError.value = ''
|
|
}
|
|
|
|
function savePreset() {
|
|
const name = presetName.value.trim()
|
|
if (!name) return
|
|
presets.value.push({
|
|
id: crypto.randomUUID(),
|
|
name: name.slice(0, 80),
|
|
colors: [...colors.value],
|
|
createdAt: new Date().toISOString(),
|
|
})
|
|
presetName.value = ''
|
|
}
|
|
|
|
function applyPreset(preset: GradientPreset) {
|
|
replaceColors(preset.colors)
|
|
}
|
|
|
|
function deletePreset(id: string) {
|
|
presets.value = presets.value.filter((preset) => preset.id !== id)
|
|
}
|
|
|
|
async function copyOutput() {
|
|
try {
|
|
await navigator.clipboard.writeText(output.value)
|
|
addNotification({ type: 'success', title: formatMessage(messages.copied) })
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function exportOutput() {
|
|
try {
|
|
const path = await save({
|
|
defaultPath: `minecraft-gradient.${currentAdapter.value.extension}`,
|
|
filters: [{ name: currentAdapter.value.label, extensions: [currentAdapter.value.extension] }],
|
|
})
|
|
if (path) await writeTextFile(path, output.value)
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function importPresets() {
|
|
try {
|
|
const path = await open({
|
|
multiple: false,
|
|
filters: [{ name: 'JSON', extensions: ['json'] }],
|
|
})
|
|
if (typeof path !== 'string') return
|
|
const imported = parseGradientPresets(JSON.parse(await readTextFile(path)))
|
|
if (!imported.length) {
|
|
addNotification({ type: 'error', title: formatMessage(messages.invalidPresetFile) })
|
|
return
|
|
}
|
|
presets.value = [...presets.value, ...imported]
|
|
addNotification({
|
|
type: 'success',
|
|
title: formatMessage(messages.presetsImported, { count: imported.length }),
|
|
})
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
async function exportPresets(template = false) {
|
|
try {
|
|
const path = await save({
|
|
defaultPath: template
|
|
? 'axolotl-gradient-presets-template.json'
|
|
: 'axolotl-gradient-presets.json',
|
|
filters: [{ name: 'JSON', extensions: ['json'] }],
|
|
})
|
|
if (!path) return
|
|
const exportValue = template
|
|
? [
|
|
{
|
|
name: 'Example',
|
|
colors: DEFAULT_GRADIENT_COLORS,
|
|
},
|
|
]
|
|
: presets.value
|
|
await writeTextFile(path, serializeGradientPresets(exportValue as GradientPreset[]))
|
|
} catch (error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
|
|
function readDocumentFromEditor(): GradientTextDocument {
|
|
const root = editor.value
|
|
if (!root) return textDocument.value
|
|
const lines: GradientTextRun[][] = [[]]
|
|
let line = lines[0]
|
|
|
|
const appendText = (text: string, formats: TextFormat[]) => {
|
|
const visibleText = text.replaceAll('\u200B', '')
|
|
if (!visibleText) return
|
|
const previous = line[line.length - 1]
|
|
if (previous && previous.formats.join(',') === formats.join(',')) previous.text += visibleText
|
|
else line.push({ text: visibleText, formats: [...formats] })
|
|
}
|
|
const newLine = () => {
|
|
line = []
|
|
lines.push(line)
|
|
}
|
|
const walk = (node: Node, formats: TextFormat[]) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
appendText(node.textContent ?? '', formats)
|
|
return
|
|
}
|
|
if (!(node instanceof HTMLElement)) return
|
|
if (node.tagName === 'BR') {
|
|
newLine()
|
|
return
|
|
}
|
|
const nextFormats = [...formats]
|
|
const tag = node.tagName
|
|
if ((tag === 'B' || tag === 'STRONG') && !nextFormats.includes('bold')) nextFormats.push('bold')
|
|
if ((tag === 'I' || tag === 'EM') && !nextFormats.includes('italic')) nextFormats.push('italic')
|
|
if (tag === 'U' && !nextFormats.includes('underlined')) nextFormats.push('underlined')
|
|
if (
|
|
(tag === 'S' || tag === 'STRIKE' || tag === 'DEL') &&
|
|
!nextFormats.includes('strikethrough')
|
|
) {
|
|
nextFormats.push('strikethrough')
|
|
}
|
|
if (
|
|
(tag === 'CODE' || node.dataset.gradientFormat === 'obfuscated') &&
|
|
!nextFormats.includes('obfuscated')
|
|
) {
|
|
nextFormats.push('obfuscated')
|
|
}
|
|
const isBlock = tag === 'DIV' || tag === 'P'
|
|
if (isBlock && line.length) newLine()
|
|
for (const child of Array.from(node.childNodes)) walk(child, nextFormats)
|
|
}
|
|
|
|
for (const child of Array.from(root.childNodes)) walk(child, [])
|
|
return normalizeGradientDocument({ lines })
|
|
}
|
|
|
|
function writeDocumentToEditor(value: GradientTextDocument) {
|
|
if (!editor.value) return
|
|
editor.value.replaceChildren()
|
|
for (const sourceLine of normalizeGradientDocument(value).lines) {
|
|
const line = document.createElement('div')
|
|
if (!sourceLine.length || !sourceLine.some((run) => run.text))
|
|
line.append(document.createElement('br'))
|
|
for (const run of sourceLine) {
|
|
const span = document.createElement('span')
|
|
span.textContent = run.text
|
|
if (run.formats.includes('bold')) span.style.fontWeight = '700'
|
|
if (run.formats.includes('italic')) span.style.fontStyle = 'italic'
|
|
if (run.formats.includes('underlined')) span.style.textDecoration = 'underline'
|
|
if (run.formats.includes('strikethrough')) span.style.textDecoration = 'line-through'
|
|
if (run.formats.includes('obfuscated')) {
|
|
span.dataset.gradientFormat = 'obfuscated'
|
|
span.className = 'lab-editor-obfuscated'
|
|
}
|
|
line.append(span)
|
|
}
|
|
editor.value.append(line)
|
|
}
|
|
}
|
|
|
|
function formatLabel(format: TextFormat): string {
|
|
return formatMessage(messages[format])
|
|
}
|
|
|
|
function formatAdapter(adapterId: GradientFormatId): string {
|
|
const adapter = gradientFormatAdapters.find(
|
|
(item) => item.id === adapterId,
|
|
) as GradientFormatAdapter
|
|
return `${formatAdapterName(adapterId)} · ${adapter.sample}`
|
|
}
|
|
|
|
function formatAdapterName(adapterId: GradientFormatId): string {
|
|
return formatMessage(adapterMessages[adapterId])
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="mx-auto flex w-full max-w-[90rem] flex-col gap-5 p-6">
|
|
<header class="flex min-w-0 flex-wrap items-center justify-between gap-4">
|
|
<h1 class="m-0 min-w-0 truncate text-2xl font-bold text-contrast">
|
|
{{ formatMessage(messages.title) }}
|
|
</h1>
|
|
<div
|
|
class="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 max-[680px]:w-full max-[680px]:justify-start"
|
|
>
|
|
<div class="lab-format-control">
|
|
<span>{{ formatMessage(messages.formatTitle) }}</span>
|
|
<DropdownSelect
|
|
v-model="adapterId"
|
|
:options="gradientFormatAdapters.map((adapter) => adapter.id)"
|
|
:display-name="formatAdapter"
|
|
name="Gradient output format"
|
|
class="max-w-[21rem] max-[680px]:max-w-none"
|
|
/>
|
|
</div>
|
|
<ButtonStyled size="standard" type="outlined">
|
|
<button @click="copyOutput">
|
|
<ClipboardCopyIcon />{{ formatMessage(messages.copy) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled size="standard" color="brand">
|
|
<button @click="exportOutput">
|
|
<DownloadIcon />{{ formatMessage(messages.export) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="lab-workbench" data-onboarding-id="lab-gradient-text-editor">
|
|
<section class="lab-panel min-w-0">
|
|
<section class="lab-panel-section p-4">
|
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
|
<h2 class="m-0 text-base font-bold text-contrast">
|
|
{{ formatMessage(messages.inputTitle) }}
|
|
</h2>
|
|
<div
|
|
class="flex items-center gap-1"
|
|
role="toolbar"
|
|
:aria-label="formatMessage(messages.inputTitle)"
|
|
>
|
|
<ButtonStyled
|
|
v-for="format in TEXT_FORMATS"
|
|
:key="format"
|
|
:highlighted="activeTextFormats.includes(format)"
|
|
circular
|
|
size="small"
|
|
type="transparent"
|
|
>
|
|
<button
|
|
:title="formatLabel(format)"
|
|
:aria-label="formatLabel(format)"
|
|
@mousedown.prevent
|
|
@click="formatText(format)"
|
|
>
|
|
<component :is="textFormatIcons[format]" />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref="editor"
|
|
contenteditable="true"
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
:aria-label="formatMessage(messages.inputTitle)"
|
|
:data-placeholder="formatMessage(messages.inputPlaceholder)"
|
|
class="lab-editor min-h-44 max-h-80 overflow-y-auto rounded-lg bg-surface-4 px-3 py-2.5 text-base leading-6 text-contrast outline-none transition-shadow focus:ring-4 focus:ring-brand-shadow"
|
|
@input="onEditorInput"
|
|
@focus="syncTextFormatState"
|
|
@keyup="syncTextFormatState"
|
|
@mouseup="syncTextFormatState"
|
|
@paste="onEditorPaste"
|
|
></div>
|
|
</section>
|
|
|
|
<section class="lab-panel-section p-4 lab-colors-section">
|
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h2 class="m-0 text-base font-bold text-contrast">
|
|
{{ formatMessage(messages.colorsTitle) }}
|
|
</h2>
|
|
<p class="m-0 mt-0.5 text-xs font-medium text-secondary">
|
|
{{ formatMessage(messages.colorStops, { count: colors.length }) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<ButtonStyled circular size="small" type="transparent">
|
|
<button
|
|
:title="formatMessage(messages.randomizeColors)"
|
|
:aria-label="formatMessage(messages.randomizeColors)"
|
|
@click="randomizeColors"
|
|
>
|
|
<RefreshCwIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<input
|
|
ref="newColorPicker"
|
|
type="color"
|
|
:value="colors[colors.length - 1]"
|
|
:aria-label="formatMessage(messages.addColor)"
|
|
class="lab-new-color-picker"
|
|
@change="addPickedColor(($event.target as HTMLInputElement).value)"
|
|
/>
|
|
<ButtonStyled color="brand" size="small">
|
|
<button @click="openNewColorPicker">
|
|
<PlusIcon />{{ formatMessage(messages.addColor) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="lab-color-rail"
|
|
:style="{ background: `linear-gradient(90deg, ${colors.join(', ')})` }"
|
|
></div>
|
|
<div class="lab-color-list">
|
|
<div v-for="(color, index) in colors" :key="index" class="lab-color-row">
|
|
<label class="lab-color-swatch" :style="{ backgroundColor: color }">
|
|
<input
|
|
type="color"
|
|
:value="color"
|
|
:aria-label="`${formatMessage(messages.colorsTitle)} ${index + 1}`"
|
|
@input="updateColor(index, ($event.target as HTMLInputElement).value)"
|
|
/>
|
|
</label>
|
|
<StyledInput
|
|
v-model="colorInputValues[index]"
|
|
size="small"
|
|
input-class="font-mono"
|
|
:input-attrs="{ 'aria-label': color }"
|
|
@update:model-value="updateColor(index, String($event))"
|
|
/>
|
|
<div class="flex items-center gap-1">
|
|
<ButtonStyled circular size="small" type="transparent">
|
|
<button
|
|
:title="formatMessage(messages.moveColorUp)"
|
|
:aria-label="formatMessage(messages.moveColorUp)"
|
|
:disabled="index === 0"
|
|
@click="moveColor(index, -1)"
|
|
>
|
|
<ArrowUpIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular size="small" type="transparent">
|
|
<button
|
|
:title="formatMessage(messages.moveColorDown)"
|
|
:aria-label="formatMessage(messages.moveColorDown)"
|
|
:disabled="index === colors.length - 1"
|
|
@click="moveColor(index, 1)"
|
|
>
|
|
<ArrowDownIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular size="small" type="transparent" color="red">
|
|
<button
|
|
:title="formatMessage(messages.removeColor)"
|
|
:aria-label="formatMessage(messages.removeColor)"
|
|
:disabled="colors.length <= 1"
|
|
@click="removeColor(index)"
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 flex gap-2">
|
|
<StyledInput
|
|
v-model="importValue"
|
|
:placeholder="formatMessage(messages.importColorsPlaceholder)"
|
|
wrapper-class="min-w-0 flex-1"
|
|
@keydown.enter.prevent="applyImportedColors"
|
|
/>
|
|
<ButtonStyled size="standard" type="outlined">
|
|
<button @click="applyImportedColors">
|
|
{{ formatMessage(messages.importColors) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
<p v-if="importError" class="m-0 mt-2 text-sm text-red">{{ importError }}</p>
|
|
</section>
|
|
|
|
<Accordion
|
|
class="lab-panel-section p-4"
|
|
content-class="pt-3"
|
|
button-class="group flex w-full items-center gap-2 border-0 bg-transparent p-0 text-left"
|
|
>
|
|
<template #title>
|
|
<span class="text-base font-bold text-contrast">{{
|
|
formatMessage(messages.presetsTitle)
|
|
}}</span>
|
|
<span v-if="presets.length" class="text-sm font-medium text-secondary">{{
|
|
formatMessage(messages.presetCount, { count: presets.length })
|
|
}}</span>
|
|
</template>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex gap-2">
|
|
<StyledInput
|
|
v-model="presetName"
|
|
:placeholder="formatMessage(messages.presetName)"
|
|
wrapper-class="min-w-0 flex-1"
|
|
@keydown.enter.prevent="savePreset"
|
|
/>
|
|
<ButtonStyled size="small" color="brand">
|
|
<button :disabled="!presetName.trim()" @click="savePreset">
|
|
<PlusIcon />{{ formatMessage(messages.savePreset) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
<div v-if="presets.length" class="flex flex-col gap-1.5">
|
|
<div
|
|
v-for="preset in presets"
|
|
:key="preset.id"
|
|
class="flex items-center gap-2 rounded-lg bg-surface-4 p-2"
|
|
>
|
|
<button
|
|
class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left"
|
|
@click="applyPreset(preset)"
|
|
>
|
|
<span class="block truncate text-sm font-semibold text-contrast">{{
|
|
preset.name
|
|
}}</span>
|
|
<span class="mt-1.5 flex h-1.5 overflow-hidden rounded-full">
|
|
<span
|
|
v-for="(presetColor, colorIndex) in preset.colors"
|
|
:key="`${preset.id}-${colorIndex}`"
|
|
class="flex-1"
|
|
:style="{ backgroundColor: presetColor }"
|
|
></span>
|
|
</span>
|
|
</button>
|
|
<ButtonStyled circular size="small" type="transparent" color="red">
|
|
<button
|
|
:title="formatMessage(messages.deletePreset)"
|
|
:aria-label="formatMessage(messages.deletePreset)"
|
|
@click="deletePreset(preset.id)"
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<ButtonStyled size="small" type="outlined"
|
|
><button @click="importPresets">
|
|
<UploadIcon />{{ formatMessage(messages.importPresets) }}
|
|
</button></ButtonStyled
|
|
>
|
|
<ButtonStyled size="small" type="outlined"
|
|
><button @click="exportPresets()">
|
|
<DownloadIcon />{{ formatMessage(messages.exportPresets) }}
|
|
</button></ButtonStyled
|
|
>
|
|
<ButtonStyled size="small" type="outlined"
|
|
><button @click="exportPresets(true)">
|
|
<DownloadIcon />{{ formatMessage(messages.downloadTemplate) }}
|
|
</button></ButtonStyled
|
|
>
|
|
</div>
|
|
</div>
|
|
</Accordion>
|
|
</section>
|
|
|
|
<section class="lab-panel min-w-0">
|
|
<div
|
|
v-if="currentAdapter.supportsVanillaCharacter || currentAdapter.supportsSimplify"
|
|
class="lab-panel-section p-4 flex flex-wrap items-center gap-x-5 gap-y-3"
|
|
>
|
|
<div v-if="currentAdapter.supportsVanillaCharacter" class="flex items-center gap-2">
|
|
<span class="text-sm font-semibold text-primary">{{
|
|
formatMessage(messages.vanillaCharacter)
|
|
}}</span>
|
|
<div
|
|
class="lab-segmented"
|
|
role="group"
|
|
:aria-label="formatMessage(messages.vanillaCharacter)"
|
|
>
|
|
<button
|
|
class="lab-segment"
|
|
:class="{ active: vanillaCharacter === '&' }"
|
|
@click="vanillaCharacter = '&'"
|
|
>
|
|
&
|
|
</button>
|
|
<button
|
|
class="lab-segment"
|
|
:class="{ active: vanillaCharacter === '§' }"
|
|
@click="vanillaCharacter = '§'"
|
|
>
|
|
§
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<label
|
|
v-if="currentAdapter.supportsSimplify"
|
|
class="flex cursor-pointer items-center gap-2 text-sm font-semibold text-primary"
|
|
>
|
|
<input
|
|
v-model="simplifyGradients"
|
|
type="checkbox"
|
|
class="size-4 accent-[--color-brand]"
|
|
/>
|
|
{{ formatMessage(messages.simplify) }}
|
|
</label>
|
|
</div>
|
|
|
|
<section class="lab-panel-section p-4 lab-preview-section">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<h2 class="m-0 text-base font-bold text-contrast">
|
|
{{ formatMessage(messages.previewTitle) }}
|
|
</h2>
|
|
<span class="font-mono text-xs text-secondary">{{ currentAdapter.sample }}</span>
|
|
</div>
|
|
<div
|
|
class="minecraft-preview-box"
|
|
:style="{ backgroundImage: `url(${minecraftPreviewBackground})` }"
|
|
>
|
|
<div class="minecraft-preview-content">
|
|
<p v-for="(line, lineIndex) in previewLines" :key="lineIndex">
|
|
<span
|
|
v-for="(character, characterIndex) in line"
|
|
:key="characterIndex"
|
|
:class="{
|
|
'is-bold': character.formats.includes('bold'),
|
|
'is-italic': character.formats.includes('italic'),
|
|
'is-underlined': character.formats.includes('underlined'),
|
|
'is-strikethrough': character.formats.includes('strikethrough'),
|
|
'is-obfuscated': character.formats.includes('obfuscated'),
|
|
'is-space': character.character.trim() === '',
|
|
}"
|
|
:style="{
|
|
'--minecraft-text-color': character.color ?? 'inherit',
|
|
'--minecraft-text-shadow-color': getMinecraftTextShadow(character.color),
|
|
}"
|
|
>{{
|
|
character.character.trim() === ''
|
|
? '\u00A0'
|
|
: previewCharacter(character.character, character.formats, characterIndex)
|
|
}}</span
|
|
>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="lab-panel-section p-4">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<h2 class="m-0 text-base font-bold text-contrast">
|
|
{{ formatMessage(messages.outputTitle) }}
|
|
</h2>
|
|
<span class="text-sm font-medium text-secondary">{{
|
|
formatAdapterName(currentAdapter.id)
|
|
}}</span>
|
|
</div>
|
|
<textarea
|
|
readonly
|
|
:value="output"
|
|
class="min-h-52 w-full resize-y rounded-lg bg-surface-4 p-3 font-mono text-sm leading-6 text-contrast outline-none transition-shadow focus:ring-4 focus:ring-brand-shadow"
|
|
:aria-label="formatMessage(messages.outputTitle)"
|
|
></textarea>
|
|
</section>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@font-face {
|
|
font-family: 'axolotl-lab-minecraft';
|
|
font-style: normal;
|
|
font-weight: 400;
|
|
src: url(v-bind(minecraftPreviewFont)) format('truetype');
|
|
}
|
|
|
|
.lab-workbench {
|
|
display: grid;
|
|
grid-template-columns: minmax(19rem, 0.82fr) minmax(28rem, 1.18fr);
|
|
align-items: start;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.lab-panel {
|
|
overflow: hidden;
|
|
border: 1px solid var(--color-surface-5);
|
|
border-radius: var(--radius-lg);
|
|
background: var(--color-surface-2);
|
|
}
|
|
|
|
.lab-panel-section + .lab-panel-section {
|
|
border-top: 1px solid var(--color-surface-5);
|
|
}
|
|
|
|
.lab-editor:empty::before {
|
|
color: var(--color-secondary);
|
|
content: attr(data-placeholder);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.lab-editor-obfuscated {
|
|
font-family: monospace;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.lab-format-control {
|
|
display: flex;
|
|
min-width: 0;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
color: var(--color-primary);
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.lab-format-control :deep(.animated-dropdown) {
|
|
width: min(21rem, 50vw);
|
|
}
|
|
|
|
.lab-format-control :deep(.options) {
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.lab-format-control :deep(.options::-webkit-scrollbar) {
|
|
display: none;
|
|
}
|
|
|
|
.lab-color-rail {
|
|
height: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid var(--color-surface-5);
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.lab-color-list {
|
|
max-height: 13rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.lab-color-row {
|
|
display: grid;
|
|
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.125rem;
|
|
}
|
|
|
|
.lab-color-row + .lab-color-row {
|
|
border-top: 1px solid var(--color-surface-4);
|
|
}
|
|
|
|
.lab-color-swatch {
|
|
position: relative;
|
|
display: block;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
margin-inline: auto;
|
|
overflow: hidden;
|
|
border: 2px solid var(--color-surface-5);
|
|
border-radius: 0.375rem;
|
|
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 12%);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lab-color-swatch:focus-within {
|
|
border-color: var(--color-brand);
|
|
outline: 2px solid var(--color-brand-shadow);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.lab-color-swatch input,
|
|
.lab-new-color-picker {
|
|
appearance: auto !important;
|
|
-webkit-appearance: auto !important;
|
|
}
|
|
|
|
.lab-color-swatch input {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lab-new-color-picker {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
overflow: hidden;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.lab-segmented {
|
|
display: inline-flex;
|
|
padding: 0.125rem;
|
|
border-radius: 0.375rem;
|
|
background: var(--color-surface-4);
|
|
}
|
|
|
|
.lab-segment {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 1.75rem;
|
|
height: 1.75rem;
|
|
padding: 0 0.375rem;
|
|
border: 0;
|
|
border-radius: 0.25rem;
|
|
background: transparent;
|
|
color: var(--color-secondary);
|
|
cursor: pointer;
|
|
font-family: monospace;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.lab-segment:hover,
|
|
.lab-segment.active {
|
|
background: var(--color-button-bg);
|
|
color: var(--color-contrast);
|
|
}
|
|
|
|
.lab-segment:focus-visible {
|
|
outline: 2px solid var(--color-brand);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.minecraft-preview-box {
|
|
width: min(100%, 44rem);
|
|
min-height: 16rem;
|
|
max-height: 26rem;
|
|
margin-inline: auto;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
padding: 1.25rem;
|
|
background-position: center bottom;
|
|
background-repeat: no-repeat;
|
|
background-size: cover;
|
|
font-family: 'axolotl-lab-minecraft', monospace;
|
|
font-size: 18px;
|
|
line-height: 24px;
|
|
word-break: break-all;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.minecraft-preview-content {
|
|
min-height: 2.375rem;
|
|
padding: 0.5rem;
|
|
background-color: rgb(1 1 1 / 40%);
|
|
}
|
|
|
|
.minecraft-preview-content p {
|
|
min-height: 1.5rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.minecraft-preview-content span {
|
|
color: var(--minecraft-text-color);
|
|
text-shadow: 0.125em 0.125em var(--minecraft-text-shadow-color);
|
|
}
|
|
|
|
.minecraft-preview-content .is-bold {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.minecraft-preview-content .is-italic {
|
|
font-style: italic;
|
|
}
|
|
|
|
.minecraft-preview-content .is-underlined,
|
|
.minecraft-preview-content .is-strikethrough {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.minecraft-preview-content .is-underlined::after,
|
|
.minecraft-preview-content .is-strikethrough::before {
|
|
position: absolute;
|
|
left: 0;
|
|
display: inline-block;
|
|
width: 100%;
|
|
height: 2px;
|
|
background: var(--minecraft-text-color);
|
|
box-shadow: 0.125em 0.125em var(--minecraft-text-shadow-color);
|
|
content: '';
|
|
}
|
|
|
|
.minecraft-preview-content .is-underlined::after {
|
|
bottom: -2px;
|
|
}
|
|
|
|
.minecraft-preview-content .is-strikethrough::before {
|
|
top: calc(50% - 2px);
|
|
}
|
|
|
|
.minecraft-preview-content .is-obfuscated {
|
|
font-family: monospace;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.minecraft-preview-content .is-space {
|
|
font-family: Arial, sans-serif;
|
|
}
|
|
|
|
@media (max-width: 65rem) {
|
|
.lab-workbench {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 42.5rem) {
|
|
.lab-format-control {
|
|
width: 100%;
|
|
}
|
|
|
|
.lab-format-control :deep(.animated-dropdown) {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 32rem) {
|
|
.lab-color-row {
|
|
grid-template-columns: 2.5rem minmax(0, 1fr);
|
|
}
|
|
|
|
.lab-color-row > :last-child {
|
|
grid-column: 1 / -1;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|