migrated the frontend file generation, changed folder structure

This commit is contained in:
2026-05-06 20:06:47 +03:00
parent 12a64e9f2f
commit 76fe1c4294
23 changed files with 908 additions and 0 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules
package-lock.json
# postman
.postman
postman

View File

@@ -0,0 +1,26 @@
<!-- index.html -->
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
background: none;
background-color: var(--mantine-color-secondary);
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
{
"name": "task-plugins",
"version": "1.0.0",
"type": "module",
"description": "ES module task plugin",
"module": "./dist/plugin.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/plugin.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"dev": "vite build --watch",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.28.0",
"typescript": "^5.0.0",
"vite": "^8.0.10",
"vite-plugin-singlefile": "^2.3.3"
},
"files": [
"dist"
],
"sideEffects": false,
"dependencies": {
"@mantine/core": "^9.1.1",
"@mantine/notifications": "^9.1.1",
"@tabler/icons-react": "^3.41.1",
"miew-react": "^0.11.0",
"react-resizable-panels": "^4.10.0"
}
}

View File

@@ -0,0 +1,85 @@
import { Textarea } from "@mantine/core";
import { useEffect, useRef } from "react";
interface TextAreaProps {
text: string;
onTextChange: (text: string) => void;
}
export function CodeTextArea(props: TextAreaProps) {
const inputRef = useRef(null);
const lineRef = useRef(null);
//Synchronize scrolling between the
useEffect(() => {
const inputEl = document.querySelector(".MoleculeEditInput");
const lineEl = document.querySelector(".MoleculeEditLineNumber");
if (!inputEl || !lineEl) return;
let isSyncing = false; // prevents circular scroll events
let activeEl = null; // element currently being scrolled
const syncScroll = (source: Element, target: Element) => {
if (isSyncing) return;
isSyncing = true;
requestAnimationFrame(() => {
target.scrollTop = source.scrollTop;
isSyncing = false;
});
};
const onScroll = (e: Event) => {
activeEl = e.target;
if (activeEl === inputEl) {
syncScroll(inputEl, lineEl);
} else {
syncScroll(lineEl, inputEl);
}
};
inputEl.addEventListener("scroll", onScroll, { passive: true });
lineEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
inputEl.removeEventListener("scroll", onScroll);
lineEl.removeEventListener("scroll", onScroll);
};
}, []);
return (
<Textarea
className="MoleculeEdit"
value={props.text}
ref={inputRef}
onChange={(e) => {
props.onTextChange(e.currentTarget.value);
}}
autoFocus
wrap="no-wrap"
leftSection={
<div className="MoleculeEditLineNumber" ref={lineRef}>
{Array.from({ length: props.text.split("\n").length }, (_, i) => {
if (i > 1) {
return i - 1;
} else {
return "";
}
}).join("\n")}
</div>
}
classNames={{
input: "MoleculeEditInput",
wrapper: "MoleculeEditWrapper",
}}
onKeyDown={(e) => {
// Stop arrow keys from reaching react-resizable-panels
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.stopPropagation();
}
}}
></Textarea>
);
}

View File

