mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-28 09:18:45 +00:00
Use preact to render the tree (#155)
* Use preact to render the tree * More changes to renderHtml
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
export const Octicon = {
|
||||
chevron_down: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"></path></svg>',
|
||||
chevron_up: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.22 9.78a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 0l4.25 4.25a.75.75 0 01-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 01-1.06 0z"></path></svg>',
|
||||
clippy: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zm.75 3V2.5h3V4h-3zm-2.874-.467a.75.75 0 00-.752-1.298A1.75 1.75 0 002 3.75v9.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-9.5a1.75 1.75 0 00-.874-1.515.75.75 0 10-.752 1.298.25.25 0 01.126.217v9.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-9.5a.25.25 0 01.126-.217z"></path></svg>',
|
||||
info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"></path></svg>',
|
||||
issue_opened: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"></path></svg>',
|
||||
plus_circle: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"></path></svg>',
|
||||
trashcan: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path></svg>',
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
import type { EnumOption, Hook, ValidationOption } from '@mcschema/core'
|
||||
import { DataModel, MapNode, ModelPath, ObjectNode, Path, relativePath, StringNode } from '@mcschema/core'
|
||||
import type { Localize } from '../Locales'
|
||||
import type { BlockStateRegistry, VersionId } from '../Schemas'
|
||||
import { hexId, htmlEncode } from '../Utils'
|
||||
import type { Mounter } from './Mounter'
|
||||
import { Octicon } from './Octicon'
|
||||
|
||||
export type TreeProps = {
|
||||
loc: Localize,
|
||||
mounter: Mounter,
|
||||
version: VersionId,
|
||||
blockStates: BlockStateRegistry,
|
||||
}
|
||||
|
||||
declare var ResizeObserver: any
|
||||
|
||||
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', 'foliage_placer.type', 'tree_decorator.type', 'block_placer.type', 'rule_test.predicate_type', 'processor.processor_type', 'template_element.element_type', 'nbt_operation.op', 'number_provider.value', 'score_provider.name', 'score_provider.target', 'nbt_provider.source', 'nbt_provider.target']
|
||||
const nbtFields = ['function.set_nbt.tag', 'advancement.display.icon.nbt', 'text_component_object.nbt', 'entity.nbt', 'block.nbt', 'item.nbt']
|
||||
|
||||
/**
|
||||
* Secondary model used to remember the keys of a map
|
||||
*/
|
||||
const keysModel = new DataModel(MapNode(
|
||||
StringNode(),
|
||||
StringNode()
|
||||
), { historyMax: 0 })
|
||||
|
||||
/**
|
||||
* Renders the node and handles events to update the model
|
||||
* @returns string HTML representation of this node using the given data
|
||||
*/
|
||||
export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
|
||||
base() {
|
||||
return ['', '', '']
|
||||
},
|
||||
|
||||
boolean({ node }, path, value, props) {
|
||||
const onFalse = props.mounter.onClick(() => {
|
||||
path.model.set(path, node.optional() && value === false ? undefined : false)
|
||||
})
|
||||
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(props.loc('false'))}</button>
|
||||
<button${value === true ? ' class="selected"' : ' '}
|
||||
data-id="${onTrue}">${htmlEncode(props.loc('true'))}</button>`, '']
|
||||
},
|
||||
|
||||
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, props)
|
||||
if (choices.length === 1) {
|
||||
return [prefix, suffix, body]
|
||||
}
|
||||
|
||||
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
|
||||
path.model.set(path, c.change ? c.change(value) : c.node.default())
|
||||
})
|
||||
})
|
||||
const inject = `<select data-id="${inputId}">
|
||||
${choices.map(c => `<option value="${htmlEncode(c.type)}">
|
||||
${htmlEncode(pathLocale(props.loc, pathWithChoiceContext.contextPush(c.type)))}
|
||||
</option>`).join('')}
|
||||
</select>`
|
||||
|
||||
return [prefix, inject + suffix, body]
|
||||
},
|
||||
|
||||
list({ children }, path, value, props) {
|
||||
const onAdd = props.mounter.onClick(() => {
|
||||
if (!Array.isArray(value)) value = []
|
||||
path.model.set(path, [children.default(), ...value])
|
||||
})
|
||||
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="${props.loc('button.add')}">${Octicon.plus_circle}</button>`
|
||||
|
||||
let body = ''
|
||||
if (Array.isArray(value)) {
|
||||
body = value.map((childValue, index) => {
|
||||
const onRemove = props.mounter.onClick(() => path.model.set(path.push(index), undefined))
|
||||
const onMoveUp = props.mounter.onClick(() => {
|
||||
[value[index - 1], value[index]] = [value[index], value[index - 1]]
|
||||
path.model.set(path, value)
|
||||
})
|
||||
const onMoveDown = props.mounter.onClick(() => {
|
||||
[value[index + 1], value[index]] = [value[index], value[index + 1]]
|
||||
path.model.set(path, value)
|
||||
})
|
||||
const childPath = path.push(index).contextPush('entry')
|
||||
const category = children.category(childPath)
|
||||
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(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>
|
||||
${value.length <= 1 ? '' : `<div class="node-move">
|
||||
<button class="move" data-id="${onMoveUp}" ${index === 0 ? 'disabled' : ''}>${Octicon.chevron_up}</button>
|
||||
<button class="move" data-id="${onMoveDown}" ${index === value.length - 1 ? 'disabled' : ''}>${Octicon.chevron_down}</button>
|
||||
</div>`}
|
||||
${cPrefix}
|
||||
<label ${contextMenu(props.loc, childPath, props.mounter)}>
|
||||
${htmlEncode(pathLocale(props.loc, childPath, `${index}`))}
|
||||
</label>
|
||||
${cSuffix}
|
||||
</div>
|
||||
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
if (value.length > 2) {
|
||||
body += `<div class="node-entry">
|
||||
<div class="node node-header">
|
||||
<button class="add" data-id="${onAddBottom}" aria-label="${props.loc('button.add')}">${Octicon.plus_circle}</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
return ['', suffix, body]
|
||||
},
|
||||
|
||||
map({ children, keys, config }, path, value, props) {
|
||||
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
|
||||
const onAdd = props.mounter.onClick(() => {
|
||||
const key = keyPath.get()
|
||||
path.model.set(path.push(key), children.default())
|
||||
})
|
||||
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 = props.mounter.onClick(() => path.model.set(path.push(key), undefined))
|
||||
const childPath = path.modelPush(key)
|
||||
const category = children.category(childPath)
|
||||
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(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(props.loc, childPath, props.mounter)}>
|
||||
${htmlEncode(key)}
|
||||
</label>
|
||||
${cSuffix}
|
||||
</div>
|
||||
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
return ['', suffix, body]
|
||||
},
|
||||
|
||||
number({ integer, config }, path, value, { mounter }) {
|
||||
const onChange = mounter.onChange(el => {
|
||||
const value = (el as HTMLInputElement).value
|
||||
const parsed = config?.color
|
||||
? parseInt(value.slice(1), 16)
|
||||
: integer ? parseInt(value) : parseFloat(value)
|
||||
path.model.set(path, parsed)
|
||||
})
|
||||
if (config?.color) {
|
||||
const hex = (value?.toString(16).padStart(6, '0') ?? '000000')
|
||||
return ['', `<input type="color" data-id="${onChange}" value="#${hex}">`, '']
|
||||
}
|
||||
return ['', `<input data-id="${onChange}" value="${value ?? ''}">`, '']
|
||||
},
|
||||
|
||||
object({ node, getActiveFields, getChildModelPath }, path, value, props) {
|
||||
let prefix = ''
|
||||
let suffix = ''
|
||||
if (node.optional()) {
|
||||
if (value === undefined) {
|
||||
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="${props.mounter.onClick(() => path.model.set(path, undefined))}" aria-label="${props.loc('button.collapse')}">${Octicon.trashcan}</button>`
|
||||
}
|
||||
}
|
||||
let body = ''
|
||||
if (typeof value === 'object' && value !== undefined && (!(node.optional() && value === undefined))) {
|
||||
const activeFields = getActiveFields(path)
|
||||
const activeKeys = Object.keys(activeFields)
|
||||
.filter(k => activeFields[k].enabled(path))
|
||||
body = activeKeys.map(k => {
|
||||
const field = activeFields[k]
|
||||
const childPath = getChildModelPath(path, k)
|
||||
const context = childPath.getContext().join('.')
|
||||
if (hiddenFields.includes(context)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const category = field.category(childPath)
|
||||
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(props.loc, childPath, props.mounter)}
|
||||
${help(props.loc, childPath, props.mounter)}
|
||||
${cPrefix}
|
||||
<label ${contextMenu(props.loc, childPath, props.mounter)}>
|
||||
${pathLocale(props.loc, childPath)}
|
||||
</label>
|
||||
${cSuffix}
|
||||
</div>
|
||||
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
return [prefix, suffix, body]
|
||||
},
|
||||
|
||||
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
|
||||
path.model.set(path, newValue.length === 0 ? undefined : newValue)
|
||||
evt.stopPropagation()
|
||||
})
|
||||
})
|
||||
let suffix
|
||||
const values = getValues()
|
||||
const context = path.getContext().join('.')
|
||||
if (nbtFields.includes(context)) {
|
||||
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
|
||||
const textareaId = props.mounter.register(el => {
|
||||
const textarea = el as HTMLTextAreaElement
|
||||
textarea.value = value ?? ''
|
||||
textarea.addEventListener('change', evt => {
|
||||
const newValue = textarea.value
|
||||
path.model.set(path, newValue.length === 0 ? undefined : newValue)
|
||||
evt.stopPropagation()
|
||||
})
|
||||
const sizes = keyPath.get()
|
||||
if (sizes) {
|
||||
textarea.style.width = `${sizes.split(' ')[0]}px`
|
||||
textarea.style.height = `${sizes.split(' ')[1]}px`
|
||||
}
|
||||
new ResizeObserver(() => {
|
||||
keyPath.set(`${textarea.offsetWidth} ${textarea.offsetHeight}`)
|
||||
}).observe(el)
|
||||
})
|
||||
suffix = `<textarea data-id="${textareaId}"></textarea>`
|
||||
} else if ((isEnum(config) && !config.additional)
|
||||
|| selectRegistries.includes(context) ) {
|
||||
let context = new Path([])
|
||||
if (isEnum(config) && typeof config.enum === 'string') {
|
||||
context = context.contextPush(config.enum)
|
||||
} else if (!isEnum(config) && config?.validator === 'resource' && typeof config.params.pool === 'string') {
|
||||
context = context.contextPush(config.params.pool)
|
||||
}
|
||||
suffix = `<select data-id="${inputId}">
|
||||
${node.optional() ? `<option value="">${props.loc('unset')}</option>` : ''}
|
||||
${values.map(v => `<option value="${htmlEncode(v)}">
|
||||
${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 => `<option value="${htmlEncode(v)}">`).join('')}
|
||||
</datalist>`}`
|
||||
}
|
||||
return ['', suffix, '']
|
||||
},
|
||||
}
|
||||
|
||||
function isEnum(value?: ValidationOption | EnumOption): value is EnumOption {
|
||||
return !!(value as any)?.enum
|
||||
}
|
||||
|
||||
function hashString(str: string) {
|
||||
var hash = 0, i, chr
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
function pathLocale(loc: Localize, path: Path, ...params: string[]) {
|
||||
const ctx = path.getContext()
|
||||
for (let i = 0; i < ctx.length; i += 1) {
|
||||
const key = ctx.slice(i).join('.')
|
||||
const result = loc(key, ...params)
|
||||
if (key !== result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return htmlEncode(ctx[ctx.length - 1])
|
||||
}
|
||||
|
||||
function error(loc: Localize, path: ModelPath, mounter: Mounter) {
|
||||
const e = path.model.errors.get(path, true)
|
||||
if (e.length === 0) return ''
|
||||
const message = e[0].params ? loc(e[0].error, ...e[0].params) : loc(e[0].error)
|
||||
return popupIcon('node-error', 'issue_opened', htmlEncode(message), mounter)
|
||||
}
|
||||
|
||||
function help(loc: Localize, path: Path, mounter: Mounter) {
|
||||
const key = path.contextPush('help').getContext().join('.')
|
||||
const message = loc(key)
|
||||
if (message === key) return ''
|
||||
return popupIcon('node-help', 'info', htmlEncode(message), mounter)
|
||||
}
|
||||
|
||||
const popupIcon = (type: string, icon: keyof typeof Octicon, popup: string, mounter: Mounter) => {
|
||||
const onClick = mounter.onClick(el => {
|
||||
el.getElementsByTagName('span')[0].classList.add('show')
|
||||
document.body.addEventListener('click', () => {
|
||||
el.getElementsByTagName('span')[0].classList.remove('show')
|
||||
}, { capture: true, once: true })
|
||||
})
|
||||
return `<div class="node-icon ${type}" data-id="${onClick}">
|
||||
${Octicon[icon]}
|
||||
<span class="icon-popup">${popup}</span>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const contextMenu = (loc: Localize, path: ModelPath, mounter: Mounter) => {
|
||||
const id = mounter.register(el => {
|
||||
const openMenu = () => {
|
||||
const popup = document.createElement('div')
|
||||
popup.classList.add('node-menu')
|
||||
|
||||
const message = loc(path.contextPush('help').getContext().join('.'))
|
||||
if (!message.endsWith('.help')) {
|
||||
popup.insertAdjacentHTML('beforeend', `<span class="menu-item help-item">${message}</span>`)
|
||||
}
|
||||
|
||||
const context = path.getContext().join('.')
|
||||
popup.insertAdjacentHTML('beforeend', `
|
||||
<div class="menu-item">
|
||||
<span class="btn">${Octicon.clippy}</span>
|
||||
Context: 
|
||||
<span class="menu-item-context">${context}</span>
|
||||
</div>`)
|
||||
popup.querySelector('.menu-item .btn')?.addEventListener('click', () => {
|
||||
const inputEl = document.createElement('input')
|
||||
inputEl.value = context
|
||||
el.appendChild(inputEl)
|
||||
inputEl.select()
|
||||
document.execCommand('copy')
|
||||
el.removeChild(inputEl)
|
||||
})
|
||||
|
||||
el.appendChild(popup)
|
||||
document.body.addEventListener('click', () => {
|
||||
try {el.removeChild(popup)} catch (e) {}
|
||||
}, { capture: true, once: true })
|
||||
document.body.addEventListener('contextmenu', () => {
|
||||
try {el.removeChild(popup)} catch (e) {}
|
||||
}, { capture: true, once: true })
|
||||
}
|
||||
el.addEventListener('contextmenu', evt => {
|
||||
openMenu()
|
||||
evt.preventDefault()
|
||||
})
|
||||
let timer: any = null
|
||||
el.addEventListener('touchstart', () => {
|
||||
timer = setTimeout(() => {
|
||||
openMenu()
|
||||
timer = null
|
||||
}, 800)
|
||||
})
|
||||
el.addEventListener('touchend', () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
})
|
||||
return `data-id="${id}"`
|
||||
}
|
||||
336
src/app/schema/renderHtml.tsx
Normal file
336
src/app/schema/renderHtml.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { BooleanHookParams, EnumOption, Hook, INode, NumberHookParams, StringHookParams, ValidationOption } from '@mcschema/core'
|
||||
import { DataModel, MapNode, ModelPath, ObjectNode, Path, relativePath, StringNode } from '@mcschema/core'
|
||||
import type { ComponentChildren, JSX } from 'preact'
|
||||
import { Octicon } from '../components/Octicon'
|
||||
import { useFocus } from '../hooks'
|
||||
import { locale } from '../Locales'
|
||||
import type { BlockStateRegistry } from '../Schemas'
|
||||
import { hexId } from '../Utils'
|
||||
|
||||
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', 'foliage_placer.type', 'tree_decorator.type', 'block_placer.type', 'rule_test.predicate_type', 'processor.processor_type', 'template_element.element_type', 'nbt_operation.op', 'number_provider.value', 'score_provider.name', 'score_provider.target', 'nbt_provider.source', 'nbt_provider.target']
|
||||
const nbtFields = ['function.set_nbt.tag', 'advancement.display.icon.nbt', 'text_component_object.nbt', 'entity.nbt', 'block.nbt', 'item.nbt']
|
||||
|
||||
/**
|
||||
* Secondary model used to remember the keys of a map
|
||||
*/
|
||||
const keysModel = new DataModel(MapNode(
|
||||
StringNode(),
|
||||
StringNode()
|
||||
), { historyMax: 0 })
|
||||
|
||||
type JSXTriple = [JSX.Element | null, JSX.Element | null, JSX.Element | null]
|
||||
type RenderHook = Hook<[any, string, BlockStateRegistry], JSXTriple>
|
||||
|
||||
type NodeProps<T> = T & { node: INode<any> } & { path: ModelPath } & { value: any} & { lang: string } & { states: BlockStateRegistry }
|
||||
|
||||
/**
|
||||
* Renders the node and handles events to update the model
|
||||
* @returns string HTML representation of this node using the given data
|
||||
*/
|
||||
export const renderHtml: RenderHook = {
|
||||
base() {
|
||||
return [null, null, null]
|
||||
},
|
||||
|
||||
boolean(params, path, value, lang, states) {
|
||||
return [null, <BooleanSuffix {...{...params, path, value, lang, states}} />, null]
|
||||
},
|
||||
|
||||
choice({ choices, config, switchNode }, path, value, lang, states) {
|
||||
const choice = switchNode.activeCase(path, true) as typeof choices[number]
|
||||
const contextPath = (config?.context) ? new ModelPath(path.getModel(), new Path(path.getArray(), [config.context])) : path
|
||||
const [prefix, suffix, body] = choice.node.hook(this, contextPath, value, lang, states)
|
||||
if (choices.length === 1) {
|
||||
return [prefix, suffix, body]
|
||||
}
|
||||
const choiceContextPath = config?.choiceContext ? new Path([], [config.choiceContext]) : config?.context ? new Path([], [config.context]) : path
|
||||
const set = (value: string) => {
|
||||
const c = choices.find(c => c.type === value) ?? choice
|
||||
console.log(c)
|
||||
path.model.set(path, c.change ? c.change(value) : c.node.default())
|
||||
}
|
||||
const inject = <select value={choice.type} onChange={(e) => set((e.target as HTMLSelectElement).value)}>
|
||||
{choices.map(c => <option value={c.type}>
|
||||
{pathLocale(lang, choiceContextPath.contextPush(c.type))}
|
||||
</option>)}
|
||||
</select>
|
||||
return [prefix, <>{inject}{suffix}</>, body]
|
||||
},
|
||||
|
||||
list({ children }, path, value, lang, states) {
|
||||
const onAdd = () => {
|
||||
if (!Array.isArray(value)) value = []
|
||||
path.model.set(path, [children.default(), ...value])
|
||||
}
|
||||
const onAddBottom = () => {
|
||||
if (!Array.isArray(value)) value = []
|
||||
path.model.set(path, [...value, children.default()])
|
||||
}
|
||||
const suffix = <button class="add" onClick={onAdd}>{Octicon.plus_circle}</button>
|
||||
const body = <>
|
||||
{(value && Array.isArray(value)) && value.map((cValue, index) => {
|
||||
const cPath = path.push(index).contextPush('entry')
|
||||
const onRemove = () => cPath.set(undefined)
|
||||
const onMoveUp = () => {
|
||||
const v = [...value];
|
||||
[v[index - 1], v[index]] = [v[index], v[index - 1]]
|
||||
path.model.set(path, v)
|
||||
}
|
||||
const onMoveDown = () => {
|
||||
const v = [...value];
|
||||
[v[index + 1], v[index]] = [v[index], v[index + 1]]
|
||||
path.model.set(path, v)
|
||||
}
|
||||
return <div class="node-entry">
|
||||
<TreeNode path={cPath} schema={children} value={cValue} lang={lang} states={states}>
|
||||
<button class="remove" onClick={onRemove}>{Octicon.trashcan}</button>
|
||||
{value.length > 1 && <div class="node-move">
|
||||
<button class="move" onClick={onMoveUp} disabled={index === 0}>{Octicon.chevron_up}</button>
|
||||
<button class="move" onClick={onMoveDown} disabled={index === value.length - 1}>{Octicon.chevron_down}</button>
|
||||
</div>}
|
||||
</TreeNode>
|
||||
</div>
|
||||
})}
|
||||
{(value && value.length > 2) && <div class="node-entry">
|
||||
<div class="node node-header">
|
||||
<button class="add" onClick={onAddBottom}>{Octicon.plus_circle}</button>
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
return [null, suffix, body]
|
||||
},
|
||||
|
||||
map({ children, keys, config }, path, value, lang, states) {
|
||||
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
|
||||
const onAdd = () => {
|
||||
const key = keyPath.get()
|
||||
path.model.set(path.push(key), children.default())
|
||||
}
|
||||
const blockState = config.validation?.validator === 'block_state_map' ? states?.[relativePath(path, config.validation.params.id).get()] : null
|
||||
const keysSchema = blockState?.properties
|
||||
? StringNode(null!, { enum: Object.keys(blockState.properties ?? {}) })
|
||||
: keys
|
||||
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, lang, states)
|
||||
}
|
||||
const suffix = <>
|
||||
{keysSchema.hook(this, keyPath, keyPath.get() ?? '', lang, states)[1]}
|
||||
<button class="add" onClick={onAdd}>{Octicon.plus_circle}</button>
|
||||
</>
|
||||
const body = <>
|
||||
{typeof value === 'object' && Object.entries(value).map(([key, cValue]) => {
|
||||
const cPath = path.modelPush(key)
|
||||
const cSchema = blockState
|
||||
? StringNode(null!, { enum: blockState.properties?.[key] ?? [] })
|
||||
: children
|
||||
if (blockState?.properties?.[key] && typeof cValue === 'string'
|
||||
&& !blockState.properties?.[key].includes(cValue)) {
|
||||
path.model.errors.add(cPath, 'error.invalid_enum_option', cValue)
|
||||
}
|
||||
const onRemove = () => cPath.set(undefined)
|
||||
return <div class="node-entry" key={key}>
|
||||
<TreeNode schema={cSchema} path={cPath} value={cValue} lang={lang} states={states} label={key}>
|
||||
<button class="remove" onClick={onRemove}>{Octicon.trashcan}</button>
|
||||
</TreeNode>
|
||||
</div>
|
||||
})}
|
||||
</>
|
||||
return [null, suffix, body]
|
||||
},
|
||||
|
||||
number(params, path, value, lang, states) {
|
||||
return [null, <NumberSuffix {...{...params, path, value, lang, states}} />, null]
|
||||
},
|
||||
|
||||
object({ node, getActiveFields, getChildModelPath }, path, value, lang, states) {
|
||||
let prefix: JSX.Element | null = null
|
||||
let suffix: JSX.Element | null = null
|
||||
if (node.optional()) {
|
||||
if (value === undefined) {
|
||||
const onExpand = () => path.set(node.default())
|
||||
suffix = <button class="collapse closed" onClick={onExpand}>{Octicon.plus_circle}</button>
|
||||
} else {
|
||||
const onCollapse = () => path.set(undefined)
|
||||
suffix = <button class="collapse open" onClick={onCollapse}>{Octicon.trashcan}</button>
|
||||
}
|
||||
}
|
||||
const body = <>
|
||||
{(typeof value === 'object' && !(node.optional() && value === undefined)) &&
|
||||
Object.entries(getActiveFields(path))
|
||||
.filter(([_, child]) => child.enabled(path))
|
||||
.map(([key, child]) => {
|
||||
const cPath = getChildModelPath(path, key)
|
||||
const context = cPath.getContext().join('.')
|
||||
if (hiddenFields.includes(context)) return null
|
||||
const [cPrefix, cSuffix, cBody] = child.hook(this, cPath, value[key], lang, states)
|
||||
if (!cPrefix && !cSuffix && !((cBody?.props?.children?.length ?? 0) > 0)) return null
|
||||
const isFlattened = child.type(cPath) === 'object' && flattenedFields.includes(context)
|
||||
const isInlined = inlineFields.includes(context)
|
||||
if (isFlattened || isInlined) {
|
||||
prefix = <>{prefix}<ErrorPopup lang={lang} path={cPath} /><HelpPopup lang={lang} path={cPath} />{cPrefix}</>
|
||||
suffix = <>{suffix}{cSuffix}</>
|
||||
return isFlattened ? cBody : null
|
||||
}
|
||||
return <TreeNode schema={child} path={cPath} value={value[key]} lang={lang} states={states} />
|
||||
})
|
||||
}
|
||||
</>
|
||||
return [prefix, suffix, body]
|
||||
},
|
||||
|
||||
string(params, path, value, lang, states) {
|
||||
return [null, <StringSuffix {...{...params, path, value, lang, states}} />, null]
|
||||
},
|
||||
}
|
||||
|
||||
function BooleanSuffix({ path, node, value, lang }: NodeProps<BooleanHookParams>) {
|
||||
const set = (target: boolean) => {
|
||||
path.model.set(path, node.optional() && value === target ? undefined : target)
|
||||
}
|
||||
return <>
|
||||
<button class={value === false ? 'selected' : ''} onClick={() => set(false)}>{locale(lang, 'false')}</button>
|
||||
<button class={value === true ? 'selected' : ''} onClick={() => set(true)}>{locale(lang, 'true')}</button>
|
||||
</>
|
||||
}
|
||||
|
||||
function NumberSuffix({ path, config, integer, value }: NodeProps<NumberHookParams>) {
|
||||
const onChange = (evt: Event) => {
|
||||
const value = (evt.target as HTMLInputElement).value
|
||||
const parsed = config?.color
|
||||
? parseInt(value.slice(1), 16)
|
||||
: integer ? parseInt(value) : parseFloat(value)
|
||||
path.model.set(path, parsed)
|
||||
}
|
||||
return <input type={config?.color ? 'color' : 'text'} onChange={onChange}
|
||||
value={config?.color ? '#' + value?.toString(16).padStart(6, '0') ?? '#000000' : value ?? ''} />
|
||||
}
|
||||
|
||||
function StringSuffix({ path, getValues, config, node, value, lang, states }: NodeProps<StringHookParams>) {
|
||||
const onChange = (evt: Event) => {
|
||||
const newValue = (evt.target as HTMLSelectElement).value
|
||||
path.model.set(path, newValue.length === 0 ? undefined : newValue)
|
||||
evt.stopPropagation()
|
||||
}
|
||||
const values = getValues()
|
||||
const context = path.getContext().join('.')
|
||||
if (nbtFields.includes(context)) {
|
||||
return <textarea value={value ?? ''} onChange={onChange}></textarea>
|
||||
} else if ((isEnum(config) && !config.additional) || selectRegistries.includes(context)) {
|
||||
let context = new Path([])
|
||||
if (isEnum(config) && typeof config.enum === 'string') {
|
||||
context = context.contextPush(config.enum)
|
||||
} else if (!isEnum(config) && config?.validator === 'resource' && typeof config.params.pool === 'string') {
|
||||
context = context.contextPush(config.params.pool)
|
||||
}
|
||||
return <select value={value ?? ''} onChange={onChange}>
|
||||
{node.optional() && <option value="">{locale(lang, 'unset')}</option>}
|
||||
{values.map(v => <option value={v}>
|
||||
{pathLocale(lang, context.contextPush(v.replace(/^minecraft:/, '')))}
|
||||
</option>)}
|
||||
</select>
|
||||
} else if (!isEnum(config) && config?.validator === 'block_state_key') {
|
||||
const blockState = states?.[relativePath(path, config.params.id).get()]
|
||||
const values = Object.keys(blockState?.properties ?? {})
|
||||
return <select value={value ?? ''} onChange={onChange}>
|
||||
{values.map(v => <option>{v}</option>)}
|
||||
</select>
|
||||
} else {
|
||||
const datalistId = hexId()
|
||||
return <>
|
||||
<input value={value ?? ''} onChange={onChange}
|
||||
list={values.length > 0 ? datalistId : ''} />
|
||||
{values.length > 0 && <datalist id={datalistId}>
|
||||
{values.map(v => <option value={v} />)}
|
||||
</datalist>}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
type TreeNodeProps = {
|
||||
schema: INode<any>,
|
||||
path: ModelPath,
|
||||
value: any,
|
||||
lang: string,
|
||||
states: BlockStateRegistry,
|
||||
compare?: any,
|
||||
label?: string,
|
||||
children?: ComponentChildren,
|
||||
}
|
||||
function TreeNode({ label, schema, path, value, lang, states, children }: TreeNodeProps) {
|
||||
const type = schema.type(path)
|
||||
const category = schema.category(path)
|
||||
const [prefix, suffix, body] = schema.hook(renderHtml, path, value, lang, states)
|
||||
return <div class={`node ${type}-node`} data-category={category}>
|
||||
<div class="node-header">
|
||||
<ErrorPopup lang={lang} path={path} />
|
||||
<HelpPopup lang={lang} path={path} />
|
||||
{children}
|
||||
{prefix}
|
||||
<label>{label ?? pathLocale(lang, path, `${path.last()}`)}</label>
|
||||
{suffix}
|
||||
</div>
|
||||
{body && <div class="node-body">{body}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function isEnum(value?: ValidationOption | EnumOption): value is EnumOption {
|
||||
return !!(value as any)?.enum
|
||||
}
|
||||
|
||||
function hashString(str: string) {
|
||||
var hash = 0, i, chr
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
function pathLocale(lang: string, path: Path, ...params: string[]) {
|
||||
const ctx = path.getContext()
|
||||
for (let i = 0; i < ctx.length; i += 1) {
|
||||
const key = ctx.slice(i).join('.')
|
||||
const result = locale(lang, key, ...params)
|
||||
if (key !== result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return ctx[ctx.length - 1]
|
||||
}
|
||||
|
||||
function ErrorPopup({ lang, path }: { lang: string, path: ModelPath }) {
|
||||
const e = path.model.errors.get(path, true)
|
||||
if (e.length === 0) return null
|
||||
const message = locale(lang, e[0].error, ...(e[0].params ?? []))
|
||||
return popupIcon('node-error', 'issue_opened', message)
|
||||
}
|
||||
|
||||
function HelpPopup({ lang, path }: { lang: string, path: Path }) {
|
||||
const key = path.contextPush('help').getContext().join('.')
|
||||
const message = locale(lang, key)
|
||||
if (message === key) return null
|
||||
return popupIcon('node-help', 'info', message)
|
||||
}
|
||||
|
||||
const popupIcon = (type: string, icon: keyof typeof Octicon, popup: string) => {
|
||||
const [active, setActive] = useFocus()
|
||||
|
||||
return <div class={`node-icon ${type}${active ? ' show' : ''}`} onClick={setActive}>
|
||||
{Octicon[icon]}
|
||||
<span class="icon-popup">{popup}</span>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user