import type { BooleanHookParams, EnumOption, Hook, INode, NodeChildren, NumberHookParams, StringHookParams, ValidationOption } from '@mcschema/core' import { DataModel, ListNode, MapNode, ModelPath, ObjectNode, Path, relativePath, StringNode } from '@mcschema/core' import type { ComponentChildren, JSX } from 'preact' import { memo } from 'preact/compat' import { useState } from 'preact/hooks' import { Btn } from '../components' import { Octicon } from '../components/Octicon' import { useFocus } from '../hooks' import { locale } from '../Locales' import type { BlockStateRegistry } from '../Schemas' import { CachedDecorator, CachedFeature } from '../Schemas' import { deepClone, deepEqual, hexId, isObject, newSeed } from '../Utils' import { ModelWrapper } from './ModelWrapper' const selectRegistries = ['loot_table.type', 'loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'dimension.generator.biome_source.preset', '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', 'block_predicate.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', 'decorator.block_survives_filter.state'] 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', 'generator_biome.biome'] const nbtFields = ['function.set_nbt.tag', 'advancement.display.icon.nbt', 'text_component_object.nbt', 'entity.nbt', 'block.nbt', 'item.nbt'] const fixedLists = ['generator_biome.parameters.temperature', 'generator_biome.parameters.humidity', 'generator_biome.parameters.continentalness', 'generator_biome.parameters.erosion', 'generator_biome.parameters.depth', 'generator_biome.parameters.weirdness', 'feature.end_spike.crystal_beam_target', 'feature.end_gateway.exit', 'decorator.block_filter.offset', 'block_predicate.matching_blocks.offset', 'block_predicate.matching_fluids.offset'] /** * 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, Record], JSXTriple> type NodeProps = T & { node: INode, path: ModelPath, value: any, lang: string, states: BlockStateRegistry, ctx: Record, } export function FullNode({ model, lang, blockStates }: { model: DataModel, lang: string, blockStates: BlockStateRegistry }) { const path = new ModelPath(model) const [prefix, suffix, body] = model.schema.hook(renderHtml, path, deepClone(model.data), lang, blockStates, {}) return suffix?.props?.children.some((c: any) => c) ?
{prefix}{suffix}
{body}
: body } const renderHtml: RenderHook = { base() { return [null, null, null] }, boolean(params, path, value, lang, states, ctx) { return [null, , null] }, choice({ choices, config, switchNode }, path, value, lang, states, ctx) { 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, ctx) 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 const newValue = c.change ? c.change(value, { wrapLists: true }) : DataModel.wrapLists(config.choiceContext === 'feature' ? c.node.default()?.config?.feature : c.node.default()) path.model.set(path, newValue) } const inject = return [prefix, <>{inject}{suffix}, body] }, list({ children, config }, path, value, lang, states, ctx) { const { expand, collapse, isToggled } = useToggles() const [maxShown, setMaxShown] = useState(50) const context = path.getContext().join('.') if (fixedLists.includes(context)) { const prefix = <> {[...Array(config.maxLength!)].map((_, i) => )}
const suffix = <>{[...Array(config.maxLength)].map((_, i) => { const child = children.hook(this, path.modelPush(i), value?.[i]?.node, lang, states, ctx) return child[1] })} return [prefix, suffix, null] } const onAdd = () => { if (!Array.isArray(value)) value = [] const node = DataModel.wrapLists(children.default()) path.model.set(path, [{ node, id: hexId() }, ...value]) } const onAddBottom = () => { if (!Array.isArray(value)) value = [] const node = DataModel.wrapLists(children.default()) path.model.set(path, [...value, { node, id: hexId() }]) } const suffix = const body = <> {(value && Array.isArray(value)) && value.map(({ node: cValue, id: cId }, index) => { if (index === maxShown) { return
} if (index > maxShown) { return null } const cPath = path.push(index).contextPush('entry') const canToggle = children.type(cPath) === 'object' const toggle = isToggled(cId) if (canToggle && (toggle === false || (toggle === undefined && value.length > 20))) { return
} const onRemove = () => cPath.set(undefined) const onMoveUp = () => { const v = [...path.get()]; [v[index - 1], v[index]] = [v[index], v[index - 1]] path.model.set(path, v) } const onMoveDown = () => { const v = [...path.get()]; [v[index + 1], v[index]] = [v[index], v[index + 1]] path.model.set(path, v) } const actions: MenuAction[] = [ { icon: 'duplicate', label: 'duplicate', onSelect: () => { const v = [...path.get()] v.splice(index, 0, { id: hexId(), node: deepClone(cValue) }) path.model.set(path, v) }, }, ] return {canToggle && } {value.length > 1 &&
}
})} {(value && value.length > 0 && value.length <= maxShown) &&
} return [null, suffix, body] }, map({ children, keys, config }, path, value, lang, states, ctx) { const { expand, collapse, isToggled } = useToggles() const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())])) const onAdd = () => { const key = keyPath.get() path.model.set(path.push(key), DataModel.wrapLists(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, ctx) } const suffix = <> {keysSchema.hook(this, keyPath, keyPath.get() ?? '', lang, states, ctx)[1]} const body = <> {typeof value === 'object' && Object.entries(value).map(([key, cValue]) => { const cPath = path.modelPush(key) const canToggle = children.type(cPath) === 'object' const toggle = isToggled(key) if (canToggle && (toggle === false || (toggle === undefined && value.length > 20))) { return
} 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 {canToggle && } })} return [null, suffix, body] }, number(params, path, value, lang, states, ctx) { return [null, , null] }, object({ node, config, getActiveFields, getChildModelPath }, path, value, lang, states, ctx) { if (path.getArray().length == 0 && isDecorated(config.context, value)) { const { wrapper, fields } = createDecoratorsWrapper(getActiveFields(path), path, value) value = wrapper.data getActiveFields = () => fields getChildModelPath = (path, key) => new ModelPath(wrapper, new Path(path.getArray(), ['feature'])).push(key) } let prefix: JSX.Element | null = null let suffix: JSX.Element | null = null if (node.optional()) { if (value === undefined) { const onExpand = () => path.set(DataModel.wrapLists(node.default())) suffix = } else { const onCollapse = () => path.set(undefined) suffix = } } const newCtx = (typeof value === 'object' && value !== null && node.default()?.pools) ? { ...ctx, loot: value?.type } : ctx const body = <> {(typeof value === 'object' && value !== null && !(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, newCtx) 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}{cPrefix} suffix = <>{suffix}{cSuffix} return isFlattened ? cBody : null } return }) } return [prefix, suffix, body] }, string(params, path, value, lang, states, ctx) { return [null, , null] }, } function Collapsed({ path, value }: { path: ModelPath, value: any, schema: INode }) { const context = path.getContext().join('.') switch (context) { case 'loot_table.pools.entry': return case 'function.set_contents.entries.entry': case 'loot_pool.entries.entry': return } for (const child of Object.values(value ?? {})) { if (typeof child === 'string') { return } } return null } function useToggles() { const [toggleState, setToggleState] = useState(new Map()) const [toggleAll, setToggleAll] = useState(undefined) const expand = (key: string) => (evt: MouseEvent) => { if (evt.ctrlKey) { setToggleState(new Map()) setToggleAll(true) } else { setToggleState(state => new Map(state.set(key, true))) } } const collapse = (key: string) => (evt: MouseEvent) => { if (evt.ctrlKey) { setToggleState(new Map()) setToggleAll(false) } else { setToggleState(state => new Map(state.set(key, false))) } } const isToggled = (key: string) => { return toggleState?.get(key) ?? toggleAll } return { expand, collapse, isToggled } } function BooleanSuffix({ path, node, value, lang }: NodeProps) { const set = (target: boolean) => { path.model.set(path, node.optional() && value === target ? undefined : target) } return <> } function NumberSuffix({ path, config, integer, value, lang }: NodeProps) { const onChange = (evt: Event) => { const value = (evt.target as HTMLInputElement).value const parsed = integer ? parseInt(value) : parseFloat(value) path.model.set(path, parsed) } const onColor = (evt: Event) => { const value = (evt.target as HTMLInputElement).value const parsed = parseInt(value.slice(1), 16) path.model.set(path, parsed) } return <> {if (evt.key === 'Enter') onChange(evt)}} /> {config?.color && } {['dimension.generator.seed', 'dimension.generator.biome_source.seed', 'world_settings.seed'].includes(path.getContext().join('.')) && } } function StringSuffix({ path, getValues, config, node, value, lang, states }: NodeProps) { const onChange = (evt: Event) => { evt.stopPropagation() const newValue = (evt.target as HTMLSelectElement).value if (newValue === value) return path.model.set(path, newValue.length === 0 ? undefined : newValue) } const values = getValues() const context = path.getContext().join('.') if (nbtFields.includes(context)) { return } 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 } 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 } else { const datalistId = hexId() return <> {if (evt.key === 'Enter') onChange(evt)}} list={values.length > 0 ? datalistId : ''} /> {values.length > 0 && {values.map(v => } } } type MenuAction = { label: string, description?: string, icon: keyof typeof Octicon, onSelect: () => unknown, } type TreeNodeProps = { schema: INode, path: ModelPath, value: any, lang: string, states: BlockStateRegistry, ctx: Record, compare?: any, label?: string, actions?: MenuAction[], children?: ComponentChildren, } function TreeNode({ label, schema, path, value, lang, states, ctx, actions, children }: TreeNodeProps) { const type = schema.type(path) const category = schema.category(path) const context = path.getContext().join('.') const [active, setActive] = useFocus() const onContextMenu = (evt: MouseEvent) => { evt.preventDefault() setActive() } const newCtx = {...ctx} delete newCtx.index const [prefix, suffix, body] = schema.hook(renderHtml, path, value, lang, states, newCtx) return
{children} {prefix} {suffix}
{body &&
{body}
}
} const MemoedTreeNode = memo(TreeNode, (prev, next) => { return prev.schema === next.schema && prev.lang === next.lang && prev.path.equals(next.path) && deepEqual(prev.ctx, next.ctx) && deepEqual(prev.value, next.value) }) 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, nested }: { lang: string, path: ModelPath, nested?: boolean }) { if (path.model instanceof ModelWrapper) { path = path.model.map(path).withModel(path.model) } const e = nested ? path.model.errors.getAll().filter(e => e.path.startsWith(path)) : 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
{Octicon[icon]} {popup}
} function isDecorated(context: string | undefined, value: any) { return context === 'feature' && value?.type?.replace(/^minecraft:/, '') === 'decorated' && isObject(value?.config) } function createDecoratorsWrapper(originalFields: NodeChildren, path: ModelPath, value: any) { const decorators: any[] = [] const feature = iterateNestedDecorators(value, decorators) const fields = { type: originalFields.type, config: ObjectNode({ decorators: ListNode(CachedDecorator), feature: CachedFeature, }, { context: 'feature.decorated' }), } const schema = ObjectNode(fields, { context: 'feature' }) const featurePath = new Path(['config', 'feature']) const decoratorsPath = new Path(['config', 'decorators']) const model = path.getModel() const wrapper: ModelWrapper = new ModelWrapper(schema, path => { if (path.startsWith(featurePath)) { return new Path([...[...Array(decorators.length - 1)].flatMap(() => ['config', 'feature']), ...path.modelArr]) } else if (path.startsWith(decoratorsPath)) { if (path.modelArr.length === 2) { return new Path([]) } const index = path.modelArr[2] if (typeof index === 'number') { return new Path([...[...Array(index)].flatMap(() => ['config', 'feature']), 'config', 'decorator', ...path.modelArr.slice(3)]) } } return path }, path => { if (path.equals(decoratorsPath)) { const newDecorators: any[] = [] iterateNestedDecorators(model.data, newDecorators) return newDecorators } return model.get(wrapper.map(path)) }, (path, value, silent) => { if (path.startsWith(featurePath)) { const newDecorators: any[] = [] iterateNestedDecorators(model.data, newDecorators) const newPath =new Path([...[...Array(newDecorators.length - 1)].flatMap(() => ['config', 'feature']), ...path.modelArr]) return model.set(newPath, value, silent) } else if (path.startsWith(decoratorsPath)) { const index = path.modelArr[2] if (path.modelArr.length === 2) { const feature = wrapper.get(featurePath) return model.set(new Path(), produceNestedDecorators(feature, value), silent) } else if (typeof index === 'number') { if (path.modelArr.length === 3 && value === undefined) { const feature = wrapper.get(featurePath) const newDecorators: any[] = [] iterateNestedDecorators(model.data, newDecorators) newDecorators.splice(index, 1) const newValue = produceNestedDecorators(feature, newDecorators) return model.set(new Path(), newValue, silent) } else { const newPath = new Path([...[...Array(index)].flatMap(() => ['config', 'feature']), 'config', 'decorator', ...path.modelArr.slice(3)]) return model.set(newPath, value, silent) } } } model.set(path, value, silent) }) wrapper.data = { type: model.data.type, config: { decorators, feature, }, } wrapper.errors = model.errors return { fields, wrapper } } function iterateNestedDecorators(value: any, decorators: any[]): any { if (value?.type?.replace(/^minecraft:/, '') !== 'decorated') { return value } if (!isObject(value?.config)) { return value } decorators.push({ id: decorators.length, node: value.config.decorator }) return iterateNestedDecorators(value.config.feature ?? '', decorators) } function produceNestedDecorators(feature: any, decorators: any[]): any { if (decorators.length === 0) return feature return { type: 'minecraft:decorated', config: { decorator: decorators.shift().node, feature: produceNestedDecorators(feature, decorators), }, } }