diff --git a/package-lock.json b/package-lock.json index c23f5286..1bfd240a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@mcschema/java-1.16": "^0.6.3", "@mcschema/java-1.17": "^0.2.23", "@mcschema/locales": "^0.1.20", + "rfdc": "^1.3.0", "seedrandom": "^3.0.5", "split.js": "^1.5.11" }, @@ -2084,6 +2085,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4070,6 +4076,11 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index 4faf06e8..6abf3df9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mcschema/java-1.16": "^0.6.3", "@mcschema/java-1.17": "^0.2.23", "@mcschema/locales": "^0.1.20", + "rfdc": "^1.3.0", "seedrandom": "^3.0.5", "split.js": "^1.5.11" }, diff --git a/src/app/components/BtnInput.tsx b/src/app/components/BtnInput.tsx index 468dacfd..4e28007d 100644 --- a/src/app/components/BtnInput.tsx +++ b/src/app/components/BtnInput.tsx @@ -11,7 +11,7 @@ type BtnInputProps = { onChange?: (value: string) => unknown, } export function BtnInput({ icon, label, large, type, doSelect, value, onChange }: BtnInputProps) { - const onKeyUp = onChange === undefined ? () => {} : (e: any) => { + const onInput = onChange === undefined ? () => {} : (e: any) => { const value = (e.target as HTMLInputElement).value if (type !== 'number' || (!value.endsWith('.') && !isNaN(Number(value)))) { onChange?.(value) @@ -28,6 +28,6 @@ export function BtnInput({ icon, label, large, type, doSelect, value, onChange } return
e.stopPropagation()}> {icon && Octicon[icon]} {label && {label}} - +
} diff --git a/src/app/components/BtnMenu.tsx b/src/app/components/BtnMenu.tsx index f62e66a0..a0aa7247 100644 --- a/src/app/components/BtnMenu.tsx +++ b/src/app/components/BtnMenu.tsx @@ -1,7 +1,7 @@ import type { ComponentChildren } from 'preact' -import { useEffect, useState } from 'preact/hooks' import type { Octicon } from '.' import { Btn } from '.' +import { useFocus } from '../hooks' type BtnMenuProps = { icon?: keyof typeof Octicon, @@ -10,23 +10,10 @@ type BtnMenuProps = { children: ComponentChildren, } export function BtnMenu({ icon, label, relative, children }: BtnMenuProps) { - const [active, setActive] = useState(false) - - const hider = () => { - setActive(false) - } - - useEffect(() => { - if (active) { - document.body.addEventListener('click', hider) - } - return () => { - document.body.removeEventListener('click', hider) - } - }, [active]) + const [active, setActive] = useFocus() return
- setActive(true)} /> + {active &&
{children}
} diff --git a/src/app/components/Octicon.tsx b/src/app/components/Octicon.tsx index a4d95f4a..eb506f57 100644 --- a/src/app/components/Octicon.tsx +++ b/src/app/components/Octicon.tsx @@ -4,6 +4,7 @@ export const Octicon = { arrow_right: , chevron_down: , chevron_right: , + chevron_up: , clippy: , code: , dash: , @@ -14,17 +15,21 @@ export const Octicon = { gear: , globe: , history: , + info: , + issue_opened: , kebab_horizontal: , link: , mark_github: , moon: , play: , plus: , + plus_circle: , search: , sun: , sync: , tag: , three_bars: , + trashcan: , unfold: , upload: , x: , diff --git a/src/app/components/SourcePanel.tsx b/src/app/components/SourcePanel.tsx index d76a16b4..deb8ef0e 100644 --- a/src/app/components/SourcePanel.tsx +++ b/src/app/components/SourcePanel.tsx @@ -49,6 +49,9 @@ export function SourcePanel({ lang, name, model, blockStates, doCopy, doDownload useModel(model, () => { retransform.current() }) + useEffect(() => { + if (model) retransform.current() + }, [model]) useEffect(() => { retransform.current() diff --git a/src/app/components/Tree.tsx b/src/app/components/Tree.tsx index e2ed0e4a..460b2dc8 100644 --- a/src/app/components/Tree.tsx +++ b/src/app/components/Tree.tsx @@ -1,11 +1,12 @@ import type { DataModel } from '@mcschema/core' import { ModelPath } from '@mcschema/core' -import { useEffect, useRef } from 'preact/hooks' +import type { JSX } from 'preact' +import { useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks' +import rfdc from 'rfdc' import { useModel } from '../hooks' -import { locale } from '../Locales' -import { Mounter } from '../schema/Mounter' import { renderHtml } from '../schema/renderHtml' import type { BlockStateRegistry, VersionId } from '../Schemas' +const clone = rfdc() type TreePanelProps = { lang: string, @@ -14,44 +15,29 @@ type TreePanelProps = { blockStates: BlockStateRegistry | null, onError: (message: string) => unknown, } -export function Tree({ lang, model, version, blockStates, onError }: TreePanelProps) { - const tree = useRef(null) - const redraw = useRef() +export function Tree({ lang, model, blockStates, onError }: TreePanelProps) { + if (!model || !blockStates) return <> - useEffect(() => { - redraw.current = () => { - if (!model || !blockStates) return - try { - const mounter = new 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) - const type = model.schema.type(path) - let html = rendered[2] - if (rendered[1]) { - html = `
-
${rendered[0]}${rendered[1]}
-
${rendered[2]}
-
` - } - tree.current.innerHTML = html - mounter.mounted(tree.current) - } catch (e) { - onError(`Error rendering the tree: ${e.message}`) - console.error(e) - tree.current.innerHTML = '' - } - } + const [error] = useErrorBoundary(e => { + onError(`Error rendering the tree: ${e.message}`) + console.error(e) }) + if (error) return <> + const [state, setState] = useState(0) useModel(model, () => { - redraw.current() + setState(state => state + 1) }) - useEffect(() => { - redraw.current() - }, [lang, model, blockStates]) + const path = new ModelPath(model) + const tree = useRef(null) + useMemo(() => { + const [prefix, suffix, body] = model.schema.hook(renderHtml, path, clone(model.data), lang, blockStates) + tree.current = suffix?.props?.children.some((c: any) => c) ?
+
{prefix}{suffix}
+
{body}
+
: body + }, [lang, model, blockStates, state]) - return
+ return
{tree.current}
} diff --git a/src/app/hooks/index.ts b/src/app/hooks/index.ts index 3d84cf07..b94b6ade 100644 --- a/src/app/hooks/index.ts +++ b/src/app/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useFocus' export * from './useModel' export * from './useOnDrag' export * from './useOnHover' diff --git a/src/app/hooks/useFocus.ts b/src/app/hooks/useFocus.ts new file mode 100644 index 00000000..447d9965 --- /dev/null +++ b/src/app/hooks/useFocus.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'preact/hooks' + +export function useFocus(): [boolean, () => unknown] { + const [active, setActive] = useState(false) + + const hider = () => { + setActive(false) + } + + useEffect(() => { + if (active) { + document.body.addEventListener('click', hider) + } + return () => { + document.body.removeEventListener('click', hider) + } + }, [active]) + + return [active, () => setActive(true)] +} diff --git a/src/app/hooks/useModel.ts b/src/app/hooks/useModel.ts index f35bc5d9..996ea35e 100644 --- a/src/app/hooks/useModel.ts +++ b/src/app/hooks/useModel.ts @@ -12,7 +12,6 @@ export function useModel(model: DataModel | undefined | null, invalidated: (mode useEffect(() => { model?.addListener(listener) - listener.invalidated() return () => { model?.removeListener(listener) } diff --git a/src/app/schema/Octicon.ts b/src/app/schema/Octicon.ts deleted file mode 100644 index 5d5d08aa..00000000 --- a/src/app/schema/Octicon.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const Octicon = { - chevron_down: '', - chevron_up: '', - clippy: '', - info: '', - issue_opened: '', - plus_circle: '', - trashcan: '', -} diff --git a/src/app/schema/renderHtml.ts b/src/app/schema/renderHtml.ts deleted file mode 100644 index 0d576649..00000000 --- a/src/app/schema/renderHtml.ts +++ /dev/null @@ -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 ['', `${htmlEncode(props.loc('false'))} - ${htmlEncode(props.loc('true'))}`, ''] - }, - - 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 = `` - - 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 = `` - - 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 `
-
- ${error(props.loc, childPath, props.mounter)} - ${help(props.loc, childPath, props.mounter)} - - ${value.length <= 1 ? '' : `
- - -
`} - ${cPrefix} - - ${cSuffix} -
- ${cBody ? `
${cBody}
` : ''} -
-
` - }).join('') - if (value.length > 2) { - body += `
-
- -
-
` - } - } - 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] + `` - 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 `
-
- ${error(props.loc, childPath, props.mounter)} - ${help(props.loc, childPath, props.mounter)} - - ${cPrefix} - - ${cSuffix} -
- ${cBody ? `
${cBody}
` : ''} -
-
` - }) - .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 ['', ``, ''] - } - return ['', ``, ''] - }, - - object({ node, getActiveFields, getChildModelPath }, path, value, props) { - let prefix = '' - let suffix = '' - if (node.optional()) { - if (value === undefined) { - suffix = `` - } else { - suffix = `` - } - } - 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 `
-
- ${error(props.loc, childPath, props.mounter)} - ${help(props.loc, childPath, props.mounter)} - ${cPrefix} - - ${cSuffix} -
- ${cBody ? `
${cBody}
` : ''} -
` - }) - .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 = `` - } 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 = `` - } 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 = `` - } else { - const datalistId = hexId() - suffix = ` - ${values.length === 0 ? '' : ` - ${values.map(v => ``}` - } - 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 `
- ${Octicon[icon]} - ${popup} -
` -} - -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', `${message}`) - } - - const context = path.getContext().join('.') - popup.insertAdjacentHTML('beforeend', ` - `) - 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}"` -} diff --git a/src/app/schema/renderHtml.tsx b/src/app/schema/renderHtml.tsx new file mode 100644 index 00000000..c1240ba8 --- /dev/null +++ b/src/app/schema/renderHtml.tsx @@ -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 & { node: INode } & { 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, , 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 = + 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 = + 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
+ + + {value.length > 1 &&
+ + +
} +
+
+ })} + {(value && value.length > 2) &&
+
+ +
+
} + + 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]} + + + 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
+ + + +
+ })} + + return [null, suffix, body] + }, + + number(params, path, value, lang, states) { + return [null, , 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 = + } else { + const onCollapse = () => path.set(undefined) + suffix = + } + } + 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}{cPrefix} + suffix = <>{suffix}{cSuffix} + return isFlattened ? cBody : null + } + return + }) + } + + return [prefix, suffix, body] + }, + + string(params, path, value, lang, states) { + return [null, , null] + }, +} + +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 }: NodeProps) { + 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 +} + +function StringSuffix({ path, getValues, config, node, value, lang, states }: NodeProps) { + 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 + } 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 <> + 0 ? datalistId : ''} /> + {values.length > 0 && + {values.map(v => } + + } +} + +type TreeNodeProps = { + schema: INode, + 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
+
+ + + {children} + {prefix} + + {suffix} +
+ {body &&
{body}
} +
+} + +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
+ {Octicon[icon]} + {popup} +
+} diff --git a/src/styles/nodes.css b/src/styles/nodes.css index 3b6381c1..ea7598ef 100644 --- a/src/styles/nodes.css +++ b/src/styles/nodes.css @@ -148,6 +148,10 @@ border-color: var(--node-remove) !important; } +.node-header > *:focus { + position: relative; +} + /** Rounded corners */ .node-header > .node-icon { @@ -267,7 +271,7 @@ button.move:disabled { } .node-icon svg:hover + .icon-popup, -.node-icon .icon-popup.show { +.node-icon.show .icon-popup { visibility: visible; }