diff --git a/src/app/AbstractView.ts b/src/app/AbstractView.ts deleted file mode 100644 index 78048fe9..00000000 --- a/src/app/AbstractView.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ModelListener, DataModel } from '@mcschema/core'; - -export abstract class AbstractView implements ModelListener { - model: DataModel - - constructor(model: DataModel) { - this.model = model - this.model.addListener(this) - } - - setModel(model: DataModel) { - this.model.removeListener(this) - this.model = model - this.model.addListener(this) - } - - invalidated(model: DataModel): void {} -} diff --git a/src/app/ErrorsView.ts b/src/app/ErrorsView.ts deleted file mode 100644 index 785b91b2..00000000 --- a/src/app/ErrorsView.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - DataModel, - Errors, -} from '@mcschema/core' -import { AbstractView } from './AbstractView' -import { locale } from './locales' - -export class ErrorsView extends AbstractView { - target: HTMLElement - - constructor(model: DataModel, target: HTMLElement) { - super(model) - this.target = target - } - - errors(errors: Errors): void { - this.target.style.display = errors.count() > 0 ? 'flex' : 'none' - - this.target.children[0].innerHTML = errors.getAll().map(err => - `
- - ${err.path.toString()} - - - ${locale(err.error, err.params)} -
` - ).join('') - } -} diff --git a/src/app/Mounter.ts b/src/app/Mounter.ts index 02974514..09cf502d 100644 --- a/src/app/Mounter.ts +++ b/src/app/Mounter.ts @@ -1,12 +1,5 @@ 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('') -} +import { hexId } from './Utils' type Registry = { [id: string]: (el: Element) => void @@ -24,7 +17,7 @@ export interface Mounter { registerChange(callback: (el: Element) => void): string registerClick(callback: (el: Element) => void): string nodeInjector(path: ModelPath, mounter: Mounter): string - mount(el: HTMLElement): void + mount(el: Element): void } export class Mounter implements Mounter { @@ -79,7 +72,7 @@ export class Mounter implements Mounter { return this.registerEvent('click', callback) } - mount(el: HTMLElement): void { + mount(el: Element): void { for (const id in this.registry) { const element = el.querySelector(`[data-id="${id}"]`) if (element !== null) this.registry[id](element) diff --git a/src/app/RegistryFetcher.ts b/src/app/RegistryFetcher.ts index a9ffd606..1fad9ca5 100644 --- a/src/app/RegistryFetcher.ts +++ b/src/app/RegistryFetcher.ts @@ -1,4 +1,5 @@ import { CollectionRegistry } from '@mcschema/core' +import { checkVersion } from './App' import config from '../config.json' const localStorageCache = (version: string) => `cache_${version}` @@ -20,6 +21,10 @@ export const RegistryFetcher = async (target: CollectionRegistry, versionId: str await Promise.all(config.registries.map(async r => { const id = typeof r === 'string' ? r : r.id + if (typeof r !== 'string' && r.minVersion) { + if (!checkVersion(versionId, r.minVersion)) return + } + if (!cache.registries) { cache.registries = {} } @@ -28,9 +33,9 @@ export const RegistryFetcher = async (target: CollectionRegistry, versionId: str return } - const url = typeof r === 'string' - ? mcdata(version.mcdata_ref, r) - : `${baseUrl}/${version.mcdata_ref}/${r.path}` + const url = typeof r !== 'string' && r.path + ? `${baseUrl}/${version.mcdata_ref}/${r.path}/data.min.json` + : mcdata(version.mcdata_ref, typeof r === 'string' ? r : r.id) try { const res = await fetch(url) @@ -40,7 +45,7 @@ export const RegistryFetcher = async (target: CollectionRegistry, versionId: str cache.registries[id] = data.values cacheDirty = true } catch (e) { - console.error(`Error occurred while fetching registry "${id}":`, e) + console.warn(`Error occurred while fetching registry "${id}":`, e) } })) diff --git a/src/app/Router.ts b/src/app/Router.ts new file mode 100644 index 00000000..0dc992bb --- /dev/null +++ b/src/app/Router.ts @@ -0,0 +1,41 @@ +import { App } from './App'; +import { View } from './views/View'; +import { Home } from './views/Home' +import { Generator } from './views/Generator' +import config from '../config.json' + +const categories = config.models.filter(m => m.category === true) + +const router = async () => { + const urlParts = location.pathname.split('/').filter(e => e) + const target = document.getElementById('app')! + const view = new View() + + if (urlParts.length === 0){ + App.model.set({ id: '', name: 'Data Pack', category: true}) + target.innerHTML = Home(view) + } else if (urlParts.length === 1 && categories.map(m => m.id).includes(urlParts[0])) { + App.model.set(categories.find(m => m.id === urlParts[0])!) + target.innerHTML = Home(view) + } else { + App.model.set(config.models.find(m => m.id === urlParts.join('/'))!) + target.innerHTML = Generator(view) + } + view.mounted(target) +} + +window.addEventListener("popstate", router); + +document.addEventListener("DOMContentLoaded", () => { + document.body.addEventListener("click", e => { + if (e.target instanceof Element + && e.target.hasAttribute('data-link') + && e.target.hasAttribute('href') + ) { + e.preventDefault(); + history.pushState(null, '', e.target.getAttribute('href')); + router(); + } + }); + router(); +}); diff --git a/src/app/SourceView.ts b/src/app/SourceView.ts deleted file mode 100644 index 42fd93af..00000000 --- a/src/app/SourceView.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { DataModel, Path, ModelPath } from '@mcschema/core' -import { AbstractView } from './AbstractView' -import { transformOutput } from './hooks/transformOutput' - -type SourceViewOptions = { - indentation?: number | string, - rows?: number -} - -/** - * JSON representation view of the model. - * Renders the result in a + ` +} diff --git a/src/app/components/panels/TreePanel.ts b/src/app/components/panels/TreePanel.ts new file mode 100644 index 00000000..77d11007 --- /dev/null +++ b/src/app/components/panels/TreePanel.ts @@ -0,0 +1,161 @@ +import { DataModel, ModelPath, Path } from '@mcschema/core'; +import { App, checkVersion, Previews } from '../../App'; +import { Tracker } from '../../Tracker' +import { View } from '../../views/View'; +import { Octicon } from '../Octicon'; +import { Mounter } from '../../Mounter'; +import { renderHtml } from '../../hooks/renderHtml'; +import config from '../../../config.json' +import { locale } from '../../Locales'; +import { BiomeNoisePreview } from '../../preview/BiomeNoisePreview'; + +const createPopupIcon = (type: string, icon: keyof typeof Octicon, popup: string) => { + const div = document.createElement('div') + div.className = `node-icon ${type}` + div.addEventListener('click', evt => { + div.getElementsByTagName('span')[0].classList.add('show') + document.body.addEventListener('click', evt => { + div.getElementsByTagName('span')[0].classList.remove('show') + }, { capture: true, once: true }) + }) + div.insertAdjacentHTML('beforeend', `${popup}${Octicon[icon]}`) + return div +} + +const treeViewObserver = (el: Element) => { + el.querySelectorAll('.node[data-help]').forEach(e => { + e.querySelector('.node-header')?.appendChild( + createPopupIcon('node-help', 'info', e.getAttribute('data-help') ?? '')) + }) + el.querySelectorAll('.node[data-error]').forEach(e => { + e.querySelector('.node-header')?.appendChild( + createPopupIcon('node-error', 'issue_opened', e.getAttribute('data-error') ?? '')) + }) + el.querySelectorAll('.collapse.closed, button.add').forEach(e => { + e.insertAdjacentHTML('afterbegin', Octicon.plus_circle) + }) + el.querySelectorAll('.collapse.open, button.remove').forEach(e => { + e.insertAdjacentHTML('afterbegin', Octicon.trashcan) + }) +} + +const treeViewNodeInjector = (path: ModelPath, mounter: Mounter) => { + + let res = Object.keys(Previews).map(k => Previews[k]) + .filter(v => v.active(path)) + .map(v => { + const id = mounter.registerClick(() => { + Tracker.setPreview(v.getName()) + v.path = path + App.preview.set(v) + }) + return `` + }).join('') + + if (path.pop().endsWith(new Path(['generator', 'biome_source', 'biomes']))) { + const biomePreview = Previews.biome_noise as BiomeNoisePreview + const biome = path.push('biome').get() + const id = mounter.registerChange(el => { + biomePreview.setBiomeColor(biome, (el as HTMLInputElement).value) + biomePreview.state = {} + }) + res += `` + } + return res +} + +export const TreePanel = (view: View, model: DataModel) => { + const mounter = new Mounter({ nodeInjector: treeViewNodeInjector }) + const getContent = () => { + if (App.loaded.get()) { + const path = new ModelPath(model) + const rendered = model.schema.hook(renderHtml, path, model.data, mounter) + const category = model.schema.category(path) + if (rendered[1]) { + return `
+
${rendered[1]}
+
${rendered[2]}
+
` + } + return rendered[2] + } + return '
' + } + const mountContent = (el: Element) => { + el.innerHTML = getContent() + treeViewObserver(el) + mounter.mount(el) + } + const tree = view.register(el => { + App.loaded.watchRun((value) => { + if (!value) { + // If loading is taking more than 100 ms, show spinner + new Promise(r => setTimeout(r, 100)).then(() => { + if (!App.loaded.get()) { + mountContent(el) + } + }) + } else { + mountContent(el) + } + }) + model.addListener({ + invalidated() { + mountContent(el) + } + }) + ;(Previews.biome_noise as BiomeNoisePreview).biomeColors.watch(() => { + mountContent(el) + }, 'tree-panel') + }) + const toggleMenu = (el: Element) => { + el.classList.toggle('active') + document.body.addEventListener('click', evt => { + el.classList.remove('active') + }, { capture: true, once: true }) + } + return `
+
+
+ ${Octicon.history} + +
+
+
+ ${Octicon.tag} + + ${App.version.get()} + +
+
+ ${config.versions + .filter(v => checkVersion(v.id, App.model.get()!.minVersion ?? '1.16')) + .reverse() + .map(v => ` +
+ ${v.id} +
+ `).join('')} +
+
+
+
+ ${Octicon.kebab_horizontal} +
+
+
+ ${Octicon.arrow_left} +
+
+ ${Octicon.arrow_right} +
+
+
+
+
+
` +} diff --git a/src/app/hooks/renderHtml.ts b/src/app/hooks/renderHtml.ts index 14f3ab5e..e37bfc58 100644 --- a/src/app/hooks/renderHtml.ts +++ b/src/app/hooks/renderHtml.ts @@ -1,7 +1,7 @@ import { Hook, ModelPath, Path, StringHookParams, ValidationOption, EnumOption, INode, DataModel, MapNode, StringNode } from '@mcschema/core' -import { locale, pathLocale, segmentedLocale } from '../locales' +import { locale, segmentedLocale } from '../Locales' import { Mounter } from '../Mounter' -import { hexId } from '../utils' +import { hexId, htmlEncode } from '../Utils' /** * Secondary model used to remember the keys of a map @@ -248,6 +248,12 @@ function hashString(str: string) { return hash; } +function pathLocale(path: Path, params?: string[]): string { + // return path.getContext().slice(-5).join('.') + return segmentedLocale(path.getContext(), params) + ?? path.getContext()[path.getContext().length - 1] ?? '' +} + function error(p: ModelPath, exact = true) { const errors = p.model.errors.get(p, exact) if (errors.length === 0) return '' @@ -259,8 +265,3 @@ function help(path: ModelPath) { if (message === undefined) return '' return `data-help="${htmlEncode(message)}"` } - -function htmlEncode(str: string) { - return str.replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/') -} diff --git a/src/app/locales.ts b/src/app/locales.ts index b0a2dbdb..e70d997b 100644 --- a/src/app/locales.ts +++ b/src/app/locales.ts @@ -1,29 +1,14 @@ -import { ModelPath, Path } from '@mcschema/core' +import English from '../locales/en.json' +import { App } from './App' interface Locale { [key: string]: string } -const Locales: { +export const Locales: { [key: string]: Locale -} = {} - -let language = 'en' - -export function registerLocale(code: string, locale: Locale) { - Locales[code] = locale -} - -export function hasLocale(code: string) { - return Locales[code] !== undefined -} - -export function setLanguage(code: string | undefined) { - language = code ?? language -} - -export function getLanguage() { - return language +} = { + 'en': English } export function resolveLocaleParams(value: string, params?: string[]): string | undefined { @@ -34,17 +19,17 @@ export function resolveLocaleParams(value: string, params?: string[]): string | } export function locale(key: string, params?: string[]): string { - const value: string | undefined = Locales[language][key] ?? Locales.en[key] + const value: string | undefined = Locales[App.language.get()]?.[key] ?? Locales.en[key] return resolveLocaleParams(value, params) ?? key } export function segmentedLocale(segments: string[], params?: string[], depth = 5, minDepth = 1): string | undefined { - return [language, 'en'].reduce((prev: string | undefined, code) => { + return [App.language.get(), 'en'].reduce((prev: string | undefined, code) => { if (prev !== undefined) return prev const array = segments.slice(-depth); while (array.length >= minDepth) { - const locale = resolveLocaleParams(Locales[code][array.join('.')], params) + const locale = resolveLocaleParams(Locales[code]?.[array.join('.')], params) if (locale !== undefined) return locale array.shift() } @@ -52,9 +37,3 @@ export function segmentedLocale(segments: string[], params?: string[], depth = 5 return undefined }, undefined) } - -export function pathLocale(path: Path, params?: string[]): string { - // return path.getContext().slice(-5).join('.') - return segmentedLocale(path.getContext(), params) - ?? path.getContext()[path.getContext().length - 1] ?? '' -} diff --git a/src/app/visualization/BiomeNoiseVisualizer.ts b/src/app/preview/BiomeNoisePreview.ts similarity index 66% rename from src/app/visualization/BiomeNoiseVisualizer.ts rename to src/app/preview/BiomeNoisePreview.ts index 9c9e5fd6..37b64f37 100644 --- a/src/app/visualization/BiomeNoiseVisualizer.ts +++ b/src/app/preview/BiomeNoisePreview.ts @@ -1,23 +1,24 @@ import { DataModel, Path, ModelPath } from "@mcschema/core" +import { Property } from "../state/Property" import { NormalNoise } from './NormalNoise' -import { Visualizer } from './Visualizer' -import { VisualizerView } from './VisualizerView' +import { Preview } from './Preview' const LOCAL_STORAGE_BIOME_COLORS = 'biome_colors' -export class BiomeNoiseVisualizer extends Visualizer { +export class BiomeNoisePreview extends Preview { static readonly noiseMaps = ['altitude', 'temperature', 'humidity', 'weirdness'] - private seed: string private noise: NormalNoise[] - private offsetX: number = 0 - private offsetY: number = 0 - private viewScale: number = 0 - private biomeColors: { [id: string]: number[] } + seed: string + offsetX: number = 0 + offsetY: number = 0 + viewScale: number = 0 + biomeColors: Property<{ [id: string]: number[] }> constructor() { super() this.seed = this.hexId() - this.biomeColors = JSON.parse(localStorage.getItem(LOCAL_STORAGE_BIOME_COLORS) ?? '{}') + this.biomeColors = new Property({}) + this.biomeColors.set(JSON.parse(localStorage.getItem(LOCAL_STORAGE_BIOME_COLORS) ?? '{}')) this.noise = [] } @@ -31,7 +32,7 @@ export class BiomeNoiseVisualizer extends Visualizer { } draw(model: DataModel, img: ImageData) { - this.noise = BiomeNoiseVisualizer.noiseMaps.map((id, i) => { + this.noise = BiomeNoisePreview.noiseMaps.map((id, i) => { const config = this.state[`${id}_noise`] return new NormalNoise(this.seed + i, config.firstOctave, config.amplitudes) }) @@ -49,7 +50,7 @@ export class BiomeNoiseVisualizer extends Visualizer { const xx = (x - this.offsetX) * s - 100 * s const yy = (y - this.offsetY) * s - 50 * s const b = this.closestBiome(xx, yy) - const color = biomeColorCache[b] + const color = biomeColorCache[b] ?? [128, 128, 128] data[i] = color[0] data[i + 1] = color[1] data[i + 2] = color[2] @@ -63,20 +64,8 @@ export class BiomeNoiseVisualizer extends Visualizer { this.offsetY += toY - fromY } - addControls(el: HTMLElement, view: VisualizerView) { - el.insertAdjacentHTML('beforeend', ``) - el.childNodes[0].addEventListener('click', () => { - this.viewScale -= 0.5 - view.redraw() - }) - el.childNodes[1].addEventListener('click', () => { - this.viewScale += 0.5 - view.redraw() - }) - } - private closestBiome(x: number, y: number): string { - if (!this.state.biomes) return '' + if (!this.state.biomes || this.state.biomes.length === 0) return '' const noise = this.noise.map(n => n.getValue(x, y)) let minDist = Infinity let minBiome = '' @@ -95,7 +84,7 @@ export class BiomeNoiseVisualizer extends Visualizer { } getBiomeColor(biome: string): number[] { - const color = this.biomeColors[biome] + const color = this.biomeColors.get()[biome] if (color === undefined) { return this.colorFromBiome(biome) } @@ -103,8 +92,9 @@ export class BiomeNoiseVisualizer extends Visualizer { } setBiomeColor(biome: string, value: string) { - this.biomeColors[biome] = [parseInt(value.slice(1, 3), 16), parseInt(value.slice(3, 5), 16), parseInt(value.slice(5, 7), 16)] - localStorage.setItem(LOCAL_STORAGE_BIOME_COLORS, JSON.stringify(this.biomeColors)) + const color = [parseInt(value.slice(1, 3), 16), parseInt(value.slice(3, 5), 16), parseInt(value.slice(5, 7), 16)] + this.biomeColors.set({...this.biomeColors.get(), [biome]: color}) + localStorage.setItem(LOCAL_STORAGE_BIOME_COLORS, JSON.stringify(this.biomeColors.get())) } getBiomeHex(biome: string): string { diff --git a/src/app/visualization/NoiseSettingsVisualizer.ts b/src/app/preview/NoiseSettingsPreview.ts similarity index 96% rename from src/app/visualization/NoiseSettingsVisualizer.ts rename to src/app/preview/NoiseSettingsPreview.ts index f1f2d263..d4db3954 100644 --- a/src/app/visualization/NoiseSettingsVisualizer.ts +++ b/src/app/preview/NoiseSettingsPreview.ts @@ -1,10 +1,10 @@ import SimplexNoise from 'simplex-noise' import { DataModel, Path, ModelPath } from "@mcschema/core" -import { Visualizer } from './Visualizer' +import { Preview } from './Preview' const debug = false -export class NoiseSettingsVisualizer extends Visualizer { +export class NoiseSettingsPreview extends Preview { private noise: SimplexNoise private offsetX: number diff --git a/src/app/visualization/NormalNoise.ts b/src/app/preview/NormalNoise.ts similarity index 100% rename from src/app/visualization/NormalNoise.ts rename to src/app/preview/NormalNoise.ts diff --git a/src/app/visualization/Visualizer.ts b/src/app/preview/Preview.ts similarity index 72% rename from src/app/visualization/Visualizer.ts rename to src/app/preview/Preview.ts index 957565fa..fa7e4b5b 100644 --- a/src/app/visualization/Visualizer.ts +++ b/src/app/preview/Preview.ts @@ -1,8 +1,8 @@ import { DataModel, ModelPath } from "@mcschema/core" -import { VisualizerView } from "./VisualizerView" -export abstract class Visualizer { +export abstract class Preview { state: any + path?: ModelPath dirty(path: ModelPath): boolean { return JSON.stringify(this.state) !== JSON.stringify(path.get()) @@ -13,6 +13,4 @@ export abstract class Visualizer { abstract draw(model: DataModel, img: ImageData): void onDrag(fromX: number, fromY: number, toX: number, toY: number): void {} - - addControls(el: HTMLElement, view: VisualizerView): void {} } diff --git a/src/app/state/LocalStorageProperty.ts b/src/app/state/LocalStorageProperty.ts new file mode 100644 index 00000000..75fa6841 --- /dev/null +++ b/src/app/state/LocalStorageProperty.ts @@ -0,0 +1,14 @@ +import { Property } from './Property' + +export class LocalStorageProperty extends Property { + constructor(private id: string, fallback: string) { + super(localStorage.getItem(id) ?? fallback) + } + set(value: string) { + super.set(value) + localStorage.setItem(this.id, value) + } + get(): string { + return this.value + } +} diff --git a/src/app/state/Property.ts b/src/app/state/Property.ts new file mode 100644 index 00000000..a5dc4fa2 --- /dev/null +++ b/src/app/state/Property.ts @@ -0,0 +1,40 @@ +import { hexId } from "../Utils" + +type PropertyWatcher = (value: T, oldValue: T | null) => void +type NamedPropertyWatcher = { + name: string + watcher: PropertyWatcher +} + +export class Property { + private watchers: NamedPropertyWatcher[] = [] + + constructor(public value: T) {} + + set(value: T) { + if (this.value === value) return + const oldValue = this.value + this.value = value + this.watchers.forEach(w => w.watcher(this.value, oldValue)) + } + + get(): T { + return this.value + } + + watchRun(watcher: PropertyWatcher, name?: string) { + watcher(this.value, null) + return this.watch(watcher, name) + } + + watch(watcher: PropertyWatcher, name?: string) { + name = name ?? hexId() + const w = this.watchers.find(w => w.name === name) + if (w) { + w.watcher = watcher + } else { + this.watchers.push({ name, watcher }) + } + return this + } +} diff --git a/src/app/utils.ts b/src/app/utils.ts index 950fabdd..7312a6f8 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -5,3 +5,8 @@ export function hexId(length = 12) { window.crypto.getRandomValues(arr) return Array.from(arr, dec2hex).join('') } + +export function htmlEncode(str: string) { + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/') +} diff --git a/src/app/views/Generator.ts b/src/app/views/Generator.ts new file mode 100644 index 00000000..f3d5e5da --- /dev/null +++ b/src/app/views/Generator.ts @@ -0,0 +1,65 @@ +import { App, checkVersion, Models } from '../App' +import { View } from './View' +import { Header } from '../components/Header' +import { SplitGroup } from '../components/SplitGroup' +import { Errors } from '../components/Errors' +import { TreePanel } from '../components/panels/TreePanel' +import { SourcePanel } from '../components/panels/SourcePanel' +import { PreviewPanel } from '../components/panels/PreviewPanel' + +export const Generator = (view: View): string => { + const model = Models[App.model.get()!.id] + model.listeners = [] + const getSideContent = () => { + return App.preview.get() ? + SplitGroup(view, { direction: 'vertical', sizes: [60, 40] }, [ + SourcePanel(view, model), + PreviewPanel(view, model) + ]) + : SourcePanel(view, model) + } + const validatePreview = () => { + const preview = App.preview.get() + const path = preview?.path?.withModel(model) + if (!(path && path.get() && preview?.active(path))) { + App.preview.set(null) + } + } + model.addListener({ + invalidated: validatePreview + }) + App.schemasLoaded.watch((value) => { + if (value) { + model.validate() + model.invalidate() + validatePreview() + } + }, 'generator') + App.localesLoaded.watch((value) => { + if (value && App.schemasLoaded.get()) { + model.invalidate() + } + }, 'generator') + App.version.watchRun((value) => { + const minVersion = App.model.get()!.minVersion + if (minVersion && !checkVersion(value, minVersion)) { + App.version.set(minVersion) + } + }, 'generator') + const sideContent = view.register(el => { + App.preview.watch((value, oldValue) => { + if (!value || !oldValue) { + view.mount(el, getSideContent(), false) + } + }, 'generator') + }) + const homeLink = typeof App.model.get()!.category === 'string' ? `/${App.model.get()!.category}/` : undefined + return `${Header(view, `${App.model.get()!.name} Generator`, homeLink, true)} +
+ ${SplitGroup(view, { direction: "horizontal", sizes: [66, 34] }, [ + TreePanel(view, model), + `
${getSideContent()}
` + ])} +
+ ${Errors(view, model)}` +} diff --git a/src/app/views/Home.ts b/src/app/views/Home.ts new file mode 100644 index 00000000..977c969f --- /dev/null +++ b/src/app/views/Home.ts @@ -0,0 +1,34 @@ +import { App } from '../App' +import { Header } from '../components/Header' +import { View } from './View' +import { Octicon } from '../components/Octicon' +import config from '../../config.json' + +const GeneratorCard = (url: string, name: string, arrow?: boolean, active?: boolean) => ` +
  • + + ${name} + ${arrow ? Octicon.chevron_right : ''} + +
  • +` + +export const Home = (view: View): string => { + const filteredModels = config.models.filter(m => m.category === App.model.get()!.id) + return ` + ${Header(view, 'Data Pack Generators')} +
    +
      + ${config.models + .filter(m => typeof m.category !== 'string') + .map(m => GeneratorCard(m.id, m.name, m.category === true, App.model.get()!.id === m.id)) + .join('')} +
    + ${filteredModels.length === 0 ? '' : ` +
      + ${filteredModels.map(m => GeneratorCard(m.id, m.name)).join('')} +
    + `} +
    + ` +} diff --git a/src/app/views/View.ts b/src/app/views/View.ts new file mode 100644 index 00000000..1d842fda --- /dev/null +++ b/src/app/views/View.ts @@ -0,0 +1,54 @@ +import { locale } from "../Locales" +import { hexId } from "../Utils" + +export class View { + private registry: { [id: string]: (el: Element) => void } = {} + + render(): string { + return '' + } + + register(callback: (el: Element) => void): string { + const id = hexId() + this.registry[id] = callback + return id + } + + on(type: string, callback: (el: Element) => void): string { + return this.register(el => { + el.addEventListener(type, evt => { + callback(el) + evt.stopPropagation() + }) + }) + } + + onChange(callback: (el: Element) => void): string { + return this.on('change', callback) + } + + onClick(callback: (el: Element) => void): string { + return this.on('click', callback) + } + + mounted(el: Element, clear = true): void { + for (const id in this.registry) { + const element = el.querySelector(`[data-id="${id}"]`) + if (element !== null) { + this.registry[id](element) + delete this.registry[id] + } + } + if (clear) { + this.registry = {} + } + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = locale(el.attributes.getNamedItem('data-i18n')!.value) + }) + } + + mount(el: Element, html: string, clear = true) { + el.innerHTML = html + this.mounted(el, clear) + } +} diff --git a/src/app/visualization/VisualizerView.ts b/src/app/visualization/VisualizerView.ts deleted file mode 100644 index 432b0706..00000000 --- a/src/app/visualization/VisualizerView.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { DataModel, ModelPath } from "@mcschema/core" -import { AbstractView } from "../AbstractView" -import { BiomeNoiseVisualizer } from "./BiomeNoiseVisualizer" -import { NoiseSettingsVisualizer } from "./NoiseSettingsVisualizer" -import { Visualizer } from "./Visualizer" - -export class VisualizerView extends AbstractView { - ctx: CanvasRenderingContext2D - visualizer?: Visualizer - active: boolean - path?: ModelPath - el: HTMLElement - canvas: HTMLCanvasElement - sourceView: HTMLElement - gutter: HTMLElement - controls: HTMLElement - lastHeight?: string - dragStart?: number[] - - constructor(model: DataModel, el: HTMLElement) { - super(model) - this.el = el - this.canvas = el.querySelector('canvas') as HTMLCanvasElement - this.ctx = this.canvas.getContext('2d')! - this.active = false - this.gutter = el.parentElement!.querySelector('.gutter') as HTMLElement - this.sourceView = el.parentElement!.getElementsByTagName('textarea')[0] as HTMLElement - this.controls = el.querySelector('.visualizer-controls') as HTMLElement - - this.canvas.addEventListener('mousedown', evt => { - this.dragStart = [evt.offsetX, evt.offsetY] - }) - this.canvas.addEventListener('mousemove', evt => { - if (this.dragStart === undefined) return - if (this.visualizer?.onDrag) { - this.visualizer.onDrag(this.dragStart[0], this.dragStart[1], evt.offsetX, evt.offsetY) - this.redraw() - } - this.dragStart = [evt.offsetX, evt.offsetY] - }) - this.canvas.addEventListener('mouseup', evt => { - this.dragStart = undefined - }) - } - - redraw() { - if (this.active && this.visualizer) { - this.visualizer.state = {} - this.invalidated() - } - } - - invalidated() { - this.path = this.path?.withModel(this.model) - let newState: any - if (this.active && this.visualizer && this.path - && this.visualizer.active(this.path) - && (newState = this.path.get())) { - if (newState && this.visualizer.dirty(this.path)) { - const img = this.ctx.createImageData(200, 100) - this.visualizer.state = JSON.parse(JSON.stringify(newState)) - this.visualizer.draw(this.model, img) - this.ctx.putImageData(img, 0, 0) - } - this.el.style.display = 'block' - this.gutter.style.display = 'block' - if (this.lastHeight) { - this.sourceView.style.height = this.lastHeight - this.lastHeight = undefined - } - } else { - this.el.style.display = 'none' - this.gutter.style.display = 'none' - this.lastHeight = this.sourceView.style.height - this.sourceView.style.height = '100%' - } - } - - set(visualizer: Visualizer, path: ModelPath) { - this.active = true - this.visualizer = visualizer - this.path = path - this.visualizer.state = undefined - this.controls.innerHTML = '' - this.visualizer.addControls(this.controls, this) - this.redraw() - } - - static visualizers: Visualizer[] = [ - new BiomeNoiseVisualizer(), - new NoiseSettingsVisualizer() - ] -} diff --git a/src/config.json b/src/config.json index 77a61b9d..7b0dac72 100644 --- a/src/config.json +++ b/src/config.json @@ -42,13 +42,13 @@ } ], "versions": [ - { - "id": "1.17", - "mcdata_ref": "master" - }, { "id": "1.16", "mcdata_ref": "1.16.4" + }, + { + "id": "1.17", + "mcdata_ref": "master" } ], "models": [ @@ -91,48 +91,55 @@ { "id": "worldgen", "name": "Worldgen", - "children": [ - { - "id": "worldgen/biome", - "name": "Biome", - "schema": "biome" - }, - { - "id": "worldgen/carver", - "name": "Carver", - "schema": "configured_carver" - }, - { - "id": "worldgen/feature", - "name": "Feature", - "schema": "configured_feature" - }, - { - "id": "worldgen/noise-settings", - "name": "Noise Settings", - "schema": "noise_settings" - }, - { - "id": "worldgen/structure-feature", - "name": "Structure Feature", - "schema": "configured_structure_feature" - }, - { - "id": "worldgen/surface-builder", - "name": "Surface Builder", - "schema": "configured_surface_builder" - }, - { - "id": "worldgen/processor-list", - "name": "Processor List", - "schema": "processor_list" - }, - { - "id": "worldgen/template-pool", - "name": "Template Pool", - "schema": "template_pool" - } - ] + "category": true + }, + { + "id": "worldgen/biome", + "name": "Biome", + "category": "worldgen", + "schema": "biome" + }, + { + "id": "worldgen/carver", + "name": "Carver", + "category": "worldgen", + "schema": "configured_carver" + }, + { + "id": "worldgen/feature", + "name": "Feature", + "category": "worldgen", + "schema": "configured_feature" + }, + { + "id": "worldgen/noise-settings", + "name": "Noise Settings", + "category": "worldgen", + "schema": "noise_settings" + }, + { + "id": "worldgen/structure-feature", + "name": "Structure Feature", + "category": "worldgen", + "schema": "configured_structure_feature" + }, + { + "id": "worldgen/surface-builder", + "name": "Surface Builder", + "category": "worldgen", + "schema": "configured_surface_builder" + }, + { + "id": "worldgen/processor-list", + "name": "Processor List", + "category": "worldgen", + "schema": "processor_list" + }, + { + "id": "worldgen/template-pool", + "name": "Template Pool", + "category": "worldgen", + "schema": "template_pool" } ], "registries": [ @@ -144,10 +151,10 @@ "item", "loot_condition_type", "loot_function_type", - "loot_nbt_provider_type", - "loot_number_provider_type", + { "id": "loot_nbt_provider_type", "minVersion": "1.17" }, + { "id": "loot_number_provider_type", "minVersion": "1.17" }, "loot_pool_entry_type", - "loot_score_provider_type", + { "id": "loot_score_provider_type", "minVersion": "1.17" }, "mob_effect", "rule_test", "pos_rule_test", @@ -168,9 +175,6 @@ "worldgen/surface_builder", "worldgen/tree_decorator_type", "worldgen/trunk_placer_type", - { - "id": "worldgen/biome", - "path": "/processed/reports/biomes/data.min.json" - } + { "id": "worldgen/biome", "path": "processed/reports/biomes" } ] } diff --git a/src/index.html b/src/index.html index 8fecb624..3ef63d53 100644 --- a/src/index.html +++ b/src/index.html @@ -8,11 +8,12 @@ m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); ga('create', 'UA-73024255-2', 'auto'); - ga('set', 'page', location.pathname.replace(/^\/dev/, '')); + ga('set', 'page', location.pathname); ga('set', 'dimension1', localStorage.getItem('theme') ?? 'default'); ga('set', 'dimension2', 'v2'); ga('set', 'dimension3', localStorage.getItem('schema_version') ?? '1.17'); ga('set', 'dimension4', localStorage.getItem('language') ?? 'en'); + ga('set', 'dimension5', 'none'); ga('send', 'pageview'); @@ -26,101 +27,7 @@ - -
    -
    -
    - -

    -
    - -
    -
    -
    -
    - - - -
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    - - -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    + +
    diff --git a/src/locales/de.json b/src/locales/de.json index 9b8e06b0..459cd8f4 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -7,7 +7,7 @@ "worldgen/feature": "Merkmal", "worldgen/carver": "Borer", "worldgen/biome": "Biom", - "visualize": "Visualisieren", + "preview": "Visualisieren", "title.home": "Datenpaketgeneratoren", "title.generator": "%0%-Generator", "share": "Teilen", diff --git a/src/locales/en.json b/src/locales/en.json index ac4d7fe7..aaa88fd0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -13,7 +13,7 @@ "share": "Share", "title.generator": "%0% Generator", "title.home": "Data Pack Generators", - "visualize": "Visualize", + "preview": "Visualize", "undo": "Undo", "world": "World Settings", "worldgen/biome": "Biome", diff --git a/src/locales/fr.json b/src/locales/fr.json index e7327c7a..331d13e7 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -11,7 +11,7 @@ "share": "Partager", "title.generator": "Générateur de %0%", "title.home": "Générateur de data-pack", - "visualize": "Visualiser", + "preview": "Visualiser", "world": "Paramètres du monde", "worldgen/biome": "Biome", "worldgen/carver": "Sculpteur", diff --git a/src/locales/ja.json b/src/locales/ja.json index 8a14f7d3..0e908b4f 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -11,7 +11,7 @@ "share": "共有", "title.generator": "%0%ジェネレーター", "title.home": "データパックジェネレーター", - "visualize": "可視化", + "preview": "可視化", "world": "ワールド設定 (World Settings)", "worldgen/biome": "バイオーム (Biome)", "worldgen/carver": "地形彫刻 (Carver)", diff --git a/src/locales/pl.json b/src/locales/pl.json index 85b07827..b7b6f0be 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -11,7 +11,7 @@ "share": "Podziel się", "title.generator": "Generator %0%", "title.home": "Generatory Data Packów", - "visualize": "Wizualizuj", + "preview": "Wizualizuj", "world": "Ustawienia Świata", "worldgen/biome": "Biom", "worldgen/carver": "Rzeźbiarz", diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json index 516e78b4..76cf4dd1 100644 --- a/src/locales/zh-cn.json +++ b/src/locales/zh-cn.json @@ -14,7 +14,7 @@ "title.generator": "%0% 生成器", "title.home": "数据包生成器", "undo": "撤销", - "visualize": "可视化", + "preview": "可视化", "world": "世界设置", "worldgen/biome": "生物群系", "worldgen/carver": "地形雕刻器", diff --git a/src/locales/zh-tw.json b/src/locales/zh-tw.json index 62139856..7d5046f5 100644 --- a/src/locales/zh-tw.json +++ b/src/locales/zh-tw.json @@ -14,7 +14,7 @@ "title.generator": "%0% 生成器", "title.home": "資料包生成器", "undo": "復原", - "visualize": "可視化", + "preview": "可視化", "world": "世界設定", "worldgen/biome": "生態域", "worldgen/carver": "地形雕刻器", diff --git a/src/sitemap.txt b/src/sitemap.txt index b5b531e9..0a279abe 100644 --- a/src/sitemap.txt +++ b/src/sitemap.txt @@ -1,6 +1,7 @@ https://misode.github.io https://misode.github.io/loot-table/ https://misode.github.io/predicate/ +https://misode.github.io/item-modifier/ https://misode.github.io/advancement/ https://misode.github.io/dimension/ https://misode.github.io/dimension-type/ diff --git a/src/styles/global.css b/src/styles/global.css index 57f0a9d1..5c66b656 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -38,18 +38,19 @@ padding: 0; } +a svg { + pointer-events: none; +} + body { font-size: 18px; font-family: Arial, Helvetica, sans-serif; -} - -.container { overflow-x: hidden; background-color: var(--background); transition: background-color var(--style-transition); } -.header { +header { display: flex; justify-content: space-between; align-items: center; @@ -65,114 +66,133 @@ body { } .header-title h2 { - margin-right: 10px; color: var(--nav); transition: color var(--style-transition); } -.home-link.nav-item { +.home-link { margin: 0 8px 0 0; + height: 32px; + fill: var(--nav); + transition: fill var(--style-transition); } .home-link svg { + width: 32px; + height: 32px; padding: 2px; } -.nav { +nav ul { display: flex; align-items: center; } -.nav-item { +nav > .toggle, +nav li { display: flex; align-items: center; cursor: pointer; margin: 0 16px; user-select: none; +} + +.home-link:hover svg, +header .toggle:hover svg, +nav li:hover svg { + fill: var(--nav-hover); +} + +nav > .toggle { + display: none; +} + +nav li.dimmed svg { + fill: var(--nav-faded); +} + +nav li.dimmed:hover svg { + fill: var(--nav-faded-hover); +} + +nav > .toggle svg, +nav li > *, +nav li svg { + width: 24px; + height: 24px; fill: var(--nav); transition: fill var(--style-transition); } -.nav-item:hover { - fill: var(--nav-hover); +nav > .toggle span { + color: var(--nav); + margin-left: 5px; } -.nav-item span { - font-size: 1.1em; -} - -.nav-item#source-toggle { - display: none; -} - -.nav-item#github-link { - fill: var(--nav-faded); -} - -.nav-item#github-link:hover { - fill: var(--nav-faded-hover); -} - -.toggle:not(.toggled) svg:nth-child(1) { - display: none; -} - -.toggle.toggled svg:nth-child(2) { - display: none; -} - -.nav-selector { +.dropdown { position: relative; } -.nav-selector-menu { - display: flex; - flex-direction: column; - visibility: hidden; +.dropdown > * { position: absolute; - top: 100%; - left: 0; - margin-top: 10px; - z-index: 10; - border-radius: 3px; - background-color: var(--nav-menu); + width: 24px; + height: 24px; } -.nav-selector-menu .btn { - white-space: nowrap; +.dropdown > *:not(select) { + pointer-events: none; +} + +.dropdown select { + cursor: pointer; + border: none; + background: none; + color: transparent; + outline: none; +} + +.dropdown option { + color: var(--text); + background-color: var(--background); + font-size: 130%; + border: none; } .content { display: flex; height: calc(100vh - 56px); overflow-y: hidden; + color: var(--text); + fill: var(--text); +} + +.split-group { + display: flex; + width: 100%; + height: 100%; +} + +.split-group.vertical { + flex-direction: column; +} + +.panel { + position: relative; + height: 100%; + overflow: hidden; } .tree { - position: relative; -} - -.tree-content { - overflow-y: auto; + display: flow-root; + padding: 16px 16px 50vh; height: 100%; - padding: 1rem; - padding-bottom: 50vh; + overflow: auto; } .source { - position: relative; -} - -.source-content { - display: flex; - flex-direction: column; - height: 100%; -} - -.source textarea { width: 100%; height: 100%; - padding: 1.3rem 0.4rem; + padding: 32px 8px; border: none; white-space: pre; overflow-wrap: normal; @@ -188,39 +208,48 @@ body { transition: background-color var(--style-transition), color var(--style-transition) } -.source textarea::selection { +.source::selection { background-color: var(--selection); } -.tree-controls, -.source-controls, -.visualizer-controls { +.panel-controls { display: flex; - flex-direction: row-reverse; position: absolute; - right: 17px; - top: 0; - padding: 5px; + right: 22px; + top: 5px;; + z-index: 1; } -.tree-controls .btn:not(:first-child), -.source-controls .btn:not(:first-child), -.visualizer-controls .btn:not(:first-child) { +.preview-panel .panel-controls { + right: 5px; +} + +.panel-controls > *:not(:last-child) { margin-right: 5px; } -.tree-controls-menu, -.source-controls-menu { - display: flex; - visibility: hidden; - flex-direction: column; - position: absolute; - right: 17px; - top: 37px; - padding: 5px; +.panel-menu { + position: relative; } -.source-controls input { +.panel-menu > .btn { + height: 100%; +} + +.panel-menu-list { + display: none; + flex-direction: column; + position: absolute; + right: 0; + top: 100%; + margin-top: 5px; +} + +.panel-menu .btn.active ~ .panel-menu-list { + display: flex; +} + +.panel-controls input { margin-right: 5px; background: var(--background); color: var(--text); @@ -229,7 +258,7 @@ body { transition: background-color var(--style-transition), color var(--style-transition); } -.source-controls input::selection { +.panel-controls input::selection { background-color: var(--selection); } @@ -250,17 +279,7 @@ body { cursor: ew-resize; } -.visualizer-content { - width: 100%; - max-width: 100%; - position: relative; -} - -.visualizer-controls { - right: 0; -} - -.visualizer-content canvas { +.preview-panel canvas { width: 100%; height: 100%; background-color: var(--nav-faded); @@ -283,6 +302,7 @@ body { cursor: pointer; outline: none; font-size: 1rem; + white-space: nowrap; background-color: var(--btn-background); color: var(--btn-text); fill: var(--btn-text); @@ -315,6 +335,7 @@ body { .errors { position: fixed; + display: flex; bottom: 17px; right: 17px; margin: 5px; @@ -325,31 +346,27 @@ body { transition: fill var(--style-transition); } -.errors:not(.active) .error-list { - display: none; -} - .error { display: flex; align-items: center; padding: 7px; } -.error span { - padding-left: 11px; +.error span:not(:last-child) { + padding-right: 11px; } .errors .toggle { padding: 6px; width: 36px; height: 36px; - align-self: flex-end; cursor: pointer; user-select: none; } -.errors.active .toggle { - padding: 2px; +.errors svg { + width: 24px; + height: 24px; } .home { @@ -361,6 +378,7 @@ body { display: flex; flex-direction: column; margin: 0 20px; + list-style-type: none; } .generators-card { @@ -369,6 +387,7 @@ body { cursor: pointer; user-select: none; text-decoration: none; + text-transform: capitalize; border-radius: 3px; background-color: var(--nav-faded); color: var(--text); @@ -380,6 +399,10 @@ body { transition: margin 0.2s; } +.generators-card * { + pointer-events: none; +} + .generators-card:hover, .generators-card.selected { background-color: var(--nav-faded-hover); @@ -391,17 +414,54 @@ body { margin-left: 10px; } +.spinner { + margin: 40px auto 0; + width: 80px; + height: 80px; +} + +.spinner:after { + content: ""; + display: block; + width: 64px; + height: 64px; + margin: 8px; + border-radius: 50%; + border: 6px solid var(--border); + border-color: var(--border) transparent var(--border) transparent; + animation: spinner 1.2s linear infinite, fadein 0.4s; + transition: border-color var(--style-transition); +} + +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + @media screen and (max-width: 580px) { body { overflow-y: hidden; } - .header { + header { flex-direction: column; height: 92px; } - .nav { + nav { align-self: flex-end; } @@ -410,42 +470,36 @@ body { height: calc(100vh - 92px); } - .source, - .gutter.gutter-horizontal { + .tree { + padding-top: 50px; + } + + .tree-panel + .gutter { display: none; } - .tree { + .content-output, + .tree-panel { width: 100% !important; - height: 100%; } - .nav-item#source-toggle { + nav > .toggle { display: flex; position: absolute; left: 10px; } - .source.active { - display: initial; - position: absolute; - width: 100% !important; - height: 100%; + body[data-panel="tree"] .content-output { + display: none; } - .source.active .source-controls { - right: 17px; - top: 0px; + body[data-panel="source"] .tree-panel { + display: none; } - .source.active .source-controls-menu { - right: 17px; - top: 37px; - } - - .nav-selector-menu { - right: 0; - left: initial; + .home { + padding: 5px; + justify-content: center; } } @@ -453,10 +507,19 @@ body { .header-title h2 { font-size: 18px; } + + .generators-list { + margin: 0 15px; + } + + .generators-card { + font-size: 14px; + padding: 8px; + } } @media screen and (min-width: 581px) and (max-width: 640px) { .header-title h2 { - font-size: 22px; + font-size: 23px; } } diff --git a/src/styles/nodes.css b/src/styles/nodes.css index 492f19b3..f57508fa 100644 --- a/src/styles/nodes.css +++ b/src/styles/nodes.css @@ -174,10 +174,6 @@ button.remove { display: inline-block; } -.node-icon svg { - cursor: pointer; -} - .node-icon .icon-popup { visibility: hidden; width: 240px; @@ -209,19 +205,20 @@ button.remove { visibility: visible; } -.node-icon > svg { +.node-icon svg { height: 34px; width: 34px; min-width: 34px; margin-left: 6px; + cursor: pointer; transition: fill var(--style-transition); } -.node-header svg.node-help { +.node-icon.node-help svg { fill: var(--node-border); } -.node-header svg.node-error { +.node-icon.node-error svg { fill: var(--node-remove); } diff --git a/tsconfig.json b/tsconfig.json index d8a0e1e0..f497f803 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ } }, "include": [ - "src" + "src", "app_.ts" ] } diff --git a/webpack.config.js b/webpack.config.js index 2bef9272..740e6d72 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,7 +5,7 @@ const webpack = require('webpack'); const config = require('./src/config.json') module.exports = (env, argv) => ({ - entry: './src/app/app.ts', + entry: './src/app/Router.ts', output: { path: __dirname + '/dist', filename: 'js/bundle.js' @@ -47,20 +47,10 @@ module.exports = (env, argv) => ({ filename: '404.html', template: 'src/index.html' }), - ...config.models.flatMap(buildModel) + ...config.models.map(m => new HtmlWebpackPlugin({ + title: `${m.name} Generator${m.category === true ? 's' : ''} Minecraft 1.16, 1.17`, + filename: `${m.id}/index.html`, + template: 'src/index.html' + })) ] }) - -function buildModel(model) { - const page = new HtmlWebpackPlugin({ - title: `${model.name} Generator Minecraft 1.16, 1.17`, - filename: `${model.id}/index.html`, - template: 'src/index.html' - }) - if (model.schema) { - return page - } else if (model.children) { - return [page, ...model.children.flatMap(buildModel)] - } - return [] -}