add basic code editor for playground

This commit is contained in:
Moritz Hölting 2024-06-20 15:43:14 +02:00
parent 2842926f4f
commit 48a450c2fb
14 changed files with 1246 additions and 92 deletions

View File

@ -2,75 +2,77 @@ import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightLinksValidator from "starlight-links-validator";
import shikiConfig from './src/utils/shiki';
import react from "@astrojs/react";
// https://astro.build/config
export default defineConfig({
integrations: [
starlight({
title: 'ShulkerScript',
logo: {
src: './src/assets/logo.webp',
alt: 'ShulkerScript Logo',
},
favicon: '/favicon.ico',
description: 'A simple and powerful scripting language for Minecraft datapacks.',
social: {
github: 'https://github.com/moritz-hoelting/shulkerscript-cli',
},
tableOfContents: { minHeadingLevel: 1, maxHeadingLevel: 3 },
defaultLocale: 'root',
locales: {
root: {
label: 'English',
lang: 'en',
},
de: {
label: 'Deutsch',
lang: 'de',
},
},
editLink: {
baseUrl: 'https://github.com/moritz-hoelting/shulkerscript-webpage/edit/main',
},
customCss: ['./src/styles/style.css'],
plugins: [starlightLinksValidator({
errorOnFallbackPages: false,
})],
expressiveCode: {
shiki: shikiConfig,
},
sidebar: [
{
label: 'Guides',
autogenerate: {
directory: 'guides',
},
translations: {
de: 'Anleitungen',
}
},
{
label: 'Roadmap',
link: '/roadmap',
translations: {
de: 'Zukunftspläne',
},
},
{
label: 'Reference',
autogenerate: {
directory: 'reference',
},
collapsed: true,
translations: {
de: 'Referenz',
},
badge: {
text: 'WIP',
variant: 'caution',
}
}
]
}),
],
});
integrations: [react(), starlight({
title: 'ShulkerScript',
logo: {
src: './src/assets/logo.webp',
alt: 'ShulkerScript Logo'
},
favicon: '/favicon.ico',
description: 'A simple and powerful scripting language for Minecraft datapacks.',
social: {
github: 'https://github.com/moritz-hoelting/shulkerscript-cli'
},
tableOfContents: {
minHeadingLevel: 1,
maxHeadingLevel: 3
},
defaultLocale: 'root',
locales: {
root: {
label: 'English',
lang: 'en'
},
de: {
label: 'Deutsch',
lang: 'de'
}
},
editLink: {
baseUrl: 'https://github.com/moritz-hoelting/shulkerscript-webpage/edit/main'
},
customCss: ['./src/styles/style.css'],
plugins: [starlightLinksValidator({
errorOnFallbackPages: false
})],
expressiveCode: {
shiki: shikiConfig
},
components: {
PageTitle: './src/components/override/PageTitle.astro',
ContentPanel: './src/components/override/ContentPanel.astro',
},
sidebar: [{
label: 'Guides',
autogenerate: {
directory: 'guides'
},
translations: {
de: 'Anleitungen'
}
}, {
label: 'Roadmap',
link: '/roadmap',
translations: {
de: 'Zukunftspläne'
}
}, {
label: 'Reference',
autogenerate: {
directory: 'reference'
},
collapsed: true,
translations: {
de: 'Referenz'
},
badge: {
text: 'WIP',
variant: 'caution'
}
}]
})]
});

View File

