diff --git a/package-lock.json b/package-lock.json index 7b4701e4..7830a113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,16 +5,16 @@ "requires": true, "dependencies": { "@mcschema/core": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@mcschema/core/-/core-0.9.3.tgz", - "integrity": "sha512-DtDncu+NE32OUDzjCv+IAA+owPXASEbR9WXRCikpDY/eTxpjiUkgrsjYzG9A8GhE/Hw60vbwbDuSrHjeTDYSTg==" + "version": "0.10.0-beta.6", + "resolved": "https://registry.npmjs.org/@mcschema/core/-/core-0.10.0-beta.6.tgz", + "integrity": "sha512-52QcHOBuATSL9dqI3fuNMexuJFwwF9NrtILs+IzkwAt/40e7VnR29doJW8P3JkNcZ2a9BaVtw5ExfbePj9rTFg==" }, "@mcschema/java-1.16": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.16/-/java-1.16-0.5.8.tgz", - "integrity": "sha512-hbrDilc5fQK97x0MhbBiwHT+C4xCjsO7HPdgcZqvzhlsbcSkG2fz03Ew/myJJQyxYpShyZLq2QPl6OVtiCp40Q==", + "version": "0.5.9-beta.1", + "resolved": "https://registry.npmjs.org/@mcschema/java-1.16/-/java-1.16-0.5.9-beta.1.tgz", + "integrity": "sha512-Yh+Z6jH/GU/WgshsHfz6aRZLXGMldhaHgWfk+eWvY3f8ftrurNW+OUddCx3kwOZ4gADy5pivVhISB1yecpWKxQ==", "requires": { - "@mcschema/core": "^0.9.0" + "@mcschema/core": "^0.10.0-beta.3" } }, "@mcschema/locales": { diff --git a/package.json b/package.json index b066d318..f3803920 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "author": "Misode", "license": "MIT", "dependencies": { - "@mcschema/core": "^0.9.3", - "@mcschema/java-1.16": "^0.5.8", + "@mcschema/core": "^0.10.0-beta.6", + "@mcschema/java-1.16": "^0.5.9-beta.1", "@mcschema/locales": "^0.1.11", "@types/google.analytics": "0.0.40", "@types/split.js": "^1.4.0", diff --git a/src/app/Mounter.ts b/src/app/Mounter.ts new file mode 100644 index 00000000..02974514 --- /dev/null +++ b/src/app/Mounter.ts @@ -0,0 +1,89 @@ +import { ModelPath } from '@mcschema/core' + +const dec2hex = (dec: number) => ('0' + dec.toString(16)).substr(-2) + +export function hexId(length = 12) { + var arr = new Uint8Array(length / 2) + window.crypto.getRandomValues(arr) + return Array.from(arr, dec2hex).join('') +} + +type Registry = { + [id: string]: (el: Element) => void +} + +type NodeInjector = (path: ModelPath, mounter: Mounter) => string + +type TreeViewOptions = { + nodeInjector?: NodeInjector +} + +export interface Mounter { + register(callback: (el: Element) => void): string + registerEvent(type: string, callback: (el: Element) => void): string + registerChange(callback: (el: Element) => void): string + registerClick(callback: (el: Element) => void): string + nodeInjector(path: ModelPath, mounter: Mounter): string + mount(el: HTMLElement): void +} + +export class Mounter implements Mounter { + registry: Registry = {} + nodeInjector: NodeInjector + + constructor(options?: TreeViewOptions) { + this.nodeInjector = options?.nodeInjector ?? (() => '') + } + + /** + * Registers a callback and gives an ID + * @param callback function that is called when the element is mounted + * @returns the ID that should be applied to the data-id attribute + */ + register(callback: (el: Element) => void): string { + const id = hexId() + this.registry[id] = callback + return id + } + + /** + * Registers an event and gives an ID + * @param type event type + * @param callback function that is called when the event is fired + * @returns the ID that should be applied to the data-id attribute + */ + registerEvent(type: string, callback: (el: Element) => void): string { + return this.register(el => { + el.addEventListener(type, evt => { + callback(el) + evt.stopPropagation() + }) + }) + } + + /** + * Registers a change event and gives an ID + * @param callback function that is called when the event is fired + * @returns the ID that should be applied to the data-id attribute + */ + registerChange(callback: (el: Element) => void): string { + return this.registerEvent('change', callback) + } + + /** + * Registers a click event and gives an ID + * @param callback function that is called when the event is fired + * @returns the ID that should be applied to the data-id attribute + */ + registerClick(callback: (el: Element) => void): string { + return this.registerEvent('click', callback) + } + + mount(el: HTMLElement): void { + for (const id in this.registry) { + const element = el.querySelector(`[data-id="${id}"]`) + if (element !== null) this.registry[id](element) + } + this.registry = {} + } +} diff --git a/src/app/SourceView.ts b/src/app/SourceView.ts index f8f88a35..42fd93af 100644 --- a/src/app/SourceView.ts +++ b/src/app/SourceView.ts @@ -1,5 +1,6 @@ import { DataModel, Path, ModelPath } from '@mcschema/core' import { AbstractView } from './AbstractView' +import { transformOutput } from './hooks/transformOutput' type SourceViewOptions = { indentation?: number | string, @@ -27,7 +28,7 @@ export class SourceView extends AbstractView { } invalidated() { - const transformed = this.model.schema.transform(new ModelPath(this.model), this.model.data) + const transformed = this.model.schema.hook(transformOutput, new ModelPath(this.model), this.model.data) this.target.value = JSON.stringify(transformed, null, this.options?.indentation) } diff --git a/src/app/TreeView.ts b/src/app/TreeView.ts index 33b4d83d..2d12bddc 100644 --- a/src/app/TreeView.ts +++ b/src/app/TreeView.ts @@ -1,5 +1,7 @@ -import { DataModel, ModelPath, Mounter } from '@mcschema/core' +import { DataModel, ModelPath } from '@mcschema/core' import { AbstractView } from './AbstractView' +import { Mounter } from './Mounter' +import { renderHtml } from './hooks/renderHtml' type Registry = { [id: string]: (el: Element) => void @@ -39,7 +41,7 @@ export class TreeView extends AbstractView { invalidated() { const mounter = new Mounter({nodeInjector: this.nodeInjector}) const path = new ModelPath(this.model) - const rendered = this.model.schema.render(path, this.model.data, mounter) + const rendered = this.model.schema.hook(renderHtml, path, this.model.data, mounter) const category = this.model.schema.category(path) if (rendered[1]) { this.target.innerHTML = `
diff --git a/src/app/app.ts b/src/app/app.ts index 001a9f28..1e4965dc 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -5,7 +5,6 @@ import { locale, LOCALES, ModelPath, - Mounter, Path, } from '@mcschema/core' import { getCollections, getSchemas } from '@mcschema/java-1.16' @@ -16,6 +15,7 @@ import { SourceView } from './SourceView' import { ErrorsView } from './ErrorsView' import config from '../config.json' import { BiomeNoiseVisualizer } from './visualization/BiomeNoiseVisualizer' +import { Mounter } from './Mounter' const LOCAL_STORAGE_THEME = 'theme' const LOCAL_STORAGE_LANGUAGE = 'language' diff --git a/src/app/hooks/renderHtml.ts b/src/app/hooks/renderHtml.ts new file mode 100644 index 00000000..9a9f808a --- /dev/null +++ b/src/app/hooks/renderHtml.ts @@ -0,0 +1,241 @@ +import { locale, Hook, ModelPath, Path, StringHookParams, ValidationOption, EnumOption, INode, DataModel, MapNode, StringNode } from '@mcschema/core' +import { Mounter } from '../Mounter' +import { hexId } from '../utils' + +/** + * 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, Mounter], [string, string, string]> = { + base() { + return ['', '', ''] + }, + + boolean({ node }, path, value, mounter) { + const onFalse = mounter.registerClick(el => { + path.model.set(path, node.optional() && value === false ? undefined : false) + }) + const onTrue = mounter.registerClick(el => { + path.model.set(path, node.optional() && value === true ? undefined : true) + }) + return ['', `${locale('false')} + ${locale('true')}`, ''] + }, + + choice({ choices, config, switchNode }, path, value, mounter) { + const choice = switchNode.activeCase(path) ?? choices[0] + 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 inject = choices.map(c => { + if (c.type === choice.type) { + return `` + } + const buttonId = mounter.registerClick(el => { + path.model.set(path, c.change ? c.change(value) : c.node.default()) + }) + return `` + }).join('') + + const [prefix, suffix, body] = choice.node.hook(this, pathWithContext, value, mounter) + return [prefix, inject + suffix, body] + }, + + list({ children }, path, value, mounter) { + const onAdd = mounter.registerClick(el => { + if (!Array.isArray(value)) value = [] + path.model.set(path, [children.default(), ...value]) + }) + const onAddBottom = mounter.registerClick(el => { + if (!Array.isArray(value)) value = [] + path.model.set(path, [...value, children.default()]) + }) + const suffix = `` + + mounter.nodeInjector(path, mounter) + + let body = '' + if (Array.isArray(value)) { + body = value.map((childValue, index) => { + const removeId = mounter.registerClick(el => path.model.set(path.push(index), undefined)) + const childPath = path.push(index).localePush('entry') + const category = children.category(childPath) + const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, childValue, mounter) + return `
+
+ + ${cPrefix} + + ${cSuffix} +
+ ${cBody ? `
${cBody}
` : ''} +
` + }).join('') + if (value.length > 2) { + body += `
+
+ +
+
` + } + } + return ['', suffix, body] + }, + + map({ keys, children }, path, value, mounter) { + const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())])) + const onAdd = mounter.registerClick(el => { + const key = keyPath.get() + path.model.set(path.push(key), children.default()) + }) + const keyRendered = keys.hook(this, keyPath, keyPath.get() ?? '', mounter) + const suffix = keyRendered[1] + + `` + + mounter.nodeInjector(path, mounter) + let body = '' + if (typeof value === 'object' && value !== undefined) { + body = Object.keys(value) + .map(key => { + const removeId = mounter.registerClick(el => path.model.set(path.push(key), undefined)) + const childPath = path.modelPush(key) + const category = children.category(childPath) + const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, value[key], mounter) + return `
+
+ + ${cPrefix} + + ${cSuffix} +
+ ${cBody ? `
${cBody}
` : ''} +
` + }) + .join('') + } + return ['', suffix, body] + }, + + number({ integer, config }, path, value, mounter) { + const onChange = mounter.registerChange(el => { + const value = (el as HTMLInputElement).value + let 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, mounter) { + let prefix = '' + if (node.optional()) { + if (value === undefined) { + prefix = `` + } else { + prefix = `` + } + } + let suffix = mounter.nodeInjector(path, mounter) + let body = '' + if (typeof value === 'object' && value !== undefined && (!(node.optional() && value === undefined))) { + const activeFields = getActiveFields(path) + body = Object.keys(activeFields) + .filter(k => activeFields[k].enabled(path)) + .map(k => { + const field = activeFields[k] + const childPath = getChildModelPath(path, k) + const category = field.category(childPath) + const [cPrefix, cSuffix, cBody] = field.hook(this, childPath, value[k], mounter) + return `
+
+ ${cPrefix} + + ${cSuffix} +
+ ${cBody ? `
${cBody}
` : ''} +
` + }) + .join('') + } + return [prefix, suffix, body] + }, + + string(params, path, value, mounter) { + const inputId = mounter.register(el => { + (el as HTMLSelectElement).value = value ?? '' + el.addEventListener('change', evt => { + const newValue = (el as HTMLSelectElement).value + console.log("UPDATING NEW VALUE!", path.toString(), newValue) + path.model.set(path, newValue.length === 0 ? undefined : newValue) + evt.stopPropagation() + }) + }) + return ['', rawString(params, path, inputId), ''] + } +} + +function isEnum(value?: ValidationOption | EnumOption): value is EnumOption { + return !!(value as any)?.enum +} + +function isValidator(value?: ValidationOption | EnumOption): value is ValidationOption { + return !!(value as any)?.validator +} + +function rawString({ node, getValues, config }: { node: INode } & StringHookParams, path: ModelPath, inputId?: string) { + const values = getValues() + if (isEnum(config) && !config.additional) { + const contextPath = typeof config.enum === 'string' ? + new Path(path.getArray(), [config.enum]) : path + return selectRaw(node, contextPath, values, inputId) + } + if (config && isValidator(config) + && config.validator === 'resource' + && typeof config.params.pool === 'string' + && values.length > 0) { + const contextPath = new Path(path.getArray(), [config.params.pool]) + if (contextPath.localePush(values[0]).strictLocale()) { + return selectRaw(node, contextPath, values, inputId) + } + } + const datalistId = hexId() + return ` + ${values.length === 0 ? '' : + ` + ${values.map(v => + ``}` +} + +function selectRaw(node: INode, contextPath: Path, values: string[], inputId?: string) { + return `` +} + +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; +} diff --git a/src/app/hooks/transformOutput.ts b/src/app/hooks/transformOutput.ts new file mode 100644 index 00000000..eac78f57 --- /dev/null +++ b/src/app/hooks/transformOutput.ts @@ -0,0 +1,53 @@ +import { Hook } from '@mcschema/core' + +export const transformOutput: Hook<[any], any> = { + base({}, _, value) { + return value + }, + + boolean({}, _, value) { + return value + }, + + choice({ switchNode }, path, value) { + return switchNode.hook(this, path, value) + }, + + list({ children }, path, value) { + if (!Array.isArray(value)) return value + return value.map((obj, index) => + children.hook(this, path.push(index), obj) + ) + }, + + map({ children }, path, value) { + if (value === undefined) return undefined + let res: any = {} + Object.keys(value).forEach(f => + res[f] = children.hook(this, path.push(f), value[f]) + ) + return res; + }, + + number({}, _, value) { + return value + }, + + object({ getActiveFields }, path, value) { + if (value === undefined || value === null || typeof value !== 'object') { + return value + } + let res: any = {} + const activeFields = getActiveFields(path) + Object.keys(activeFields) + .filter(k => activeFields[k].enabled(path)) + .forEach(f => { + res[f] = activeFields[f].hook(this, path.push(f), value[f]) + }) + return res + }, + + string({}, _, value) { + return value + } +} diff --git a/src/app/utils.ts b/src/app/utils.ts new file mode 100644 index 00000000..950fabdd --- /dev/null +++ b/src/app/utils.ts @@ -0,0 +1,7 @@ +const dec2hex = (dec: number) => ('0' + dec.toString(16)).substr(-2) + +export function hexId(length = 12) { + var arr = new Uint8Array(length / 2) + window.crypto.getRandomValues(arr) + return Array.from(arr, dec2hex).join('') +}