@@ -0,0 +1,94 @@
import {
Button,
Center,
Divider,
Tabs,
Text,
Textarea,
useMantineTheme,
} from "@mantine/core";
import { Group, Panel, Separator } from "react-resizable-panels";
import "./MoleculePage.css";
import {
IconCheck,
IconCode,
IconGripVertical,
IconList,
IconX,
} from "@tabler/icons-react";
import { CodeTextArea } from "./CodeTextArea/CodeTextArea";
import MoleculeViewer from "./MoleculeViewer/MoleculeViewer";
import { MoleculeData, SelectedAtom, TaskEditorProps } from "../Types/plugin";
import { useState } from "react";
function MoleculeEditorPage(props: TaskEditorProps<MoleculeData>) {
const [text, setText] = useState(props.data?.data.text);
const [selectedAtom, setSelectedAtom] = useState<SelectedAtom | null>(null);
const theme = useMantineTheme();
return (
<>
<div style={{ display: "flex", flexDirection: "column", height: "90vh" }}>
<Divider style={{ margin: "10px" }} />
<Group style={{ flex: 1, gap: "5px" }}>
<Panel minSize={200} defaultSize={500}>
<Tabs
variant="outline"
style={{
height: "100%",
display: "flex",
flexDirection: "column",
}}
radius="lg"
defaultValue="code"
classNames={{ panel: "tabPanel" }}
>
<Tabs.List>
<Tabs.Tab value="code" leftSection={<IconCode size={12} />}>
Код
</Tabs.Tab>
<Tabs.Tab value="list" leftSection={<IconList size={12} />}>
Список
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="code">
<CodeTextArea
text={text || ""}
onTextChange={(text) => {
setText(text);
props.setData({ text: text });
}}
/>
</Tabs.Panel>
<Tabs.Panel value="list">
<></>
</Tabs.Panel>
</Tabs>
</Panel>
<Separator>
<Center
style={{
height: "100%",
background: theme.colors.dark[4],
borderRadius: "5px",
}}
>
<IconGripVertical size={12} />
</Center>
</Separator>
<Panel style={{ flexShrink: 0 }} minSize={200}>
<MoleculeViewer
moleculeData={props.data ? props.data.data.text : ""}
selectedAtom={selectedAtom}
setSelectedAtom={setSelectedAtom}
/>
</Panel>
</Group>
</div>
</>
);
}
export default MoleculeEditorPage;

View File

@@ -0,0 +1,77 @@
.experimentButtons {
display: flex;
gap: 20px;
flex-direction: row;
justify-content: right;
width: fit-content;
margin-left: auto;
flex-wrap: nowrap;
text-wrap: nowrap;
}
.MoleculeEdit {
height: 100%;
display: flex;
}
.MoleculeEditWrapper {
display: flex !important;
flex: 1 !important;
min-width: 0;
min-height: 0;
background-color: var(--mantine-color-secondary-filled);
}
.MoleculeEditLineNumber {
height: 100%;
padding-top: calc(1px + var(--input-padding-y, 0rem));
padding-bottom: calc(1px + var(--input-padding-y, 0rem));
font-family:
"Fira Code", "Courier New", Courier, monospace; /* Monospace fonts */
font-size: 14px; /* Comfortable size */
line-height: 1.5; /* Spacing like an editor */
letter-spacing: 0; /* Keeps punctuation aligned */
white-space: pre-line;
overflow: scroll;
scrollbar-width: none;
overscroll-behavior: none;
cursor: default;
}
.MoleculeEditInput {
font-family:
"Fira Code", "Courier New", Courier, monospace; /* Monospace fonts */
font-size: 14px; /* Comfortable size */
line-height: 1.5; /* Spacing like an editor */
letter-spacing: 0; /* Keeps punctuation aligned */
white-space: pre; /* preserves spaces & tabs */
overflow-x: auto; /* horizontal scroll when needed */
overflow-y: auto; /* vertical scroll when needed */
word-wrap: normal; /* prevent wrapping */
overscroll-behavior: none;
background-color: var(--mantine-color-secondary-filled);
border-radius: 0px !important;
}
.Separator {
width: 10px;
}
.moleculeInput {
height: 250px;
}
.moleculeInputWrapper {
height: 100%;
}
.moleculeInputWrapper > * {
height: 100%;
}
.tabPanel {
flex: 1 !important;
padding: 10px;
border-left: 1px solid var(--tab-border-color);
border-right: 1px solid var(--tab-border-color);
border-bottom: 1px solid var(--tab-border-color);
}

View File

@@ -0,0 +1,22 @@
.moleculeViewer {
height: 100%;
width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: auto;
position: relative;
overscroll-behavior: contain;
touch-action: none;
}
.atomEditor {
position: absolute;
border-radius: md;
right: 10px;
top: 10px;
}
.atomEditor * {
width: 100%;
height: auto;
}

View File