@ -11,10 +11,23 @@
},
"dependencies": {
"@astrojs/check": "^0.5.10",
"@astrojs/react": "^3.6.0",
"@astrojs/starlight": "^0.24.2",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@monaco-editor/react": "^4.6.0",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"astro": "^4.10.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sharp": "^0.32.6",
"starlight-links-validator": "^0.7.1",
"typescript": "^5.4.5"
},
"devDependencies": {
"sass": "^1.77.6"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
namespace "my-shulkerscript-pack";
#[tick]
fn main() {
// Change this
/say Hello World!
}

View File

@ -0,0 +1,115 @@
import React, { useEffect, useState } from "react";
import Editor, { useMonaco, type Monaco } from "@monaco-editor/react";
import FileView from "./playground/FileView";
import "@styles/playground.scss";
import mainFileContent from "@assets/playground/main.shu?raw";
import Header from "./playground/Header";
export type File = {
language?: string;
content: string;
};
export type Directory = {
dirs?: { [key: string]: Directory };
files?: { [key: string]: File };
};
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
export default function Playground() {
const rootDir: Directory = {
dirs: {
src: {
files: {
"main.shu": {
content: mainFileContent,
},
},
},
},
files: {
"pack.toml": {
content: "pack.toml content",
language: "toml",
},
},
};
const [fileName, setFileName] = useState("src/main.shu");
const file = getFile(rootDir, fileName)!;
const monaco = useMonaco();
useEffect(() => {
if (monaco) {
loadFiles(monaco, rootDir);
}
}, [monaco]);
return (
<>
<main
className="playground not-content"
style={{
maxWidth: "95vw",
marginInline: "auto",
marginTop: "0.5cm",
}}
>
<Header />
<FileView
className="file-view"
root={rootDir}
fileName={fileName}
setSelectedFileName={setFileName}
/>
<div className="editor">
<Editor
height="60vh"
theme="vs-dark"
path={fileName}
defaultLanguage={file.language}
defaultValue={file.content}
/>
</div>
</main>
</>
);
}
function getFile(root: Directory, path: string): File | null {
if (path.includes("/")) {
let dir = root;
const split = path.split("/");
let last = split.pop()!;
for (const dirName of split) {
if (dir.dirs) {
dir = dir.dirs[dirName];
} else {
return null;
}
}
return dir.files?.[last] ?? null;
}
return root.files?.[path] ?? null;
}
function loadFiles(monaco: Monaco, dir: Directory, prefix = "") {
for (const [name, d] of Object.entries(dir.dirs ?? {})) {
loadFiles(monaco, d, prefix + name + "/");
}
for (const [name, file] of Object.entries(dir.files ?? {})) {
loadFile(monaco, file, prefix + name);
}
}
function loadFile(monaco: Monaco, file: File, name: string) {
const uri = monaco.Uri.parse(name);
if (!monaco.editor.getModel(uri)) {
monaco.editor.createModel(file.content, file.language, uri);
}
}

View File

@ -0,0 +1,9 @@
---
import type { Props } from "@astrojs/starlight/props";
import Default from "@astrojs/starlight/components/ContentPanel.astro";
const isPlayground = Astro.props.slug === 'playground';
---
{isPlayground ? <slot /> : <Default {...Astro.props}><slot /></Default>}

View File

@ -0,0 +1,9 @@
---
import type { Props } from "@astrojs/starlight/props";
import Default from "@astrojs/starlight/components/PageTitle.astro";
const isPlayground = Astro.props.slug === 'playground';
---
{isPlayground ? <></> : <Default {...Astro.props}><slot /></Default>}

View File

@ -0,0 +1,125 @@
import type { Directory, SetState } from "@components/Playground";
import React, { useState } from "react";
export default function FileView({
root,
fileName,
setSelectedFileName,
className,
}: {
root: Directory;
fileName: string;
setSelectedFileName: SetState<string>;
className?: string;
}) {
return (
<div className={className}>
{Object.entries(root.dirs ?? {}).map(([name, dir]) => {
return (
<DirElement
key={name}
name={name}
dir={dir}
fileName={fileName.slice(name.length + 1)}
setSelectedFileName={setSelectedFileName}
/>
);
})}
{Object.entries(root.files ?? {}).map(([name, _]) => {
return (
<span key={name}>
<FileElement
name={name}
disabled={fileName == name}
onClick={() => setSelectedFileName(name)}
/>
</span>
);
})}
</div>
);
}
function FileElement({
name,
disabled,
onClick,
}: {
name: string;
disabled: boolean;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) {
return (
<button disabled={disabled} onClick={onClick}>
{name}
</button>
);
}
function DirElement({
name,
dir: currentDir,
collapsed: pCollapsed,
fileName,
setSelectedFileName,
}: {
name: string;
dir: Directory;
collapsed?: boolean;
fileName: string;
setSelectedFileName: SetState<string>;
}) {
const [collapsed, setCollapsed] = useState(pCollapsed ?? false);
const modSetSelectedFileName: SetState<string> = (selected) => {
setSelectedFileName(name + "/" + selected);
};
return (
<div key={name}>
<button
style={{ display: "block" }}
onClick={() => setCollapsed(!collapsed)}
>
{name}/
</button>
<div style={{ marginLeft: ".25cm" }}>
{collapsed ? null : (
<div>
{Object.entries(currentDir.dirs ?? {}).map(
([dirname, dir]) => {
return (
<DirElement
key={name}
name={name}
dir={dir}
fileName={fileName.slice(
name.length + 1
)}
setSelectedFileName={
modSetSelectedFileName
}
/>
);
}
)}
{Object.entries(currentDir.files ?? {}).map(
([currentName, _]) => {
return (
<FileElement
key={name}
name={name}
disabled={fileName == currentName}
onClick={() =>
modSetSelectedFileName(currentName)
}
/>
);
}
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import SplitButton from "./SplitButton";
export default function Header() {
const clickBuild = () => {
console.log("build");
}
const clickZip = () => {
console.log("zip");
}
return (
<header style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5cm",
}}>
<h1 id="_top">Playground</h1>
<SplitButton onClick={clickBuild} options={[["Download zip", clickZip]]}>Build</SplitButton>
</header>
);
}

View File

@ -0,0 +1,115 @@
import * as React from "react";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import { ThemeProvider } from "@mui/material";
import { customTheme } from "@utils/material-ui-theme";
export default function SplitButton({
options,
children,
onClick,
}: {
options: [string, React.MouseEventHandler<HTMLLIElement>][];
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
index: number
) => {
options[index][1](event);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event: Event) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
}
setOpen(false);
};
return (
<ThemeProvider theme={customTheme}>
<ButtonGroup
variant="contained"
ref={anchorRef}
aria-label="Button group with a nested menu"
>
<Button onClick={onClick}>
{children}
</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select merge strategy"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{
zIndex: 1,
}}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === "bottom"
? "center top"
: "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{options.map((option, index) => (
<MenuItem
key={option[0]}
onClick={(event) =>
handleMenuItemClick(
event,
index
)
}
>
{option[0]}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</ThemeProvider>
);
}

View File

@ -0,0 +1,8 @@
---
import PlaygroundComponent from '@components/Playground';
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
---
<StarlightPage frontmatter={{ title: 'Playground', template: "splash" }}>
<PlaygroundComponent client:only="react" />
</StarlightPage>

View File

@ -0,0 +1,19 @@
.playground {
display: grid;
grid-template-columns: 1fr 4fr;
grid-template-areas:
"header header"
"files editor";
> header {
grid-area: header;
}
> .file-view {
grid-area: files;
display: flex;
flex-direction: column;
}
> .editor {
grid-area: editor;
}
}

View File

@ -0,0 +1,18 @@
import { createTheme } from '@mui/material/styles';
export const customTheme = createTheme({
palette: {
primary: {
light: '#a700c3',
main: '#a400c0',
dark: '#a400c0',
contrastText: '#fff',
},
secondary: {
light: '#ff7961',
main: '#f44336',
dark: '#ba000d',
contrastText: '#000',
},
},
});

View File

@ -1,3 +1,12 @@
{
"extends": "astro/tsconfigs/strict"
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@*": ["src/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"exclude": ["dist", "node_modules"]
}