diff --git a/.gitignore b/.gitignore index e102be8..a8baa10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +node_modules +package-lock.json # postman .postman postman diff --git a/Dockerfile b/backend/Dockerfile similarity index 100% rename from Dockerfile rename to backend/Dockerfile diff --git a/docker-compose.yaml b/backend/docker-compose.yaml similarity index 100% rename from docker-compose.yaml rename to backend/docker-compose.yaml diff --git a/requirements.txt b/backend/requirements.txt similarity index 100% rename from requirements.txt rename to backend/requirements.txt diff --git a/src/app.py b/backend/src/app.py similarity index 100% rename from src/app.py rename to backend/src/app.py diff --git a/src/logging_config.py b/backend/src/logging_config.py similarity index 100% rename from src/logging_config.py rename to backend/src/logging_config.py diff --git a/src/request_response_models.py b/backend/src/request_response_models.py similarity index 100% rename from src/request_response_models.py rename to backend/src/request_response_models.py diff --git a/frontend-plugin/index.html b/frontend-plugin/index.html new file mode 100644 index 0000000..360e96f --- /dev/null +++ b/frontend-plugin/index.html @@ -0,0 +1,26 @@ + + + + + + + + + +
+ + + diff --git a/frontend-plugin/package.json b/frontend-plugin/package.json new file mode 100644 index 0000000..e7cd775 --- /dev/null +++ b/frontend-plugin/package.json @@ -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" + } +} diff --git a/frontend-plugin/src/EditorPage/CodeTextArea/CodeTextArea.tsx b/frontend-plugin/src/EditorPage/CodeTextArea/CodeTextArea.tsx new file mode 100644 index 0000000..7688d2d --- /dev/null +++ b/frontend-plugin/src/EditorPage/CodeTextArea/CodeTextArea.tsx @@ -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 ( + + ); +} diff --git a/frontend-plugin/src/EditorPage/EditorPage.tsx b/frontend-plugin/src/EditorPage/EditorPage.tsx new file mode 100644 index 0000000..b4e21fe --- /dev/null +++ b/frontend-plugin/src/EditorPage/EditorPage.tsx @@ -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) { + const [text, setText] = useState(props.data?.data.text); + const [selectedAtom, setSelectedAtom] = useState(null); + const theme = useMantineTheme(); + + return ( + <> +
+ + + + + + }> + Код + + }> + Список + + + + + { + setText(text); + props.setData({ text: text }); + }} + /> + + + + <> + + + + +
+ +
+
+ + + +
+
+ + ); +} + +export default MoleculeEditorPage; diff --git a/frontend-plugin/src/EditorPage/MoleculePage.css b/frontend-plugin/src/EditorPage/MoleculePage.css new file mode 100644 index 0000000..c15dc4f --- /dev/null +++ b/frontend-plugin/src/EditorPage/MoleculePage.css @@ -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); +} diff --git a/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.css b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.css new file mode 100644 index 0000000..638a1a8 --- /dev/null +++ b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.css @@ -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; +} diff --git a/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.tsx b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.tsx new file mode 100644 index 0000000..c777370 --- /dev/null +++ b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewer.tsx @@ -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(null); + const molViewerRef = useRef(null); + + const [isResizing, setIsResizing] = useState(false); + + const [viewingData, setViewingData] = useState(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: , + style: { paddingLeft: "5px" }, + }); + }); + } + miew.setOptions({ + settings: { + bg: { + color: theme.colors.secondaryDark[7], + }, + fogAlpha: 0.7, + axes: true, + }, + }); + }; + + //мемоизируем объект чтобы он не перегружаля при изменении [miew] состояния + const MemoViewer = useMemo( + () => , + [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 ( +
+
+ {MemoViewer} + {isResizing && viewingData && ( +
+
+ +
+
+ )} +
+ {props.selectedAtom && ( + + + + )} + +
+ + + +
+
+ ); +} + +export default MoleculeViewer; diff --git a/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewerMenu.tsx b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewerMenu.tsx new file mode 100644 index 0000000..a493d47 --- /dev/null +++ b/frontend-plugin/src/EditorPage/MoleculeViewer/MoleculeViewerMenu.tsx @@ -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 ( + <> +
+ <> + + Атом: {props.selectedAtom.name} ({props.selectedAtom.serial}) + + + Положение X: {props.selectedAtom.position.x} + Положение Y: {props.selectedAtom.position.y} + Положение Z: {props.selectedAtom.position.z} + +
+ + ); +} + +export default MoleculeViewerMenu; diff --git a/frontend-plugin/src/ListItem/ListItem.tsx b/frontend-plugin/src/ListItem/ListItem.tsx new file mode 100644 index 0000000..b3b2fa5 --- /dev/null +++ b/frontend-plugin/src/ListItem/ListItem.tsx @@ -0,0 +1,23 @@ +// ListItem/ListItem.tsx +import React from "react"; +import { MoleculeData, TaskData } from "../Types/plugin"; + +const ListItem: React.FunctionComponent<{ + data: TaskData | undefined; +}> = (props) => { + return ( +
+