@@ -0,0 +1,269 @@
import Viewer from "miew-react";
import Miew from "miew";
import {
Paper,
UnstyledButton,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
import "./MoleculeViewer.css";
import { notifications } from "@mantine/notifications";
import {
IconAlertHexagon,
IconArrowBarBoth,
IconRefresh,
} from "@tabler/icons-react";
import MoleculeViewerMenu from "./MoleculeViewerMenu";
import { SelectedAtom } from "../../Types/plugin";
const callbackOnResizeFinish = (
dom_elem: HTMLElement,
beginning_callback: () => void,
end_callback: () => void,
delay = 100, // ms after resize "finishes"
) => {
let timeoutId: number | undefined;
const resizeObserver = new ResizeObserver(() => {
beginning_callback();
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
end_callback();
}, delay);
});
resizeObserver.observe(dom_elem);
return () => resizeObserver.disconnect(); // cleanup helper
};
interface MoleculeViewerProps {
moleculeData: string;
selectedAtom: SelectedAtom | null;
setSelectedAtom: (selectedAtom: SelectedAtom | null) => void;
}
function MoleculeViewer(props: MoleculeViewerProps) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
// объект загруженного редактора молекул для редактирования
const [miew, setMiew] = useState<Miew | null>(null);
const molViewerRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState<boolean>(false);
const [viewingData, setViewingData] = useState<string>(props.moleculeData);
//при загрузке сохраняем объект miew
const onInitMiew = (miew: Miew) => {
setMiew(miew);
if (miew && viewingData) {
//прогружаем молекулу
miew
.load(viewingData, {
sourceType: "immediate",
fileType: "xyz",
})
.then(() => {
if (props.selectedAtom) {
props.setSelectedAtom(null);
}
})
.catch((error) => {
if (error.message == "Operation cancelled") {
return;
}
notifications.show({
color: "orange",
radius: "md",
title: "Ошибка при отображении молекулы",
message:
"Проверте правильность написания кода молекулы, в нем содержатся ошибки",
icon: <IconAlertHexagon />,
style: { paddingLeft: "5px" },
});
});
}
miew.setOptions({
settings: {
bg: {
color: theme.colors.secondaryDark[7],
},
fogAlpha: 0.7,
axes: true,
},
});
};
//мемоизируем объект чтобы он не перегружаля при изменении [miew] состояния
const MemoViewer = useMemo(
() => <Viewer onInit={onInitMiew} />,
[viewingData],
);
//Замена станартного обработчика нажатия на молекулы
useEffect(() => {
if (molViewerRef.current && miew) {
//INFO: при изменении размера окна обносить webgl
const cleanup = callbackOnResizeFinish(
molViewerRef.current,
handleResizeStart,
handleResizeEnd,
);
//@ts-expect-error Miew class not implementing typescript correctly
miew.removeEventListener("newpick");
//@ts-expect-error Miew class not implementing typescript correctly
miew.addEventListener("newpick", handleClick);
return () => {
cleanup();
//@ts-expect-error Miew class not implementing typescript correctly
miew.removeEventListener("newpick");
};
}
}, [molViewerRef, miew]);
//On selected Atom change, highlight it
useEffect(() => {
if (miew) {
if (!props.selectedAtom) {
//@ts-expect-error Miew class not implementing typescript correctly
miew.select("");
return;
}
//@ts-expect-error Miew class not implementing typescript correctly
miew.select("serial " + props.selectedAtom.serial, false);
//спрятать информацию встроенную
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.opacity = 0.0;
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.height = "0px";
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.overflow = "hidden";
}
}, [props.selectedAtom, miew]);
//при смене темы обновляем фон MoleculeViewer
useEffect(() => {
if (miew) {
miew.setOptions({
settings: {
bg: {
color: theme.colors.secondaryDark[7],
},
},
});
}
}, [colorScheme]);
//при нажятии на атом выбираем его
const handleClick = (pick: { type: string; obj: { atom: SelectedAtom } }) => {
if (miew) {
if (pick.obj.atom) {
props.setSelectedAtom(pick.obj.atom);
} else {
props.setSelectedAtom(null);
}
}
};
const handleResizeStart = () => {
setIsResizing(true);
};
const handleResizeEnd = () => {
//@ts-expect-error Miew class not implementing typescript correctly
miew._onResize();
//@ts-expect-error Miew class not implementing typescript correctly
miew._picker.handleResize();
setTimeout(() => {
setIsResizing(false);
}, 50);
};
const handleMouseEnter = () => {
document.body.style.overflow = "hidden"; // disable page scroll
};
const handleMouseLeave = () => {
document.body.style.overflow = "auto"; // re-enable scroll
};
const refreshDisplay = () => {
if (props.moleculeData != viewingData) {
setViewingData(props.moleculeData);
} else {
if (miew) {
//@ts-expect-error Miew class not implementing typescript correctly
miew.resetView();
}
}
};
return (
<div ref={molViewerRef} className="moleculeViewer">
<div
style={{
width: "100%",
height: "100%",
backgroundColor: theme.colors.secondaryDark[7],
borderRadius: "5px",
overflow: "hidden",
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{MemoViewer}
{isResizing && viewingData && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.secondaryDark[7], // optional dim
opacity: 0.5,
zIndex: 10,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<IconArrowBarBoth size={80} />
</div>
</div>
)}
</div>
{props.selectedAtom && (
<Paper className="atomEditor" radius="md" p="md">
<MoleculeViewerMenu selectedAtom={props.selectedAtom} miew={miew} />
</Paper>
)}
<div
style={{
position: "absolute",
left: "10px",
top: "10px",
display: "flex",
zIndex: 5,
width: "auto",
}}
>
<UnstyledButton onClick={refreshDisplay}>
<IconRefresh color="white" size={25} />
</UnstyledButton>
</div>
</div>
);
}
export default MoleculeViewer;

