From 7db47938b8e738e4e39172463c77e92d7f64fa71 Mon Sep 17 00:00:00 2001 From: Misode Date: Fri, 24 Sep 2021 22:12:33 +0200 Subject: [PATCH] Make list and map entries collapsible (#169) * Make list entries collapsible * Show errors in collapsed nodes and keep context of loot type * Make map entries collapsible * Add collapse-all functionality --- src/app/Utils.ts | 2 +- src/app/components/Tree.tsx | 24 +-- .../previews/BiomeSourcePreview.tsx | 2 +- src/app/schema/renderHtml.tsx | 187 +++++++++++++----- src/locales/en.json | 3 + src/styles/nodes.css | 6 + 6 files changed, 158 insertions(+), 66 deletions(-) diff --git a/src/app/Utils.ts b/src/app/Utils.ts index 9d4732dc..272e6478 100644 --- a/src/app/Utils.ts +++ b/src/app/Utils.ts @@ -108,7 +108,7 @@ export function deepEqual(a: any, b: any) { if (Array.isArray(a)) { length = a.length if (length != b.length) return false - for (i = length; i-- !== 0;) { + for (i = 0; i < length; i++) { if (!deepEqual(a[i], b[i])) return false } return true diff --git a/src/app/components/Tree.tsx b/src/app/components/Tree.tsx index 460b2dc8..114e240a 100644 --- a/src/app/components/Tree.tsx +++ b/src/app/components/Tree.tsx @@ -1,12 +1,8 @@ import type { DataModel } from '@mcschema/core' -import { ModelPath } from '@mcschema/core' -import type { JSX } from 'preact' -import { useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks' -import rfdc from 'rfdc' +import { useErrorBoundary, useState } from 'preact/hooks' import { useModel } from '../hooks' -import { renderHtml } from '../schema/renderHtml' +import { FullNode } from '../schema/renderHtml' import type { BlockStateRegistry, VersionId } from '../Schemas' -const clone = rfdc() type TreePanelProps = { lang: string, @@ -24,20 +20,12 @@ export function Tree({ lang, model, blockStates, onError }: TreePanelProps) { }) if (error) return <> - const [state, setState] = useState(0) + const [, setState] = useState(0) useModel(model, () => { setState(state => state + 1) }) - 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
{tree.current}
+ return
+ +
} diff --git a/src/app/components/previews/BiomeSourcePreview.tsx b/src/app/components/previews/BiomeSourcePreview.tsx index 53869a19..841a7e47 100644 --- a/src/app/components/previews/BiomeSourcePreview.tsx +++ b/src/app/components/previews/BiomeSourcePreview.tsx @@ -17,7 +17,7 @@ export const BiomeSourcePreview = ({ model, data, shown, version }: PreviewProps const seed = BigInt(model.get(new Path(['generator', 'seed']))) const octaves = getOctaves(model.get(new Path(['generator', 'settings']))) - const state = calculateState(data, octaves) + const state = shown ? calculateState(data, octaves) : '' const type: string = data.type?.replace(/^minecraft:/, '') const { canvas, redraw } = useCanvas({ diff --git a/src/app/schema/renderHtml.tsx b/src/app/schema/renderHtml.tsx index 9c83a0f9..6a130b39 100644 --- a/src/app/schema/renderHtml.tsx +++ b/src/app/schema/renderHtml.tsx @@ -2,16 +2,13 @@ import type { BooleanHookParams, EnumOption, Hook, INode, NumberHookParams, Stri import { DataModel, 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 { useRef, 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 { deepEqual, hexId, newSeed } from '../Utils' - -const LIST_LIMIT = 20 -const LIST_LIMIT_SHOWN = 5 +import { deepClone, deepEqual, hexId, newSeed } from '../Utils' 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'] const hiddenFields = ['number_provider.type', 'score_provider.type', 'nbt_provider.type', 'int_provider.type', 'float_provider.type', 'height_provider.type'] @@ -29,7 +26,7 @@ const keysModel = new DataModel(MapNode( ), { historyMax: 0 }) type JSXTriple = [JSX.Element | null, JSX.Element | null, JSX.Element | null] -type RenderHook = Hook<[any, string, BlockStateRegistry], JSXTriple> +type RenderHook = Hook<[any, string, BlockStateRegistry, Record], JSXTriple> type NodeProps = T & { node: INode, @@ -37,25 +34,31 @@ type NodeProps = T & { value: any, lang: string, states: BlockStateRegistry, + ctx: Record, } -/** - * 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 = { +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) { - return [null, , null] + boolean(params, path, value, lang, states, ctx) { + return [null, , null] }, - choice({ choices, config, switchNode }, path, value, lang, states) { + 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) + const [prefix, suffix, body] = choice.node.hook(this, contextPath, value, lang, states, ctx) if (choices.length === 1) { return [prefix, suffix, body] } @@ -72,7 +75,10 @@ export const renderHtml: RenderHook = { return [prefix, <>{inject}{suffix}, body] }, - list({ children, config }, path, value, lang, states) { + 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 = <> @@ -81,7 +87,7 @@ export const renderHtml: RenderHook = {
const suffix = <>{[...Array(config.maxLength)].map((_, i) => { - const child = children.hook(this, path.modelPush(i), value?.[i]?.node, lang, states) + const child = children.hook(this, path.modelPush(i), value?.[i]?.node, lang, states, ctx) return child[1] })} return [prefix, suffix, null] @@ -100,13 +106,29 @@ export const renderHtml: RenderHook = { const suffix = const body = <> {(value && Array.isArray(value)) && value.map(({ node: cValue, id: cId }, index) => { - if (value.length > LIST_LIMIT && index >= LIST_LIMIT_SHOWN && index < value.length - LIST_LIMIT_SHOWN) { - if (index === LIST_LIMIT_SHOWN) { - return {value.length - LIST_LIMIT} hidden entries... - } + 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()]; @@ -118,7 +140,8 @@ export const renderHtml: RenderHook = { [v[index + 1], v[index]] = [v[index], v[index + 1]] path.model.set(path, v) } - return + return + {canToggle && } {value.length > 1 &&
@@ -126,14 +149,16 @@ export const renderHtml: RenderHook = {
}
})} - {(value && value.length > 2) &&
+ {(value && value.length > 2 && value.length <= maxShown) &&
} return [null, suffix, body] }, - map({ children, keys, config }, path, value, lang, states) { + 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() @@ -154,15 +179,26 @@ export const renderHtml: RenderHook = { path.model.errors.add(path.push(key), 'error.invalid_enum_option', value[key]) } }) - return ObjectNode(Object.fromEntries(properties)).hook(this, path, value, lang, states) + return ObjectNode(Object.fromEntries(properties)).hook(this, path, value, lang, states, ctx) } const suffix = <> - {keysSchema.hook(this, keyPath, keyPath.get() ?? '', lang, states)[1]} + {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 @@ -171,7 +207,8 @@ export const renderHtml: RenderHook = { path.model.errors.add(cPath, 'error.invalid_enum_option', cValue) } const onRemove = () => cPath.set(undefined) - return + return + {canToggle && } })} @@ -179,11 +216,11 @@ export const renderHtml: RenderHook = { return [null, suffix, body] }, - number(params, path, value, lang, states) { - return [null, , null] + number(params, path, value, lang, states, ctx) { + return [null, , null] }, - object({ node, getActiveFields, getChildModelPath }, path, value, lang, states) { + object({ node, getActiveFields, getChildModelPath }, path, value, lang, states, ctx) { let prefix: JSX.Element | null = null let suffix: JSX.Element | null = null if (node.optional()) { @@ -195,6 +232,8 @@ export const renderHtml: RenderHook = { 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)) @@ -203,7 +242,7 @@ export const renderHtml: RenderHook = { 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) + 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) @@ -212,18 +251,63 @@ export const renderHtml: RenderHook = { suffix = <>{suffix}{cSuffix} return isFlattened ? cBody : null } - return + return }) } return [prefix, suffix, body] }, - string(params, path, value, lang, states) { - return [null, , null] + 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) @@ -236,11 +320,18 @@ function BooleanSuffix({ path, node, value, lang }: NodeProps function NumberSuffix({ path, config, integer, value }: NodeProps) { const [text, setText] = useState(value ?? '') + const commitTimeout = useRef() + const scheduleCommit = (value: number) => { + if (commitTimeout.current) clearTimeout(commitTimeout.current) + commitTimeout.current = setTimeout(() => { + path.model.set(path, value) + }, 500) + } const onChange = (evt: Event) => { const value = (evt.target as HTMLInputElement).value const parsed = integer ? parseInt(value) : parseFloat(value) - path.model.set(path, parsed) setText(value) + scheduleCommit(parsed) } const onBlur = () => { setText(value ?? '') @@ -248,8 +339,8 @@ function NumberSuffix({ path, config, integer, value }: NodeProps { const value = (evt.target as HTMLInputElement).value const parsed = parseInt(value.slice(1), 16) - path.model.set(path, parsed) setText(parsed) + scheduleCommit(parsed) } return <> @@ -260,9 +351,9 @@ function NumberSuffix({ path, config, integer, value }: NodeProps) { const onChange = (evt: Event) => { + evt.stopPropagation() 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('.') @@ -305,12 +396,12 @@ type TreeNodeProps = { value: any, lang: string, states: BlockStateRegistry, + ctx: Record, compare?: any, label?: string, children?: ComponentChildren, - context?: number, } -function TreeNode({ label, schema, path, value, lang, states, children }: TreeNodeProps) { +function TreeNode({ label, schema, path, value, lang, states, ctx, children }: TreeNodeProps) { const type = schema.type(path) const category = schema.category(path) const context = path.getContext().join('.') @@ -321,7 +412,9 @@ function TreeNode({ label, schema, path, value, lang, states, children }: TreeNo setActive() } - const [prefix, suffix, body] = schema.hook(renderHtml, path, value, lang, states) + const newCtx = {...ctx} + delete newCtx.index + const [prefix, suffix, body] = schema.hook(renderHtml, path, value, lang, states, newCtx) return
@@ -345,11 +438,11 @@ function TreeNode({ label, schema, path, value, lang, states, children }: TreeNo } const MemoedTreeNode = memo(TreeNode, (prev, next) => { - return deepEqual(prev.value, next.value) - && prev.path.equals(next.path) - && prev.schema === next.schema + return prev.schema === next.schema && prev.lang === next.lang - && prev.context === next.context + && prev.path.equals(next.path) + && deepEqual(prev.ctx, next.ctx) + && deepEqual(prev.value, next.value) }) function isEnum(value?: ValidationOption | EnumOption): value is EnumOption { @@ -378,8 +471,10 @@ function pathLocale(lang: string, path: Path, ...params: string[]) { return ctx[ctx.length - 1] } -function ErrorPopup({ lang, path }: { lang: string, path: ModelPath }) { - const e = path.model.errors.get(path, true) +function ErrorPopup({ lang, path, nested }: { lang: string, path: ModelPath, nested?: boolean }) { + 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) diff --git a/src/locales/en.json b/src/locales/en.json index 14259201..ac8a7d30 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -8,6 +8,9 @@ "dimension_type": "Dimension Type", "dimension": "Dimension", "download": "Download", + "entries_hidden": "%0% entries hidden", + "entries_hidden.more": "Show %0% more", + "entries_hidden.all": "Show all", "fields": "Fields", "github": "GitHub", "home": "Home", diff --git a/src/styles/nodes.css b/src/styles/nodes.css index 2e347b35..2bbb134d 100644 --- a/src/styles/nodes.css +++ b/src/styles/nodes.css @@ -434,12 +434,14 @@ span.menu-item { /* Color categories */ [data-category=predicate] > .node-header > label, +[data-category=predicate].node-header > label, [data-category=predicate] > .node-body > .node > .node-header > label { background-color: var(--category-predicate); } [data-category=predicate] > .node-body, [data-category=predicate] > .node-header > label, +[data-category=predicate].node-header > label, [data-category=predicate] > .node-header > *:not(.selected), [data-category=predicate] > .node-body > .node > .node-header > *:not(.selected) { border-color: var(--category-predicate-border); @@ -453,12 +455,14 @@ span.menu-item { } [data-category=function] > .node-header > label, +[data-category=function].node-header > label, [data-category=function] > .node-body > .node > .node-header > label { background-color: var(--category-function); } [data-category=function] > .node-body, [data-category=function] > .node-header > label, +[data-category=function].node-header > label, [data-category=function] > .node-header > *:not(.selected), [data-category=function] > .node-body > .node > .node-header > *:not(.selected) { border-color: var(--category-function-border); @@ -472,12 +476,14 @@ span.menu-item { } [data-category=pool] > .node-header > label, +[data-category=pool].node-header > label, [data-category=pool] > .node-body > .node > .node-header > label { background-color: var(--category-pool); } [data-category=pool] > .node-body, [data-category=pool] > .node-header > label, +[data-category=pool].node-header > label, [data-category=pool] > .node-header > *:not(.selected), [data-category=pool] > .node-body > .node > .node-header > *:not(.selected) { border-color: var(--category-pool-border);