{props.data?.name}

+

+ {props.data?.description} +

+
+ Value: {props.data?.data.text || "Not set"} +
+
+ ); +}; + +export default ListItem; diff --git a/frontend-plugin/src/Types/plugin.tsx b/frontend-plugin/src/Types/plugin.tsx new file mode 100644 index 0000000..ed802a5 --- /dev/null +++ b/frontend-plugin/src/Types/plugin.tsx @@ -0,0 +1,28 @@ +// Types +export interface TaskData { + id: number; + name: string; + description: string; + data: TData; +} + +export interface TaskEditorProps { + data: TaskData | undefined; + setData: (data: TData) => void; +} + +export interface TaskTypePlugin { + type: string; + ListItem: React.ComponentType | undefined>; + Editor: React.ComponentType>; +} + +export interface MoleculeData { + text: string; +} + +export interface SelectedAtom { + serial: number; + name: string; + position: { x: number; y: number; z: number }; +} diff --git a/frontend-plugin/src/index.css b/frontend-plugin/src/index.css new file mode 100644 index 0000000..6d03b98 --- /dev/null +++ b/frontend-plugin/src/index.css @@ -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; +} diff --git a/frontend-plugin/src/index.tsx b/frontend-plugin/src/index.tsx new file mode 100644 index 0000000..01b24b8 --- /dev/null +++ b/frontend-plugin/src/index.tsx @@ -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(); +} + +// Notify parent that plugin is ready +if (window.parent !== window) { + window.parent.postMessage({ type: "plugin-ready" }, "*"); +} diff --git a/frontend-plugin/src/wrapper.tsx b/frontend-plugin/src/wrapper.tsx new file mode 100644 index 0000000..07454b3 --- /dev/null +++ b/frontend-plugin/src/wrapper.tsx @@ -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 = (props) => { + const [currentTaskData, setTaskData] = useState>(); + 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 ( + + + + ); + } else { + return ( + + + + ); + } +}; + +export default Wrapper; diff --git a/frontend-plugin/tsconfig.json b/frontend-plugin/tsconfig.json new file mode 100644 index 0000000..31b2acf --- /dev/null +++ b/frontend-plugin/tsconfig.json @@ -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" }], +} diff --git a/frontend-plugin/tsconfig.node.json b/frontend-plugin/tsconfig.node.json new file mode 100644 index 0000000..ef7bfa3 --- /dev/null +++ b/frontend-plugin/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + }, + "include": ["vite.config.ts"], +} diff --git a/frontend-plugin/vite.config.ts b/frontend-plugin/vite.config.ts new file mode 100644 index 0000000..ec1dd55 --- /dev/null +++ b/frontend-plugin/vite.config.ts @@ -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", + }, + }, +});