View File

@@ -0,0 +1,29 @@
import { Text, Divider } from "@mantine/core";
import Miew from "miew";
import "./MoleculeViewer.css";
import type { SelectedAtom } from "Types/Experiment/MoleculeEdit/MoleculeEdit";
interface MoleculeViewerMenuProps {
selectedAtom: SelectedAtom;
miew: Miew | null;
}
function MoleculeViewerMenu(props: MoleculeViewerMenuProps) {
return (
<>
<div className="moleculeViewerMenu">
<>
<Text ta="center">
Атом: {props.selectedAtom.name} ({props.selectedAtom.serial})
</Text>
<Divider my="sm" />
<Text ta="center">Положение X: {props.selectedAtom.position.x}</Text>
<Text ta="center">Положение Y: {props.selectedAtom.position.y}</Text>
<Text ta="center">Положение Z: {props.selectedAtom.position.z}</Text>
</>
</div>
</>
);
}
export default MoleculeViewerMenu;

View File

@@ -0,0 +1,23 @@
// ListItem/ListItem.tsx
import React from "react";
import { MoleculeData, TaskData } from "../Types/plugin";
const ListItem: React.FunctionComponent<{
data: TaskData<MoleculeData> | undefined;
}> = (props) => {
return (
<div
style={{ padding: "1rem", border: "1px solid #ccc", borderRadius: "4px" }}
>
<h3 style={{ margin: "0 0 0.5rem 0" }}>{props.data?.name}</h3>
<p style={{ margin: "0 0 0.5rem 0", color: "#666" }}>
{props.data?.description}
</p>
<div style={{ fontSize: "0.9rem", color: "#999" }}>
Value: {props.data?.data.text || "Not set"}
</div>
</div>
);
};
export default ListItem;

View File

@@ -0,0 +1,28 @@
// Types
export interface TaskData<TData = any> {
id: number;
name: string;
description: string;
data: TData;
}
export interface TaskEditorProps<TData = any> {
data: TaskData<TData> | undefined;
setData: (data: TData) => void;
}
export interface TaskTypePlugin<TData = any> {
type: string;
ListItem: React.ComponentType<TaskData<TData> | undefined>;
Editor: React.ComponentType<TaskEditorProps<TData>>;
}
export interface MoleculeData {
text: string;
}
export interface SelectedAtom {
serial: number;
name: string;
position: { x: number; y: number; z: number };
}

View File

