feat(v3): simplify the project to contain only required tools
This commit is contained in:
@@ -2,16 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
|
||||
import Controls from "./components/Controls.jsx";
|
||||
import HistogramPanel from "./components/HistogramPanel.jsx";
|
||||
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js";
|
||||
|
||||
function defaultCropParams(state) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: state?.width || 256,
|
||||
height: state?.height || 256
|
||||
};
|
||||
}
|
||||
import { applyStateOperation, combineStates, deleteState, getOperations, listStates, uploadImage } from "./lib/api.js";
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null);
|
||||
@@ -29,10 +20,10 @@ export default function App() {
|
||||
getOperations()
|
||||
.then((payload) => {
|
||||
setOperations(payload.operations || []);
|
||||
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0];
|
||||
const first = payload.operations?.[0];
|
||||
if (first) {
|
||||
setSelectedOperation(first.id);
|
||||
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])));
|
||||
setParams({ _repeat: 1, ...Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])) });
|
||||
}
|
||||
})
|
||||
.catch((error) => setStatus(error.message));
|
||||
@@ -59,7 +50,6 @@ export default function App() {
|
||||
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
|
||||
setTransform({ x: 0, y: 0, scale: 1 });
|
||||
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
|
||||
if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0]));
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
@@ -69,7 +59,7 @@ export default function App() {
|
||||
|
||||
function handleSelectOperation(operationId, nextParams) {
|
||||
setSelectedOperation(operationId);
|
||||
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams);
|
||||
setParams(nextParams);
|
||||
}
|
||||
|
||||
function handleParamChange(key, value) {
|
||||
@@ -112,14 +102,35 @@ export default function App() {
|
||||
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
|
||||
}
|
||||
|
||||
async function handleDeleteState(state) {
|
||||
if (!state || !session) return;
|
||||
setBusy(true);
|
||||
setStatus(`Deleting ${state.label}...`);
|
||||
try {
|
||||
await deleteState(state.state_id);
|
||||
const remaining = await listStates(session.session_id);
|
||||
const nextStates = remaining.states || [];
|
||||
setStates(nextStates);
|
||||
const currentActive = nextStates.find((item) => item.state_id === activeState?.state_id);
|
||||
const fallback = activeState?.state_id === state.state_id ? nextStates.at(-1) || nextStates[0] || null : currentActive || nextStates.at(-1) || nextStates[0] || null;
|
||||
setActiveState(fallback);
|
||||
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
|
||||
setStatus(`${state.label} deleted.`);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const originalState = states[0] || null;
|
||||
const viewportTitle = useMemo(() => {
|
||||
if (!activeState) return "No active state";
|
||||
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
|
||||
return `${activeState.label} - ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
|
||||
}, [activeState]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
|
||||
<div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
|
||||
<Controls
|
||||
operations={operations}
|
||||
selectedOperation={selectedOperation}
|
||||
@@ -134,13 +145,13 @@ export default function App() {
|
||||
onApply={handleApply}
|
||||
onSelectState={(state) => {
|
||||
setActiveState(state);
|
||||
if (selectedOperation === "crop") setParams(defaultCropParams(state));
|
||||
}}
|
||||
onToggleCombineState={toggleCombineState}
|
||||
onCombine={handleCombine}
|
||||
onDeleteState={handleDeleteState}
|
||||
/>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
|
||||
@@ -149,9 +160,15 @@ export default function App() {
|
||||
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1 bg-zinc-800">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-zinc-800">
|
||||
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
|
||||
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} />
|
||||
<CanvasPane
|
||||
title="Active State"
|
||||
imageData={activeState?.image_data}
|
||||
histogram={activeState?.histogram}
|
||||
transform={transform}
|
||||
onTransform={setTransform}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />
|
||||
|
||||
@@ -6,12 +6,13 @@ vi.mock("./lib/api.js", () => ({
|
||||
listStates: () => Promise.resolve({ states: [] }),
|
||||
uploadImage: vi.fn(),
|
||||
applyStateOperation: vi.fn(),
|
||||
combineStates: vi.fn()
|
||||
combineStates: vi.fn(),
|
||||
deleteState: vi.fn()
|
||||
}));
|
||||
|
||||
describe("App real render", () => {
|
||||
it("mounts without mocking third-party components", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,23 @@ vi.mock("react-quick-pinch-zoom", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./lib/api.js", () => ({
|
||||
getOperations: () => Promise.resolve({ operations: [] }),
|
||||
getOperations: () => Promise.resolve({
|
||||
operations: [
|
||||
{ id: "histeq", label: "Histogram Equalization", chapter: "Basic", slide_group: "Histogram", params: {}, matrices: [] }
|
||||
]
|
||||
}),
|
||||
listStates: () => Promise.resolve({ states: [] }),
|
||||
uploadImage: vi.fn(),
|
||||
applyStateOperation: vi.fn(),
|
||||
combineStates: vi.fn()
|
||||
combineStates: vi.fn(),
|
||||
deleteState: vi.fn()
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the academic workspace immediately", () => {
|
||||
it("renders the workspace immediately", async () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image States")).toBeInTheDocument();
|
||||
expect(screen.getByText("Combine Selected States")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Arithmetic / Logic")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react";
|
||||
import { Combine, Layers, SlidersHorizontal, Trash2, Upload } from "lucide-react";
|
||||
|
||||
const REPEAT_SCHEMA = { type: "int", default: 1, min: 1, max: 20, step: 1, label: "N (times)" };
|
||||
|
||||
function defaultParams(operation) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
|
||||
);
|
||||
return {
|
||||
_repeat: 1,
|
||||
...Object.fromEntries(
|
||||
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function groupOperations(operations) {
|
||||
@@ -16,15 +21,24 @@ function groupOperations(operations) {
|
||||
}
|
||||
|
||||
function ParamControl({ name, schema, value, onChange }) {
|
||||
const label = schema.label || name;
|
||||
function parseNumeric(raw) {
|
||||
let next = schema.type === "int" ? parseInt(raw || schema.default, 10) : Number(raw);
|
||||
if (schema.type === "int" && schema.odd && next % 2 === 0) next += 1;
|
||||
if (Number.isFinite(schema.min)) next = Math.max(schema.min, next);
|
||||
if (Number.isFinite(schema.max)) next = Math.min(schema.max, next);
|
||||
return next;
|
||||
}
|
||||
if (schema.type === "select") {
|
||||
return (
|
||||
<label className="mb-3 block text-xs text-zinc-300">
|
||||
<span className="mb-1 block">{name}</span>
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<select className="w-full border border-zinc-700 bg-zinc-950 px-2 py-2 text-sm" value={value ?? schema.default} onChange={(event) => onChange(name, event.target.value)}>
|
||||
{schema.choices.map((choice) => (
|
||||
<option key={choice} value={choice}>{choice}</option>
|
||||
))}
|
||||
</select>
|
||||
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -32,14 +46,15 @@ function ParamControl({ name, schema, value, onChange }) {
|
||||
return (
|
||||
<label className="mb-3 flex items-center gap-2 text-xs text-zinc-300">
|
||||
<input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} />
|
||||
{name}
|
||||
{label}
|
||||
{schema.description ? <span className="text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label className="mb-4 block">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
|
||||
<span>{name}{schema.odd ? " (odd)" : ""}</span>
|
||||
<span>{label}{schema.odd ? " (odd)" : ""}</span>
|
||||
<input
|
||||
className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums"
|
||||
type="number"
|
||||
@@ -47,7 +62,7 @@ function ParamControl({ name, schema, value, onChange }) {
|
||||
max={schema.max}
|
||||
step={schema.step}
|
||||
value={value ?? schema.default}
|
||||
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value || schema.default, 10) : Number(event.target.value))}
|
||||
onChange={(event) => onChange(name, parseNumeric(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -56,13 +71,19 @@ function ParamControl({ name, schema, value, onChange }) {
|
||||
max={schema.max}
|
||||
step={schema.step}
|
||||
value={value ?? schema.default}
|
||||
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value, 10) : Number(event.target.value))}
|
||||
onChange={(event) => onChange(name, parseNumeric(event.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowParam(schema, params) {
|
||||
if (!schema.show_when) return true;
|
||||
return params?.[schema.show_when.param] === schema.show_when.value;
|
||||
}
|
||||
|
||||
export default function Controls({
|
||||
operations,
|
||||
selectedOperation,
|
||||
@@ -78,23 +99,91 @@ export default function Controls({
|
||||
onSelectState,
|
||||
onToggleCombineState,
|
||||
onCombine,
|
||||
onDeleteState,
|
||||
}) {
|
||||
const grouped = groupOperations(operations);
|
||||
const operation = operations.find((item) => item.id === selectedOperation);
|
||||
const combineActions = [
|
||||
["add", "add"],
|
||||
["subtract", "subtract"],
|
||||
["dot_product", "dot product"],
|
||||
["average", "average selected"],
|
||||
["and", "and"],
|
||||
["or", "or"]
|
||||
];
|
||||
|
||||
function renderMatrixPreview(matrix) {
|
||||
if (matrix.kernels) {
|
||||
return (
|
||||
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
|
||||
<div className="mb-2 text-xs font-semibold text-cyan-200">{matrix.title}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{matrix.kernels.map((kernel) => (
|
||||
<div key={kernel.label}>
|
||||
<div className="mb-1 text-[11px] text-zinc-400">{kernel.label}</div>
|
||||
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${kernel.matrix[0].length}, minmax(1.75rem, auto))` }}>
|
||||
{kernel.matrix.flat().map((value, index) => (
|
||||
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-cyan-200">{matrix.title}{matrix.scale ? ` (${matrix.scale})` : ""}</div>
|
||||
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${matrix.matrix[0].length}, minmax(1.75rem, auto))` }}>
|
||||
{matrix.matrix.flat().map((value, index) => (
|
||||
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCombineActions() {
|
||||
return (
|
||||
<div className="border border-zinc-800 bg-zinc-950/70 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200">
|
||||
<Combine size={14} />
|
||||
Arithmetic / Logic
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{combineActions.map(([kind, label]) => (
|
||||
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderParameterDrawer(item) {
|
||||
if (selectedOperation !== item.id) return null;
|
||||
const canApply = Boolean(activeState) && !busy;
|
||||
const visibleParams = Object.entries(item.params || {}).filter(([, schema]) => shouldShowParam(schema, params));
|
||||
return (
|
||||
<div className="border border-emerald-700 bg-zinc-950 p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300">
|
||||
<SlidersHorizontal size={14} />
|
||||
Parameters
|
||||
</div>
|
||||
{Object.entries(item.params || {}).map(([name, schema]) => (
|
||||
{item.formula ? <div className="mb-3 border border-zinc-800 bg-zinc-900/70 p-2 text-xs leading-relaxed text-zinc-300">{item.formula}</div> : null}
|
||||
{item.repeatable ? <ParamControl name="_repeat" schema={REPEAT_SCHEMA} value={params._repeat} onChange={onParamChange} /> : null}
|
||||
{visibleParams.map(([name, schema]) => (
|
||||
<ParamControl key={name} name={name} schema={schema} value={params[name]} onChange={onParamChange} />
|
||||
))}
|
||||
{Object.keys(item.params || {}).length === 0 ? <p className="mb-3 text-sm text-zinc-500">No parameters.</p> : null}
|
||||
<button disabled={!activeState || busy} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{visibleParams.length === 0 ? <p className="mb-3 text-sm text-zinc-500">No operation-specific parameters.</p> : null}
|
||||
{item.matrices?.length ? (
|
||||
<div className="mb-3 space-y-2">
|
||||
{item.matrices.map(renderMatrixPreview)}
|
||||
</div>
|
||||
) : null}
|
||||
<button disabled={!canApply} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Apply to Active State
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,9 +191,9 @@ export default function Controls({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[430px]">
|
||||
<aside className="flex h-screen w-[430px] shrink-0 flex-col overflow-hidden border-r border-zinc-800 bg-zinc-950">
|
||||
<div className="border-b border-zinc-800 px-5 py-4">
|
||||
<h1 className="text-lg font-semibold text-zinc-50">Academic Image Processing Workspace</h1>
|
||||
<h1 className="text-lg font-semibold text-zinc-50">Image Processing Workspace</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p>
|
||||
</div>
|
||||
|
||||
@@ -129,33 +218,29 @@ export default function Controls({
|
||||
<div className="truncate text-sm text-zinc-100">{state.label}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{state.width}x{state.height} - {state.operation}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.sequence === 0 || busy}
|
||||
onClick={() => onDeleteState(state)}
|
||||
className="border border-zinc-700 p-1.5 text-zinc-400 hover:border-red-500 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title={state.sequence === 0 ? "S0 cannot be deleted" : `Delete ${state.label}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-zinc-800 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||
<Combine size={16} className="text-cyan-300" />
|
||||
Combine Selected States
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["add", "subtract", "dot_product", "average", "and", "or"].map((kind) => (
|
||||
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{kind}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{Object.entries(grouped).map(([chapter, groups]) => (
|
||||
<details key={chapter} open={chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||
<details key={chapter} open={chapter === "Basic" || chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
|
||||
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary>
|
||||
<div className="space-y-3 border-t border-zinc-800 p-3">
|
||||
{chapter === "Basic" ? renderCombineActions() : null}
|
||||
{Object.entries(groups).map(([slideGroup, items]) => (
|
||||
<details key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
|
||||
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</summary>
|
||||
<section key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
|
||||
<div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</div>
|
||||
<div className="grid grid-cols-1 gap-2 p-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="space-y-2">
|
||||
@@ -164,14 +249,13 @@ export default function Controls({
|
||||
onClick={() => onSelectOperation(item.id, defaultParams(item))}
|
||||
className={`w-full border px-3 py-2 text-left text-sm ${selectedOperation === item.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800"} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
{item.id === "crop" ? <Crop size={14} className="mr-2 inline" /> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
{renderParameterDrawer(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -51,6 +51,14 @@ export async function getStateHistogram(stateId) {
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteState(stateId) {
|
||||
const response = await fetch(`${API_BASE}/api/states/${stateId}/`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
if (response.status === 204) return {};
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function processImage(sessionId, operation, params) {
|
||||
const response = await fetch(`${API_BASE}/api/process/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -13,6 +13,13 @@ body {
|
||||
min-height: 100vh;
|
||||
background: #09090b;
|
||||
color: #e4e4e7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
canvas {
|
||||
|
||||
Reference in New Issue
Block a user