Improve rendering and validating block states

This commit is contained in:
Misode
2021-06-25 02:38:42 +02:00
parent 14da8ba575
commit 373698ebbc
5 changed files with 129 additions and 78 deletions

View File

@@ -4,24 +4,27 @@ import { useEffect, useRef } from 'preact/hooks'
import { useModel } from '../hooks'
import { locale } from '../Locales'
import { transformOutput } from '../schema/transformOutput'
import type { BlockStateRegistry } from '../Schemas'
type SourcePanelProps = {
lang: string,
name: string,
model: DataModel | null,
blockStates: BlockStateRegistry | null,
doCopy?: number,
doDownload?: number,
doImport?: number,
onError: (message: string) => unknown,
}
export function SourcePanel({ lang, name, model, doCopy, doDownload, doImport, onError }: SourcePanelProps) {
export function SourcePanel({ lang, name, model, blockStates, doCopy, doDownload, doImport, onError }: SourcePanelProps) {
const loc = locale.bind(null, lang)
const source = useRef<HTMLTextAreaElement>(null)
const download = useRef<HTMLAnchorElement>(null)
useModel(model, model => {
try {
const data = model.schema.hook(transformOutput, new ModelPath(model), model.data)
const props = { blockStates: blockStates ?? {} }
const data = model.schema.hook(transformOutput, new ModelPath(model), model.data, props)
source.current.value = JSON.stringify(data, null, 2) + '\n'
} catch (e) {
onError(`Error getting JSON output: ${e.message}`)

View File

@@ -5,24 +5,25 @@ import { useModel } from '../hooks'
import { locale } from '../Locales'
import { Mounter } from '../schema/Mounter'
import { renderHtml } from '../schema/renderHtml'
import type { VersionId } from '../Schemas'
import type { BlockStateRegistry, VersionId } from '../Schemas'
type TreePanelProps = {
lang: string,
model: DataModel | null,
version: VersionId,
model: DataModel | null,
blockStates: BlockStateRegistry | null,
onError: (message: string) => unknown,
}
export function Tree({ lang, model, version, onError }: TreePanelProps) {
export function Tree({ lang, model, version, blockStates, onError }: TreePanelProps) {
const tree = useRef<HTMLDivElement>(null)
const redraw = useRef<Function>()
useEffect(() => {
redraw.current = () => {
if (!model) return
if (!model || !blockStates) return
try {
const mounter = new Mounter()
const props = { loc: locale.bind(null, lang), version, mounter }
const props = { loc: locale.bind(null, lang), version, mounter, blockStates }
const path = new ModelPath(model)
const rendered = model.schema.hook(renderHtml, path, model.data, props)
const category = model.schema.category(path)
@@ -30,7 +31,7 @@ export function Tree({ lang, model, version, onError }: TreePanelProps) {
let html = rendered[2]
if (rendered[1]) {
html = `<div class="node ${type}-node" ${category ? `data-category="${category}"` : ''}>
<div class="node-header">${rendered[1]}</div>
<div class="node-header">${rendered[0]}${rendered[1]}</div>
<div class="node-body">${rendered[2]}</div>
</div>`
}
@@ -50,7 +51,7 @@ export function Tree({ lang, model, version, onError }: TreePanelProps) {
useEffect(() => {
redraw.current()
}, [lang])
}, [lang, model, blockStates])
return <div ref={tree} class="tree"></div>
}

View File

@@ -5,8 +5,8 @@ import { Analytics } from '../Analytics'
import { Ad, Btn, BtnInput, BtnMenu, ErrorPanel, HasPreview, Octicon, PreviewPanel, SourcePanel, Tree } from '../components'
import { fetchPreset } from '../DataFetcher'
import { locale } from '../Locales'
import type { VersionId } from '../Schemas'
import { checkVersion, getCollections, getModel } from '../Schemas'
import type { BlockStateRegistry, VersionId } from '../Schemas'
import { checkVersion, getBlockStates, getCollections, getModel } from '../Schemas'
type GeneratorProps = {
lang: string,
@@ -40,8 +40,11 @@ export function Generator({ lang, changeTitle, version, onChangeVersion, categor
changeTitle(loc('title.generator', loc(id)), allowedVersions)
const [model, setModel] = useState<DataModel | null>(null)
const [blockStates, setBlockStates] = useState<BlockStateRegistry | null>(null)
useEffect(() => {
setModel(null)
getBlockStates(version)
.then(b => setBlockStates(b))
getModel(version, id)
.then(m => setModel(m))
.catch(e => setError(e.message))
@@ -165,7 +168,7 @@ export function Generator({ lang, changeTitle, version, onChangeVersion, categor
</BtnMenu>
</div>
{error && <ErrorPanel error={error} />}
<Tree {...{lang, model, version}} onError={setError} />
<Tree {...{lang, model, version, blockStates}} onError={setError} />
</main>
<div class="popup-actions" style={`--offset: -${10 + actionsShown * 50}px;`}>
<div class={`popup-action action-preview${hasPreview ? ' shown' : ''}`} onClick={togglePreview}>
@@ -185,7 +188,7 @@ export function Generator({ lang, changeTitle, version, onChangeVersion, categor
<PreviewPanel {...{lang, model, version, id}} shown={previewShown} onError={setError} />
</div>
<div class={`popup-source${sourceShown ? ' shown' : ''}`}>
<SourcePanel {...{lang, model, doCopy, doDownload, doImport}} name={modelConfig.schema ?? 'data'} onError={setError} />
<SourcePanel {...{lang, model, blockStates, doCopy, doDownload, doImport}} name={modelConfig.schema ?? 'data'} onError={setError} />
</div>
</>
}

View File

@@ -1,7 +1,7 @@
import type { EnumOption, Hook, ValidationOption } from '@mcschema/core'
import { DataModel, MapNode, ModelPath, Path, StringNode } from '@mcschema/core'
import { DataModel, MapNode, ModelPath, ObjectNode, Path, relativePath, StringNode } from '@mcschema/core'
import type { Localize } from '../Locales'
import type { VersionId } from '../Schemas'
import type { BlockStateRegistry, VersionId } from '../Schemas'
import { hexId, htmlEncode } from '../Utils'
import type { Mounter } from './Mounter'
import { Octicon } from './Octicon'
@@ -10,12 +10,13 @@ export type TreeProps = {
loc: Localize,
mounter: Mounter,
version: VersionId,
blockStates: BlockStateRegistry,
}
const selectRegistries = ['loot_table.type', 'loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'carver.type', 'feature.type', 'decorator.type', 'feature.tree.minimum_size.type', 'block_state_provider.type', 'trunk_placer.type', 'foliage_placer.type', 'tree_decorator.type', 'int_provider.type', 'float_provider.type', 'height_provider.type', 'structure_feature.type', 'surface_builder.type', 'processor.processor_type', 'rule_test.predicate_type', 'pos_rule_test.predicate_type', 'template_element.element_type', 'block_placer.type']
const hiddenFields = ['number_provider.type', 'score_provider.type', 'nbt_provider.type', 'int_provider.type', 'float_provider.type', 'height_provider.type']
const flattenedFields = ['feature.config', 'decorator.config', 'int_provider.value', 'float_provider.value', 'block_state_provider.simple_state_provider.state', 'block_state_provider.rotated_block_provider.state', 'block_state_provider.weighted_state_provider.entries.entry.data', 'rule_test.block_state', 'structure_feature.config', 'surface_builder.config', 'template_pool.elements.entry.element']
const inlineFields = ['loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'feature.type', 'decorator.type', 'block_state_provider.type', 'feature.tree.minimum_size.type', 'trunk_placer.type', 'feature.tree.foliage_placer', 'tree_decorator.type', 'block_placer.type', 'rule_test.predicate_type', 'processor.processor_type', 'template_element.element_type']
const inlineFields = ['loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'feature.type', 'decorator.type', 'block_state_provider.type', 'feature.tree.minimum_size.type', 'trunk_placer.type', 'foliage_placer.type', 'tree_decorator.type', 'block_placer.type', 'rule_test.predicate_type', 'processor.processor_type', 'template_element.element_type']
/**
* Secondary model used to remember the keys of a map
@@ -34,30 +35,30 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
return ['', '', '']
},
boolean({ node }, path, value, { loc, mounter }) {
const onFalse = mounter.onClick(() => {
boolean({ node }, path, value, props) {
const onFalse = props.mounter.onClick(() => {
path.model.set(path, node.optional() && value === false ? undefined : false)
})
const onTrue = mounter.onClick(() => {
const onTrue = props.mounter.onClick(() => {
path.model.set(path, node.optional() && value === true ? undefined : true)
})
return ['', `<button${value === false ? ' class="selected"' : ' '}
data-id="${onFalse}">${htmlEncode(loc('false'))}</button>
data-id="${onFalse}">${htmlEncode(props.loc('false'))}</button>
<button${value === true ? ' class="selected"' : ' '}
data-id="${onTrue}">${htmlEncode(loc('true'))}</button>`, '']
data-id="${onTrue}">${htmlEncode(props.loc('true'))}</button>`, '']
},
choice({ choices, config, switchNode }, path, value, { loc, mounter, version }) {
choice({ choices, config, switchNode }, path, value, props) {
const choice = switchNode.activeCase(path, true)
const pathWithContext = (config?.context) ? new ModelPath(path.getModel(), new Path(path.getArray(), [config.context])) : path
const pathWithChoiceContext = config?.choiceContext ? new Path([], [config.choiceContext]) : config?.context ? new Path([], [config.context]) : path
const [prefix, suffix, body] = choice.node.hook(this, pathWithContext, value, { loc, mounter, version })
const [prefix, suffix, body] = choice.node.hook(this, pathWithContext, value, props)
if (choices.length === 1) {
return [prefix, suffix, body]
}
const inputId = mounter.register(el => {
const inputId = props.mounter.register(el => {
(el as HTMLSelectElement).value = choice.type
el.addEventListener('change', () => {
const c = choices.find(c => c.type === (el as HTMLSelectElement).value) ?? choice
@@ -66,39 +67,39 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
})
const inject = `<select data-id="${inputId}">
${choices.map(c => `<option value="${htmlEncode(c.type)}">
${htmlEncode(pathLocale(loc, pathWithChoiceContext.contextPush(c.type)))}
${htmlEncode(pathLocale(props.loc, pathWithChoiceContext.contextPush(c.type)))}
</option>`).join('')}
</select>`
return [prefix, inject + suffix, body]
},
list({ children }, path, value, { loc, mounter, version }) {
const onAdd = mounter.onClick(() => {
list({ children }, path, value, props) {
const onAdd = props.mounter.onClick(() => {
if (!Array.isArray(value)) value = []
path.model.set(path, [children.default(), ...value])
})
const onAddBottom = mounter.onClick(() => {
const onAddBottom = props.mounter.onClick(() => {
if (!Array.isArray(value)) value = []
path.model.set(path, [...value, children.default()])
})
const suffix = `<button class="add" data-id="${onAdd}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>`
const suffix = `<button class="add" data-id="${onAdd}" aria-label="${props.loc('button.add')}">${Octicon.plus_circle}</button>`
let body = ''
if (Array.isArray(value)) {
body = value.map((childValue, index) => {
const removeId = mounter.onClick(() => path.model.set(path.push(index), undefined))
const removeId = props.mounter.onClick(() => path.model.set(path.push(index), undefined))
const childPath = path.push(index).contextPush('entry')
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, childValue, { loc, mounter, version })
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, childValue, props)
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
<button class="remove" data-id="${removeId}" aria-label="${loc('button.remove')}">${Octicon.trashcan}</button>
${error(props.loc, childPath, props.mounter)}
${help(props.loc, childPath, props.mounter)}
<button class="remove" data-id="${removeId}" aria-label="${props.loc('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
${htmlEncode(pathLocale(loc, childPath, `${index}`))}
<label ${contextMenu(props.loc, childPath, props.mounter)}>
${htmlEncode(pathLocale(props.loc, childPath, `${index}`))}
</label>
${cSuffix}
</div>
@@ -109,7 +110,7 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
if (value.length > 2) {
body += `<div class="node-entry">
<div class="node node-header">
<button class="add" data-id="${onAddBottom}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>
<button class="add" data-id="${onAddBottom}" aria-label="${props.loc('button.add')}">${Octicon.plus_circle}</button>
</div>
</div>`
}
@@ -117,29 +118,52 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
return ['', suffix, body]
},
map({ children, keys }, path, value, { loc, mounter, version }) {
map({ children, keys, config }, path, value, props) {
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
const onAdd = mounter.onClick(() => {
const onAdd = props.mounter.onClick(() => {
const key = keyPath.get()
path.model.set(path.push(key), children.default())
})
const keyRendered = keys.hook(this, keyPath, keyPath.get() ?? '', { loc, mounter, version })
const suffix = keyRendered[1] + `<button class="add" data-id="${onAdd}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>`
const blockState = config.validation?.validator === 'block_state_map'? props.blockStates?.[relativePath(path, config.validation.params.id).get()] : null
const keysSchema = blockState?.properties
? StringNode(null!, { enum: Object.keys(blockState.properties ?? {}) })
: keys
const keyRendered = keysSchema.hook(this, keyPath, keyPath.get() ?? '', props)
const suffix = keyRendered[1] + `<button class="add" data-id="${onAdd}" aria-label="${props.loc('button.add')}">${Octicon.plus_circle}</button>`
if (blockState && path.last() === 'Properties') {
if (typeof value !== 'object') value = {}
const properties = Object.entries(blockState.properties)
.map(([key, values]) => [key, StringNode(null!, { enum: values })])
Object.entries(blockState.properties).forEach(([key, values]) => {
if (typeof value[key] !== 'string') {
path.model.errors.add(path.push(key), 'error.expected_string')
} else if (!values.includes(value[key])) {
path.model.errors.add(path.push(key), 'error.invalid_enum_option', value[key])
}
})
return ObjectNode(Object.fromEntries(properties)).hook(this, path, value, props)
}
let body = ''
if (typeof value === 'object' && value !== undefined) {
body = Object.keys(value)
.map(key => {
const onRemove = mounter.onClick(() => path.model.set(path.push(key), undefined))
const onRemove = props.mounter.onClick(() => path.model.set(path.push(key), undefined))
const childPath = path.modelPush(key)
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, value[key], { loc, mounter, version })
const childrenSchema = blockState
? StringNode(null!, { enum: blockState.properties[key] ?? [] })
: children
if (blockState?.properties[key] && !blockState.properties[key].includes(value[key])) {
path.model.errors.add(childPath, 'error.invalid_enum_option', value[key])
}
const [cPrefix, cSuffix, cBody] = childrenSchema.hook(this, childPath, value[key], props)
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
<button class="remove" data-id="${onRemove}" aria-label="${loc('button.remove')}">${Octicon.trashcan}</button>
${error(props.loc, childPath, props.mounter)}
${help(props.loc, childPath, props.mounter)}
<button class="remove" data-id="${onRemove}" aria-label="${props.loc('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
<label ${contextMenu(props.loc, childPath, props.mounter)}>
${htmlEncode(key)}
</label>
${cSuffix}
@@ -168,13 +192,14 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
return ['', `<input data-id="${onChange}" value="${value ?? ''}">`, '']
},
object({ node, getActiveFields, getChildModelPath }, path, value, { loc, mounter, version }) {
object({ node, getActiveFields, getChildModelPath }, path, value, props) {
let prefix = ''
let suffix = ''
if (node.optional()) {
if (value === undefined) {
suffix = `<button class="collapse closed" data-id="${mounter.onClick(() => path.model.set(path, node.default()))}" aria-label="${loc('button.expand')}">${Octicon.plus_circle}</button>`
suffix = `<button class="collapse closed" data-id="${props.mounter.onClick(() => path.model.set(path, node.default()))}" aria-label="${props.loc('button.expand')}">${Octicon.plus_circle}</button>`
} else {
suffix = `<button class="collapse open" data-id="${mounter.onClick(() => path.model.set(path, undefined))}" aria-label="${loc('button.collapse')}">${Octicon.trashcan}</button>`
suffix = `<button class="collapse open" data-id="${props.mounter.onClick(() => path.model.set(path, undefined))}" aria-label="${props.loc('button.collapse')}">${Octicon.trashcan}</button>`
}
}
let body = ''
@@ -191,22 +216,26 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
}
const category = field.category(childPath)
const [cPrefix, cSuffix, cBody] = field.hook(this, childPath, value[k], { loc, mounter, version })
if (field.type(childPath) === 'object' && flattenedFields.includes(context)) {
suffix += cSuffix
return cBody
}
if (inlineFields.includes(context)) {
suffix += cSuffix
const [cPrefix, cSuffix, cBody] = field.hook(this, childPath, value[k], props)
if (cPrefix.length === 0 && cSuffix.length === 0 && cBody.length === 0) {
return ''
}
const isFlattened = field.type(childPath) === 'object' && flattenedFields.includes(context)
const isInlined = inlineFields.includes(context)
if (isFlattened || isInlined) {
prefix += `${error(props.loc, childPath, props.mounter)}${help(props.loc, childPath, props.mounter)}${cPrefix}`
suffix += cSuffix
return isFlattened ? cBody : ''
}
return `<div class="node ${field.type(childPath)}-node ${cBody ? '' : 'no-body'}" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
${error(props.loc, childPath, props.mounter)}
${help(props.loc, childPath, props.mounter)}
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
${pathLocale(loc, childPath)}
<label ${contextMenu(props.loc, childPath, props.mounter)}>
${pathLocale(props.loc, childPath)}
</label>
${cSuffix}
</div>
@@ -215,11 +244,11 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
})
.join('')
}
return ['', suffix, body]
return [prefix, suffix, body]
},
string({ node, getValues, config }, path, value, { loc, mounter }) {
const inputId = mounter.register(el => {
string({ node, getValues, config }, path, value, props) {
const inputId = props.mounter.register(el => {
(el as HTMLSelectElement).value = value ?? ''
el.addEventListener('change', evt => {
const newValue = (el as HTMLSelectElement).value
@@ -238,17 +267,23 @@ export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
context = context.contextPush(config.params.pool)
}
suffix = `<select data-id="${inputId}">
${node.optional() ? `<option value="">${loc('unset')}</option>` : ''}
${node.optional() ? `<option value="">${props.loc('unset')}</option>` : ''}
${values.map(v => `<option value="${htmlEncode(v)}">
${pathLocale(loc, context.contextPush(v.replace(/^minecraft:/, '')))}
${pathLocale(props.loc, context.contextPush(v.replace(/^minecraft:/, '')))}
</option>`).join('')}
</select>`
} else if (!isEnum(config) && config?.validator === 'block_state_key') {
const blockState = props.blockStates?.[relativePath(path, config.params.id).get()]
const values = Object.keys(blockState?.properties ?? {})
suffix = `<select data-id="${inputId}">
${values.map(v => `<option>${v}</option>`).join('')}
</select>`
} else {
const datalistId = hexId()
suffix = `<input data-id="${inputId}" ${values.length === 0 ? '' : `list="${datalistId}"`}>
${values.length === 0 ? '' :
`<datalist id="${datalistId}">
${values.map(v =>
${values.map(v =>
`<option value="${htmlEncode(v)}">`
).join('')}
</datalist>`}`

View File

@@ -1,31 +1,40 @@
import type { Hook } from '@mcschema/core'
import { relativePath } from '@mcschema/core'
import type { BlockStateRegistry } from '../Schemas'
export const transformOutput: Hook<[any], any> = {
export type OutputProps = {
blockStates: BlockStateRegistry,
}
export const transformOutput: Hook<[any, OutputProps], any> = {
base({}, _, value) {
return value
},
choice({ switchNode }, path, value) {
return switchNode.hook(this, path, value)
choice({ switchNode }, path, value, props) {
return switchNode.hook(this, path, value, props)
},
list({ children }, path, value) {
list({ children }, path, value, props) {
if (!Array.isArray(value)) return value
return value.map((obj, index) =>
children.hook(this, path.push(index), obj)
children.hook(this, path.push(index), obj, props)
)
},
map({ children }, path, value) {
map({ children, config }, path, value, props) {
if (value === undefined) return undefined
const blockState = config.validation?.validator === 'block_state_map'? props.blockStates?.[relativePath(path, config.validation.params.id).get()] : null
const res: any = {}
Object.keys(value).forEach(f =>
res[f] = children.hook(this, path.push(f), value[f])
)
Object.keys(value).forEach(f => {
if (blockState) {
if (!Object.keys(blockState.properties).includes(f)) return
}
res[f] = children.hook(this, path.push(f), value[f], props)
})
return res
},
object({ getActiveFields }, path, value) {
object({ getActiveFields }, path, value, props) {
if (value === undefined || value === null || typeof value !== 'object') {
return value
}
@@ -34,7 +43,7 @@ export const transformOutput: Hook<[any], any> = {
Object.keys(activeFields)
.filter(k => activeFields[k].enabled(path))
.forEach(f => {
res[f] = activeFields[f].hook(this, path.push(f), value[f])
res[f] = activeFields[f].hook(this, path.push(f), value[f], props)
})
return res
},