@@ -0,0 +1,29 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
@font-face {
font-family: "Quicking"; /* Define a name for your font */
src: url("/Quicking.otf") format("opentype"); /* Specify the font file and format */
font-weight: normal; /* Optional: Define the weight of this font variant */
font-style: normal; /* Optional: Define the style of this font variant */
}
:root {
--mantine-color-body: var(--mantine-color-primary-filled) !important;
}

View File

@@ -0,0 +1,22 @@
// src/main.tsx (entry point for iframe, not index.tsx)
import ReactDOM from "react-dom/client";
import "./index.css";
import "@mantine/core/styles.css";
import Wrapper from "./wrapper";
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode") == "List" ? "List" : "Editor";
// Render directly
const rootElement = document.getElementById("root");
if (rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(<Wrapper mode={mode} />);
}
// Notify parent that plugin is ready
if (window.parent !== window) {
window.parent.postMessage({ type: "plugin-ready" }, "*");
}

View File

@@ -0,0 +1,109 @@
// src/main.tsx (entry point for iframe, not index.tsx)
import React, { useEffect, useState } from "react";
import ListItem from "./ListItem/ListItem";
import MoleculeEditorPage from "./EditorPage/EditorPage";
import {
colorsTuple,
createTheme,
localStorageColorSchemeManager,
MantineProvider,
virtualColor,
} from "@mantine/core";
import "./index.css";
import "@mantine/core/styles.css";
import { MoleculeData, TaskData } from "./Types/plugin";
interface WrapperProps {
mode: "List" | "Editor";
}
// Create setData function that communicates with parent
const setData = (newData: MoleculeData) => {
window.parent.postMessage(
{
type: "plugin-update",
data: newData,
},
"*",
);
};
const colorSchemeManager = localStorageColorSchemeManager({
key: "my-app-color-scheme",
});
const theme = createTheme({
colors: {
primaryDark: colorsTuple("#212529"),
primaryLight: colorsTuple("#f1f3f5"),
secondaryDark: colorsTuple("#484d53"),
secondaryLight: colorsTuple("#b9bec4"),
contrastDark: colorsTuple("#f1f3f5"),
contrastLight: colorsTuple("#212529"),
secondaryContrastDark: colorsTuple("#b9bec4"),
secondaryContrastLight: colorsTuple("#484d53"),
primary: virtualColor({
name: "primary",
dark: "primaryDark",
light: "primaryLight",
}),
secondary: virtualColor({
name: "secondary",
dark: "secondaryDark",
light: "secondaryLight",
}),
contrast: virtualColor({
name: "contrast",
dark: "contrastDark",
light: "contrastLight",
}),
secondaryContrast: virtualColor({
name: "secondaryContrast",
dark: "secondaryContrastDark",
light: "secondaryContrastLight",
}),
accent: virtualColor({
name: "accent",
dark: "blue",
light: "blue",
}),
},
});
const Wrapper: React.FunctionComponent<WrapperProps> = (props) => {
const [currentTaskData, setTaskData] = useState<TaskData<MoleculeData>>();
const [currentTheme, setTheme] = useState<"light" | "dark">("dark");
// Handle messages from parent
useEffect(() => {
window.addEventListener("message", (event) => {
const { type, data } = event.data;
console.log(event.data);
setTaskData(data.taskData);
setTheme(data.theme);
});
}, [props.mode]);
if (props.mode === "List") {
return (
<MantineProvider
colorSchemeManager={colorSchemeManager}
theme={theme}
forceColorScheme={currentTheme}
>
<ListItem data={currentTaskData} />
</MantineProvider>
);
} else {
return (
<MantineProvider
colorSchemeManager={colorSchemeManager}
theme={theme}
forceColorScheme={currentTheme}
>
<MoleculeEditorPage data={currentTaskData} setData={setData} />
</MantineProvider>
);
}
};
export default Wrapper;

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"declaration": false,
"outDir": "./dist",
"sourceMap": false,
"noEmit": true,
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "./tsconfig.node.json" }],
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
},
"include": ["vite.config.ts"],
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
outDir: "dist",
emptyOutDir: true,
minify: "esbuild",
sourcemap: false,
assetsInlineLimit: 100000000, // Inline all assets
rollupOptions: {
input: "./index.html",
},
},
});