refactor cytoscape component and hooks

This commit is contained in:
nflnkr 2024-01-05 19:48:35 +03:00
parent c38c75af9a
commit b8b5bc5d2e
11 changed files with 963 additions and 1118 deletions

@ -1,301 +1,232 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import Cytoscape from "cytoscape";
import CytoscapeComponent from "react-cytoscapejs";
import popper from "cytoscape-popper";
import { Button, Box } from "@mui/material";
import { withErrorBoundary } from "react-error-boundary";
import { enqueueSnackbar } from "notistack";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootContentId } from "@root/quizes/actions";
import { devlog } from "@frontend/kitui";
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { useQuestionsStore } from "@root/questions/store";
import { useUiTools } from "@root/uiTools/store";
import { Box, Button } from "@mui/material";
import {
deleteQuestion,
updateQuestion,
getQuestionByContentId,
clearRuleForAll,
createResult,
clearRuleForAll
} from "@root/questions/actions";
import {
updateModalInfoWhyCantCreate,
updateOpenedModalSettingsId
} from "@root/uiTools/actions";
import { cleardragQuestionContentId } from "@root/uiTools/actions";
import { updateDeleteId } from "@root/uiTools/actions";
import { DeleteNodeModal } from "../DeleteNodeModal";
import { useQuestionsStore } from "@root/questions/store";
import { updateRootContentId } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { cleardragQuestionContentId, setModalQuestionParentContentId, setModalQuestionTargetContentId, updateModalInfoWhyCantCreate, updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store";
import { ProblemIcon } from "@ui_kit/ProblemIcon";
import { useRemoveNode } from "./hooks/useRemoveNode";
import type { Core, PresetLayoutOptions, SingularData } from "cytoscape";
import Cytoscape from "cytoscape";
import popper, { getPopperInstance } from "cytoscape-popper";
import { enqueueSnackbar } from "notistack";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import CytoscapeComponent from "react-cytoscapejs";
import { withErrorBoundary } from "react-error-boundary";
import { DeleteNodeModal } from "../DeleteNodeModal";
import { addNode, calcNodePosition, storeToNodes } from "./helper";
import { usePopper } from "./hooks/usePopper";
import { storeToNodes } from "./helper";
import { stylesheet } from "./style/stylesheet";
import { useRemoveNode } from "./hooks/useRemoveNode";
import "./style/styles.css";
import { stylesheet } from "./style/stylesheet";
import type { Core } from "cytoscape";
Cytoscape.use(popper);
Cytoscape.use(popper);
type PopperInstance = ReturnType<getPopperInstance<SingularData>>;
interface CsComponentProps {
modalQuestionParentContentId: string;
modalQuestionTargetContentId: string;
setOpenedModalQuestions: (open: boolean) => void;
setModalQuestionParentContentId: (id: string) => void;
setModalQuestionTargetContentId: (id: string) => void;
function CsComponent() {
const quiz = useCurrentQuiz();
const desireToOpenABranchingModal = useUiTools(state => state.desireToOpenABranchingModal);
const canCreatePublic = useUiTools(state => state.canCreatePublic);
const modalQuestionParentContentId = useUiTools(state => state.modalQuestionParentContentId);
const modalQuestionTargetContentId = useUiTools(state => state.modalQuestionTargetContentId);
const trashQuestions = useQuestionsStore(state => state.questions);
const questions = trashQuestions.filter((question) => question.type !== "result" && question.type !== null);
const [isPanningCy, setIsPanningCy] = useState<boolean>(false);
const cyRef = useRef<Core | null>(null);
const popperContainerRef = useRef<HTMLDivElement | null>(null);
const popperInstancesRef = useRef<PopperInstance[]>([]);
const { createPoppers, removeAllPoppers, removePoppersById } = usePopper({
cyRef,
quizId: quiz?.backendId,
runCyLayout,
popperContainerRef,
popperInstancesRef,
});
function runCyLayout() {
cyRef.current?.layout(layoutOptions).run();
createPoppers();
};
const { removeNode } = useRemoveNode({
cyRef,
runCyLayout,
removeButtons: removePoppersById,
});
useLayoutEffect(() => {
const cy = cyRef?.current;
if (desireToOpenABranchingModal) {
setTimeout(() => {
cy?.getElementById(desireToOpenABranchingModal)?.data("eroticeyeblink", true);
}, 250);
} else {
cy?.elements().data("eroticeyeblink", false);
}
}, [desireToOpenABranchingModal]);
useEffect(() => {
if (modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0) {
if (!cyRef.current || !quiz) return;
const es = addNode({
cy: cyRef.current,
quizId: quiz.backendId,
parentNodeContentId: modalQuestionParentContentId,
targetNodeContentId: modalQuestionTargetContentId,
});
runCyLayout();
if (es) cyRef.current.fit(es, 100);
}
setModalQuestionParentContentId("");
setModalQuestionTargetContentId("");
}, [modalQuestionTargetContentId, quiz?.backendId]);
useEffect(function onMount() {
updateOpenedModalSettingsId();
document.querySelector("#root")?.addEventListener("mouseup", cleardragQuestionContentId);
const cy = cyRef.current;
if (!cy) return;
cy.add(
storeToNodes(
questions.filter((question) => question.type && question.type !== "result") as AnyTypedQuizQuestion[],
),
);
runCyLayout();
cy.fit();
return () => {
document.querySelector("#root")?.removeEventListener("mouseup", cleardragQuestionContentId);
removeAllPoppers();
};
}, []);
useEffect(function attachDragHandlers() {
const cy = cyRef.current;
if (!cy) return;
let isPointerDown = false;
const onPointerDown = () => {
isPointerDown = true;
cy.data("dragging", true);
};
const onPointerUp = () => {
isPointerDown = false;
cy.data("dragging", false);
setIsPanningCy(false);
};
const handleMove = () => {
setIsPanningCy(isPointerDown);
};
cy.on("vmousedown", onPointerDown);
cy.on("vmousemove", handleMove);
document.addEventListener("pointerup", onPointerUp);
return () => {
cy.off("vmousedown", onPointerDown);
cy.off("vmousemove", handleMove);
document.removeEventListener("pointerup", onPointerUp);
};
}, []);
useEffect(function poppersLifecycle() {
if (isPanningCy) {
removeAllPoppers();
} else {
createPoppers();
}
}, [isPanningCy]);
return (
<>
<Box mb="20px">
<Button
sx={{
height: "27px",
color: "#7E2AEA",
textDecoration: "underline",
fontSize: "16px",
}}
variant="text"
onClick={() => {
cyRef.current?.fit();
}}
>
Выровнять
</Button>
<ProblemIcon
blink={!canCreatePublic}
onClick={() => updateModalInfoWhyCantCreate(true)}
/>
</Box>
<CytoscapeComponent
wheelSensitivity={0.1}
elements={[]}
// elements={createGraphElements(tree, quiz)}
style={{
height: "480px",
background: "#F2F3F7",
overflow: "hidden",
}}
stylesheet={stylesheet}
layout={layoutOptions}
cy={(cy) => {
cyRef.current = cy;
}}
autoungrabify={true}
/>
<DeleteNodeModal removeNode={removeNode} />
</>
);
}
function CsComponent({
modalQuestionParentContentId,
modalQuestionTargetContentId,
setOpenedModalQuestions,
setModalQuestionParentContentId,
setModalQuestionTargetContentId
}: CsComponentProps) {
const quiz = useCurrentQuiz();
const { dragQuestionContentId, desireToOpenABranchingModal, canCreatePublic } = useUiTools()
const trashQuestions = useQuestionsStore().questions
const questions = trashQuestions.filter((question) => question.type !== "result" && question.type !== null)
const [startCreate, setStartCreate] = useState("");
const [startRemove, setStartRemove] = useState("");
const cyRef = useRef<Core | null>(null);
const layoutsContainer = useRef<HTMLDivElement | null>(null);
const plusesContainer = useRef<HTMLDivElement | null>(null);
const crossesContainer = useRef<HTMLDivElement | null>(null);
const gearsContainer = useRef<HTMLDivElement | null>(null);
const { layoutOptions } = usePopper({
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
setModalQuestionParentContentId,
setOpenedModalQuestions,
setStartCreate,
setStartRemove,
});
const { removeNode } = useRemoveNode({
cyRef,
layoutOptions,
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
});
useEffect(() => {
return () => {
// if (!canCreatePublic) updateModalInfoWhyCantCreate(true)
}
}, []);
useLayoutEffect(() => {
const cy = cyRef?.current
if (desireToOpenABranchingModal) {
setTimeout(() => {
cy?.getElementById(desireToOpenABranchingModal)?.data("eroticeyeblink", true)
}, 250)
} else {
cy?.elements().data("eroticeyeblink", false)
}
}, [desireToOpenABranchingModal])
useLayoutEffect(() => {
updateOpenedModalSettingsId()
// updateRootContentId(quiz.id, "")
// clearRuleForAll()
}, [])
useEffect(() => {
if (modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0) {
addNode({ parentNodeContentId: modalQuestionParentContentId, targetNodeContentId: modalQuestionTargetContentId })
}
setModalQuestionParentContentId("")
setModalQuestionTargetContentId("")
}, [modalQuestionTargetContentId])
const addNode = ({ parentNodeContentId, targetNodeContentId }: { parentNodeContentId: string, targetNodeContentId?: string }) => {
if (quiz) {
//запрещаем работу родителя-ребенка если это один и тот же вопрос
if (parentNodeContentId === targetNodeContentId) return
const cy = cyRef?.current
const parentNodeChildren = cy?.$('edge[source = "' + parentNodeContentId + '"]')?.length
//если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа
const targetQuestion = { ...getQuestionByContentId(targetNodeContentId || dragQuestionContentId) } as AnyTypedQuizQuestion
if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren })
cy?.data('changed', true)
createResult(quiz.backendId, targetQuestion.content.id)
const es = cy?.add([
{
data: {
id: targetQuestion.content.id,
label: targetQuestion.title === "" || targetQuestion.title === " " ? "noname" : targetQuestion.title
}
},
{
data: {
source: parentNodeContentId,
target: targetQuestion.content.id
}
}
])
cy?.layout(layoutOptions).run()
cy?.center(es)
} else {
enqueueSnackbar("Добавляемый вопрос не найден")
}
} else {
enqueueSnackbar("Квиз не найден")
}
}
const clearDataAfterAddNode = ({ parentNodeContentId, targetQuestion, parentNodeChildren }: { parentNodeContentId: string, targetQuestion: AnyTypedQuizQuestion, parentNodeChildren: number }) => {
const parentQuestion = { ...getQuestionByContentId(parentNodeContentId) } as AnyTypedQuizQuestion
//смотрим не добавлен ли родителю result. Если да - делаем его неактивным. Веточкам result не нужен
trashQuestions.forEach((targetQuestion) => {
if (targetQuestion.type === "result" && targetQuestion.content.rule.parentId === parentQuestion.content.id) {
updateQuestion(targetQuestion.id, (q) => q.content.usage = false);
}
})
//предупреждаем добавленный вопрос о том, кто его родитель
updateQuestion(targetQuestion.content.id, question => {
question.content.rule.parentId = parentNodeContentId
question.content.rule.main = []
//Это листик. Сбросим ему на всякий случай не листиковые поля
question.content.rule.children = []
question.content.rule.default = ""
})
const noChild = parentQuestion.content.rule.children.length === 0
//предупреждаем родителя о новом потомке (если он ещё не знает о нём)
if (!parentQuestion.content.rule.children.includes(targetQuestion.content.id)) updateQuestion(parentNodeContentId, question => {
question.content.rule.children = [...question.content.rule.children, targetQuestion.content.id]
//единственному ребёнку даём дефолт по-умолчанию
question.content.rule.default = noChild ? targetQuestion.content.id : question.content.rule.default
})
if (!noChild) {//детей больше 1
//- предупреждаем стор вопросов об открытии модалки ветвления
updateOpenedModalSettingsId(targetQuestion.content.id)
}
}
useEffect(() => {
if (startCreate) {
addNode({ parentNodeContentId: startCreate });
cleardragQuestionContentId();
setStartCreate("");
}
}, [startCreate]);
useEffect(() => {
if (startRemove) {
updateDeleteId(startRemove);
setStartRemove("");
}
}, [startRemove]);
useEffect(() => {
document
.querySelector("#root")
?.addEventListener("mouseup", cleardragQuestionContentId);
const cy = cyRef.current;
const eles = cy?.add(
storeToNodes(
questions.filter(
(question) => question.type && question.type !== "result"
) as AnyTypedQuizQuestion[]
)
);
cy?.data("changed", true);
// cy.data('changed', true)
const elecs = eles?.layout(layoutOptions).run();
cy?.on("add", () => cy.data("changed", true));
cy?.fit();
//cy?.layout().run()
return () => {
document
.querySelector("#root")
?.removeEventListener("mouseup", cleardragQuestionContentId);
layoutsContainer.current?.remove();
plusesContainer.current?.remove();
crossesContainer.current?.remove();
gearsContainer.current?.remove();
};
}, []);
return (
<>
<Box
mb="20px">
<Button
sx={{
height: "27px",
color: "#7E2AEA",
textDecoration: "underline",
fontSize: "16px",
}}
variant="text"
onClick={() => {
cyRef.current?.fit()
}}
>
Выровнять
</Button>
<ProblemIcon blink={!canCreatePublic} onClick={() => updateModalInfoWhyCantCreate(true)} />
</Box>
<CytoscapeComponent
wheelSensitivity={0.1}
elements={[]}
// elements={createGraphElements(tree, quiz)}
style={{ height: "480px", background: "#F2F3F7" }}
stylesheet={stylesheet}
layout={(layoutOptions)}
cy={(cy) => {
cyRef.current = cy;
}}
autoungrabify={true}
/>
<DeleteNodeModal removeNode={removeNode} />
</>
);
};
function Clear() {
const quiz = useCurrentQuiz();
if (quiz) {
updateRootContentId(quiz?.id, "");
}
clearRuleForAll()
return <></>
const quiz = useCurrentQuiz();
if (quiz) {
updateRootContentId(quiz?.id, "");
}
clearRuleForAll();
return <></>;
}
export default withErrorBoundary(CsComponent, {
fallback: <Clear />,
onError: (error, info) => {
enqueueSnackbar("Дерево порвалось")
console.log(info)
console.log(error)
},
fallback: <Clear />,
onError: (error, info) => {
enqueueSnackbar("Дерево порвалось");
devlog(info);
devlog(error);
},
});
const layoutOptions: PresetLayoutOptions = {
name: "preset",
positions: calcNodePosition,
zoom: undefined,
pan: 1,
fit: false,
padding: 30,
animate: false,
animationDuration: 500,
animationEasing: undefined,
animateFilter: () => false,
ready: event => {
if (event.cy.data("firstNode") === "nonroot") {
event.cy.data("firstNode", "root");
event.cy.nodes().sort((a, b) => (a.data("root") ? 1 : -1));
} else {
event.cy.removeData("firstNode");
}
},
transform: (_, p) => p,
};

@ -1,69 +1,65 @@
import { Box } from "@mui/material"
import { useEffect, useRef, useLayoutEffect } from "react";
import { deleteQuestion, clearRuleForAll, updateQuestion, createResult } from "@root/questions/actions"
import { updateOpenedModalSettingsId } from "@root/uiTools/actions"
import { updateRootContentId } from "@root/quizes/actions"
import { useCurrentQuiz } from "@root/quizes/hooks"
import { useQuestionsStore } from "@root/questions/store"
import { enqueueSnackbar } from "notistack";
import { Box } from "@mui/material";
import { clearRuleForAll, createResult, updateQuestion } from "@root/questions/actions";
import { updateRootContentId } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { setOpenedModalQuestions, updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store";
import { enqueueSnackbar } from "notistack";
import { useEffect, useLayoutEffect, useRef } from "react";
interface Props {
setOpenedModalQuestions: (open: boolean) => void;
modalQuestionTargetContentId: string;
}
export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetContentId }: Props) => {
export const FirstNodeField = () => {
const quiz = useCurrentQuiz();
const modalQuestionTargetContentId = useUiTools(state => state.modalQuestionTargetContentId);
useLayoutEffect(() => {
updateOpenedModalSettingsId()
updateRootContentId(quiz.id, "")
clearRuleForAll()
}, [])
if (!quiz) return;
updateOpenedModalSettingsId();
updateRootContentId(quiz.id, "");
clearRuleForAll();
}, []);
const { questions } = useQuestionsStore()
const { dragQuestionContentId } = useUiTools()
const { dragQuestionContentId } = useUiTools();
const Container = useRef<HTMLDivElement | null>(null);
const modalOpen = () => setOpenedModalQuestions(true)
const modalOpen = () => setOpenedModalQuestions(true);
const newRootNode = () => {
if (quiz) {
if (dragQuestionContentId) {
updateRootContentId(quiz?.id, dragQuestionContentId)
updateQuestion(dragQuestionContentId, (question) => question.content.rule.parentId = "root")
createResult(quiz?.backendId, dragQuestionContentId)
updateRootContentId(quiz?.id, dragQuestionContentId);
updateQuestion(dragQuestionContentId, (question) => question.content.rule.parentId = "root");
createResult(quiz?.backendId, dragQuestionContentId);
}
} else {
enqueueSnackbar("Нет информации о взятом опроснике")
enqueueSnackbar("Нет информации о взятом опроснике");
}
}
};
useEffect(() => {
Container.current?.addEventListener("mouseup", newRootNode)
Container.current?.addEventListener("click", modalOpen)
return () => {
Container.current?.removeEventListener("mouseup", newRootNode)
Container.current?.removeEventListener("click", modalOpen)
}
}, [dragQuestionContentId])
Container.current?.addEventListener("mouseup", newRootNode);
Container.current?.addEventListener("click", modalOpen);
return () => {
Container.current?.removeEventListener("mouseup", newRootNode);
Container.current?.removeEventListener("click", modalOpen);
};
}, [dragQuestionContentId]);
useEffect(() => {
if (quiz) {
if (modalQuestionTargetContentId) {
updateRootContentId(quiz?.id, modalQuestionTargetContentId)
updateQuestion(modalQuestionTargetContentId, (question) => question.content.rule.parentId = "root")
createResult(quiz?.backendId, modalQuestionTargetContentId)
updateRootContentId(quiz?.id, modalQuestionTargetContentId);
updateQuestion(modalQuestionTargetContentId, (question) => question.content.rule.parentId = "root");
createResult(quiz?.backendId, modalQuestionTargetContentId);
}
} else {
enqueueSnackbar("Нет информации о взятом опроснике")
enqueueSnackbar("Нет информации о взятом опроснике");
}
}, [modalQuestionTargetContentId])
}, [modalQuestionTargetContentId]);
return (
@ -82,5 +78,5 @@ export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetCon
>
+
</Box>
)
}
);
};

@ -1,28 +1,39 @@
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared"
import { devlog } from "@frontend/kitui";
import { QuizQuestionResult } from "@model/questionTypes/result";
import { AnyTypedQuizQuestion, QuestionBranchingRule, QuestionBranchingRuleMain, UntypedQuizQuestion } from "@model/questionTypes/shared";
import { Quiz } from "@model/quiz/quiz";
import { createResult, getQuestionByContentId, updateQuestion } from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store";
import { Core } from "cytoscape";
import { enqueueSnackbar } from "notistack";
interface Nodes {
data: {
id: string;
label: string;
parent?: string;
}
};
}
interface Edges {
data: {
source: string;
target: string;
}
};
}
export const storeToNodes = (questions: AnyTypedQuizQuestion[]) => {
const nodes: Nodes[] = []
const edges: Edges[] = []
const nodes: Nodes[] = [];
const edges: Edges[] = [];
questions.forEach((question) => {
if (question.content.rule.parentId) {
nodes.push({data: {
id: question.content.id,
label: question.title === "" || question.title === " " ? "noname" : question.title
}})
nodes.push({
data: {
id: question.content.id,
label: question.title === "" || question.title === " " ? "noname" : question.title
}
});
// nodes.push({
// data: {
// id: "delete" + question.content.id,
@ -30,11 +41,249 @@ export const storeToNodes = (questions: AnyTypedQuizQuestion[]) => {
// parent: question.content.id,
// }
// },)
if (question.content.rule.parentId !== "root") edges.push({data: {
source: question.content.rule.parentId,
target: question.content.id
}})
if (question.content.rule.parentId !== "root") edges.push({
data: {
source: question.content.rule.parentId,
target: question.content.id
}
});
}
})
});
return [...nodes, ...edges];
};
export function clearDataAfterAddNode({
parentNodeContentId,
targetQuestion,
}: {
parentNodeContentId: string;
targetQuestion: AnyTypedQuizQuestion;
}) {
const parentQuestion = { ...getQuestionByContentId(parentNodeContentId) } as AnyTypedQuizQuestion;
//смотрим не добавлен ли родителю result. Если да - делаем его неактивным. Веточкам result не нужен
useQuestionsStore.getState().questions.filter(
(question): question is QuizQuestionResult => question.type === "result"
).forEach((targetQuestion) => {
if (
targetQuestion.content.rule.parentId === parentQuestion.content.id
) {
updateQuestion<QuizQuestionResult>(targetQuestion.id, (q) => (q.content.usage = false));
}
});
//предупреждаем добавленный вопрос о том, кто его родитель
updateQuestion(targetQuestion.content.id, (question) => {
question.content.rule.parentId = parentNodeContentId;
question.content.rule.main = [];
//Это листик. Сбросим ему на всякий случай не листиковые поля
question.content.rule.children = [];
question.content.rule.default = "";
});
const noChild = parentQuestion.content.rule.children.length === 0;
//предупреждаем родителя о новом потомке (если он ещё не знает о нём)
if (!parentQuestion.content.rule.children.includes(targetQuestion.content.id))
updateQuestion(parentNodeContentId, (question) => {
question.content.rule.children = [...question.content.rule.children, targetQuestion.content.id];
//единственному ребёнку даём дефолт по-умолчанию
question.content.rule.default = noChild ? targetQuestion.content.id : question.content.rule.default;
});
if (!noChild) {
//детей больше 1
//- предупреждаем стор вопросов об открытии модалки ветвления
updateOpenedModalSettingsId(targetQuestion.content.id);
}
};
export function clearDataAfterRemoveNode({
quiz,
trashQuestions,
targetQuestionContentId,
parentQuestionContentId,
}: {
quiz: Quiz | undefined;
trashQuestions: (AnyTypedQuizQuestion | UntypedQuizQuestion)[],
targetQuestionContentId: string;
parentQuestionContentId: string;
}) {
updateQuestion(targetQuestionContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.children = [];
question.content.rule.main = [];
question.content.rule.default = "";
});
//Ищём родителя
const parentQuestion = getQuestionByContentId(parentQuestionContentId);
//Делаем результат родителя активным
const parentResult = trashQuestions.find(
(q): q is QuizQuestionResult => q.type === "result" && q.content.rule.parentId === parentQuestionContentId,
);
if (parentResult) {
updateQuestion<QuizQuestionResult>(parentResult.content.id, (q) => {
q.content.usage = true;
});
} else {
createResult(quiz?.backendId, parentQuestionContentId);
}
//чистим rule родителя
if (!parentQuestion?.type) {
return;
}
const newChildren = [...parentQuestion.content.rule.children];
newChildren.splice(parentQuestion.content.rule.children.indexOf(targetQuestionContentId), 1);
const newRule: QuestionBranchingRule = {
children: newChildren,
default:
parentQuestion.content.rule.default === targetQuestionContentId
? ""
: parentQuestion.content.rule.default,
//удаляем условия перехода от родителя к этому вопросу,
main: parentQuestion.content.rule.main.filter(
(data: QuestionBranchingRuleMain) => data.next !== targetQuestionContentId,
),
parentId: parentQuestion.content.rule.parentId,
};
updateQuestion(parentQuestionContentId, (PQ) => {
PQ.content.rule = newRule;
});
};
export function calcNodePosition(node: any) {
const id = node.id();
const incomming = node.cy().edges(`[target="${id}"]`);
const layer = 0;
node.removeData("lastChild");
if (incomming.length === 0) {
if (node.cy().data("firstNode") === undefined) node.cy().data("firstNode", "root");
node.data("root", true);
const children = node.cy().edges(`[source="${id}"]`).targets();
node.data("layer", layer);
node.data("children", children.length);
const queue: any[] = [];
children.forEach((n: any) => {
queue.push({ task: n, layer: layer + 1 });
});
while (queue.length) {
const task = queue.pop();
task.task.data("layer", task.layer);
task.task.removeData("subtreeWidth");
const children = node.cy().edges(`[source="${task.task.id()}"]`).targets();
task.task.data("children", children.length);
if (children.length !== 0) {
children.forEach((n: any) => queue.push({ task: n, layer: task.layer + 1 }));
}
}
queue.push({ parent: node, children: children });
while (queue.length) {
const task = queue.pop();
if (task.children.length === 0) {
task.parent.data("subtreeWidth", task.parent.height() + 50);
continue;
}
const unprocessed = task?.children.filter((node: any) => {
return node.data("subtreeWidth") === undefined;
});
if (unprocessed.length !== 0) {
queue.push(task);
unprocessed.forEach((t: any) => {
queue.push({
parent: t,
children: t.cy().edges(`[source="${t.id()}"]`).targets(),
});
});
continue;
}
task?.parent.data(
"subtreeWidth",
task.children.reduce((p: any, n: any) => p + n.data("subtreeWidth"), 0),
);
}
const pos = { x: 0, y: 0 };
node.data("oldPos", pos);
queue.push({ task: children, parent: node });
while (queue.length) {
const task = queue.pop();
const oldPos = task.parent.data("oldPos");
let yoffset = oldPos.y - task.parent.data("subtreeWidth") / 2;
task.task.forEach((n: any) => {
const width = n.data("subtreeWidth");
n.data("oldPos", {
x: 250 * n.data("layer"),
y: yoffset + width / 2,
});
yoffset += width;
queue.push({
task: n.cy().edges(`[source="${n.id()}"]`).targets(),
parent: n,
});
});
}
return pos;
} else {
const opos = node.data("oldPos");
if (opos) {
return opos;
}
}
}
export const addNode = ({
cy,
quizId,
parentNodeContentId,
targetNodeContentId,
}: {
cy: Core;
quizId: number;
parentNodeContentId: string;
targetNodeContentId?: string;
}) => {
//запрещаем работу родителя-ребенка если это один и тот же вопрос
if (parentNodeContentId === targetNodeContentId) return;
devlog("@addNode");
const parentNodeChildren = cy.$('edge[source = "' + parentNodeContentId + '"]')?.length;
//если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа
const targetQuestion = {
...getQuestionByContentId(targetNodeContentId || useUiTools.getState().dragQuestionContentId),
} as AnyTypedQuizQuestion;
if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion });
createResult(quizId, targetQuestion.content.id);
const es = cy.add([
{
data: {
id: targetQuestion.content.id,
label:
targetQuestion.title === "" || targetQuestion.title === " "
? "noname"
: targetQuestion.title,
},
},
{
data: {
source: parentNodeContentId,
target: targetQuestion.content.id,
},
},
]);
return es;
} else {
enqueueSnackbar("Добавляемый вопрос не найден");
}
};

@ -1,479 +1,264 @@
import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import type { MutableRefObject } from "react";
import type {
PresetLayoutOptions,
LayoutEventObject,
NodeSingular,
AbstractEventObject,
} from "cytoscape";
type usePopperArgs = {
layoutsContainer: MutableRefObject<HTMLDivElement | null>;
plusesContainer: MutableRefObject<HTMLDivElement | null>;
crossesContainer: MutableRefObject<HTMLDivElement | null>;
gearsContainer: MutableRefObject<HTMLDivElement | null>;
setModalQuestionParentContentId: (id: string) => void;
setOpenedModalQuestions: (open: boolean) => void;
setStartCreate: (id: string) => void;
setStartRemove: (id: string) => void;
};
import { cleardragQuestionContentId, setModalQuestionParentContentId, setOpenedModalQuestions, updateDeleteId, updateOpenedModalSettingsId } from "@root/uiTools/actions";
import type { AbstractEventObject, Core, NodeSingular, SingularData } from "cytoscape";
import { getPopperInstance } from "cytoscape-popper";
import { type MutableRefObject } from "react";
import { addNode } from "../helper";
type PopperItem = {
id: () => string;
id: () => string;
};
type Modifier = {
name: string;
options: unknown;
name: string;
options: unknown;
};
type PopperConfig = {
popper: {
placement: string;
modifiers?: Modifier[];
};
content: (items: PopperItem[]) => void;
popper: {
placement: string;
modifiers?: Modifier[];
};
content: (items: PopperItem[]) => void;
};
type Popper = {
update: () => Promise<void>;
setOptions: (modifiers: { modifiers?: Modifier[] }) => void;
};
type PopperInstance = ReturnType<getPopperInstance<SingularData>>;
type NodeSingularWithPopper = NodeSingular & {
popper: (config: PopperConfig) => Popper;
popper: (config: PopperConfig) => PopperInstance;
};
export const usePopper = ({
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
setModalQuestionParentContentId,
setOpenedModalQuestions,
setStartCreate,
setStartRemove,
}: usePopperArgs) => {
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
cyRef,
quizId,
popperContainerRef,
popperInstancesRef,
runCyLayout,
}: {
cyRef: MutableRefObject<Core | null>;
quizId: number | undefined,
popperContainerRef: MutableRefObject<HTMLDivElement | null>;
popperInstancesRef: MutableRefObject<PopperInstance[]>;
runCyLayout: () => void;
}) => {
const removePoppersById = (id: string) => {
popperContainerRef.current?.querySelector(`.popper-layout[data-id='${id}']`)?.remove();
};
const initialPopperIcons = ({ cy }: LayoutEventObject) => {
const container =
(document.body.querySelector(
".__________cytoscape_container"
) as HTMLDivElement) || null;
const removeAllPoppers = () => {
cyRef.current?.removeListener("zoom render");
if (!container) {
return;
}
popperInstancesRef.current.forEach(p => p.destroy());
popperInstancesRef.current = [];
popperContainerRef.current?.remove();
popperContainerRef.current = null;
};
container.style.overflow = "hidden";
const createPoppers = () => {
removeAllPoppers();
if (!plusesContainer.current) {
plusesContainer.current = document.createElement("div");
plusesContainer.current.setAttribute("id", "popper-pluses");
container.append(plusesContainer.current);
}
if (!crossesContainer.current) {
crossesContainer.current = document.createElement("div");
crossesContainer.current.setAttribute("id", "popper-crosses");
container.append(crossesContainer.current);
}
if (!gearsContainer.current) {
gearsContainer.current = document.createElement("div");
gearsContainer.current.setAttribute("id", "popper-gears");
container.append(gearsContainer.current);
}
if (!layoutsContainer.current) {
layoutsContainer.current = document.createElement("div");
layoutsContainer.current.setAttribute("id", "popper-layouts");
container.append(layoutsContainer.current);
}
const cy = cyRef.current;
if (!cy) return;
const ext = cy.extent();
const nodesInView = cy.nodes().filter((n) => {
const bb = n.boundingBox();
return (
bb.x2 > ext.x1 && bb.x1 < ext.x2 && bb.y2 > ext.y1 && bb.y1 < ext.y2
);
});
const container = cy.container();
nodesInView.toArray()?.forEach((item) => {
const node = item as NodeSingularWithPopper;
if (!container) {
console.warn("Cannot create popper container");
return;
}
const layoutsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = layoutsContainer.current?.querySelector(
`.popper-layout[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
if (!popperContainerRef.current) {
popperContainerRef.current = document.createElement("div");
popperContainerRef.current.setAttribute("id", "poppers-container");
container.append(popperContainerRef.current);
}
const layoutElement = document.createElement("div");
layoutElement.style.zIndex = "0";
layoutElement.classList.add("popper-layout");
layoutElement.setAttribute("data-id", item.id());
layoutElement.addEventListener("mouseup", () => {
//Узнаём грани, идущие от этой ноды
setModalQuestionParentContentId(item.id());
setOpenedModalQuestions(true);
});
layoutsContainer.current?.appendChild(layoutElement);
cy.nodes().forEach((item) => {
const node = item as NodeSingularWithPopper;
return layoutElement;
},
});
const layoutsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: (items) => {
const item = items[0];
const itemId = item.id();
const itemElement = popperContainerRef.current?.querySelector(`.popper-layout[data-id='${itemId}']`);
if (itemElement) {
return itemElement;
}
const plusesPopper = node.popper({
popper: {
placement: "right",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = plusesContainer.current?.querySelector(
`.popper-plus[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const layoutElement = document.createElement("div");
layoutElement.style.zIndex = "0";
layoutElement.classList.add("popper-layout");
layoutElement.setAttribute("data-id", item.id());
layoutElement.addEventListener("mouseup", () => {
//Узнаём грани, идущие от этой ноды
setModalQuestionParentContentId(item.id());
setOpenedModalQuestions(true);
});
popperContainerRef.current?.appendChild(layoutElement);
const plusElement = document.createElement("div");
plusElement.classList.add("popper-plus");
plusElement.setAttribute("data-id", item.id());
plusElement.style.zIndex = "1";
plusElement.addEventListener("mouseup", () => {
setStartCreate(node.id());
});
return layoutElement;
},
});
popperInstancesRef.current.push(layoutsPopper);
plusesContainer.current?.appendChild(plusElement);
const plusesPopper = node.popper({
popper: {
placement: "right",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = popperContainerRef.current?.querySelector(`.popper-plus[data-id='${itemId}']`);
if (itemElement) {
return itemElement;
}
return plusElement;
},
});
const plusElement = document.createElement("div");
plusElement.classList.add("popper-plus");
plusElement.setAttribute("data-id", item.id());
plusElement.style.zIndex = "1";
plusElement.addEventListener("mouseup", () => {
if (!cy || !quizId) return;
const crossesPopper = node.popper({
popper: {
placement: "top-end",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = crossesContainer.current?.querySelector(
`.popper-cross[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const es = addNode({
cy,
quizId,
parentNodeContentId: node.id(),
});
runCyLayout();
if (es) cy.fit(es, 100);
cleardragQuestionContentId();
});
const crossElement = document.createElement("div");
crossElement.classList.add("popper-cross");
crossElement.setAttribute("data-id", item.id());
crossElement.style.zIndex = "2";
crossesContainer.current?.appendChild(crossElement);
crossElement.addEventListener("mouseup", () => {
setStartRemove(node.id());
});
popperContainerRef.current?.appendChild(plusElement);
return crossElement;
},
});
let gearsPopper: Popper | null = null;
if (node.data().root !== true) {
gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
return plusElement;
},
});
popperInstancesRef.current.push(plusesPopper);
const itemElement = gearsContainer.current?.querySelector(
`.popper-gear[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
const crossesPopper = node.popper({
popper: {
placement: "top-end",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = popperContainerRef.current?.querySelector(`.popper-cross[data-id='${itemId}']`);
if (itemElement) {
return itemElement;
}
const crossElement = document.createElement("div");
crossElement.classList.add("popper-cross");
crossElement.setAttribute("data-id", item.id());
crossElement.style.zIndex = "2";
popperContainerRef.current?.appendChild(crossElement);
crossElement.addEventListener("mouseup", () => {
updateDeleteId(node.id());
});
return crossElement;
},
});
popperInstancesRef.current.push(crossesPopper);
let gearsPopper: PopperInstance | null = null;
if (node.data().root !== true) {
gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = popperContainerRef.current?.querySelector(`.popper-gear[data-id='${itemId}']`);
if (itemElement) {
return itemElement;
}
const gearElement = document.createElement("div");
gearElement.classList.add("popper-gear");
gearElement.setAttribute("data-id", item.id());
gearElement.style.zIndex = "1";
popperContainerRef.current?.appendChild(gearElement);
gearElement.addEventListener("mouseup", () => {
updateOpenedModalSettingsId(item.id());
});
return gearElement;
},
});
popperInstancesRef.current.push(gearsPopper);
}
const gearElement = document.createElement("div");
gearElement.classList.add("popper-gear");
gearElement.setAttribute("data-id", item.id());
gearElement.style.zIndex = "1";
gearsContainer.current?.appendChild(gearElement);
gearElement.addEventListener("mouseup", () => {
updateOpenedModalSettingsId(item.id());
});
const onZoom = (event: AbstractEventObject) => {
if (event.cy.data("dragging")) return;
const zoom = event.cy.zoom();
return gearElement;
},
crossesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [-5 * zoom, -30 * zoom] } },
],
});
layoutsPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, -130 * zoom] } },
],
});
plusesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0] } },
],
});
gearsPopper?.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0] } },
],
});
popperContainerRef.current?.querySelectorAll(".popper-layout").forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${130 * zoom}px`;
element.style.height = `${130 * zoom}px`;
});
popperContainerRef.current?.querySelectorAll(".popper-plus").forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${40 * zoom}px`;
element.style.height = `${40 * zoom}px`;
element.style.fontSize = `${40 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
popperContainerRef.current?.querySelectorAll(".popper-cross").forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${24 * zoom}px`;
element.style.height = `${24 * zoom}px`;
element.style.fontSize = `${24 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
popperContainerRef?.current?.querySelectorAll(".popper-gear").forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${60 * zoom}px`;
element.style.height = `${40 * zoom}px`;
});
};
cy.on("zoom render", onZoom);
});
}
const update = async () => {
await plusesPopper.update();
await crossesPopper.update();
await gearsPopper?.update();
await layoutsPopper.update();
};
};
const onZoom = (event: AbstractEventObject) => {
const zoom = event.cy.zoom();
//update();
crossesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [-5 * zoom, -30 * zoom] } },
],
});
layoutsPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, -130 * zoom] } },
],
});
plusesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0 * zoom] } },
],
});
gearsPopper?.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0] } },
],
});
layoutsContainer.current
?.querySelectorAll("#popper-layouts > .popper-layout")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${130 * zoom}px`;
element.style.height = `${130 * zoom}px`;
});
plusesContainer.current
?.querySelectorAll("#popper-pluses > .popper-plus")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${40 * zoom}px`;
element.style.height = `${40 * zoom}px`;
element.style.fontSize = `${40 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
crossesContainer.current
?.querySelectorAll("#popper-crosses > .popper-cross")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${24 * zoom}px`;
element.style.height = `${24 * zoom}px`;
element.style.fontSize = `${24 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
gearsContainer?.current
?.querySelectorAll("#popper-gears > .popper-gear")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${60 * zoom}px`;
element.style.height = `${40 * zoom}px`;
});
};
//node?.on("position", update);
let pressed = false;
let hide = false;
cy?.on("mousedown", () => {
pressed = true;
});
cy?.on("mouseup", () => {
pressed = false;
hide = false;
const gc = gearsContainer.current;
if (gc) gc.style.display = "block";
const pc = plusesContainer.current;
const xc = crossesContainer.current;
const lc = layoutsContainer.current;
if (pc) pc.style.display = "block";
if (xc) xc.style.display = "block";
if (lc) lc.style.display = "block";
update();
});
cy?.on("mousemove", () => {
if (pressed && !hide) {
hide = true;
const gc = gearsContainer.current;
if (gc) gc.style.display = "none";
const pc = plusesContainer.current;
const xc = crossesContainer.current;
const lc = layoutsContainer.current;
if (pc) pc.style.display = "none";
if (xc) xc.style.display = "none";
if (lc) lc.style.display = "block";
}
});
cy?.on("zoom render", onZoom);
});
};
const readyLO = (event: LayoutEventObject) => {
if (event.cy.data("firstNode") === "nonroot") {
event.cy.data("firstNode", "root");
event.cy
.nodes()
.sort((a, b) => (a.data("root") ? 1 : -1))
.layout(layoutOptions)
.run();
} else {
event.cy.data("changed", false);
event.cy.removeData("firstNode");
}
//удаляем иконки
event.cy.nodes().forEach((ele: any) => {
const data = ele.data();
data.id && removeButtons(data.id);
});
initialPopperIcons(event);
};
const layoutOptions: PresetLayoutOptions = {
name: "preset",
positions: (node) => {
if (!node.cy().data("changed")) {
return node.data("oldPos");
}
const id = node.id();
const incomming = node.cy().edges(`[target="${id}"]`);
const layer = 0;
node.removeData("lastChild");
if (incomming.length === 0) {
if (node.cy().data("firstNode") === undefined)
node.cy().data("firstNode", "root");
node.data("root", true);
const children = node.cy().edges(`[source="${id}"]`).targets();
node.data("layer", layer);
node.data("children", children.length);
const queue = [];
children.forEach((n) => {
queue.push({ task: n, layer: layer + 1 });
});
while (queue.length) {
const task = queue.pop();
task.task.data("layer", task.layer);
task.task.removeData("subtreeWidth");
const children = node
.cy()
.edges(`[source="${task.task.id()}"]`)
.targets();
task.task.data("children", children.length);
if (children.length !== 0) {
children.forEach((n) =>
queue.push({ task: n, layer: task.layer + 1 })
);
}
}
queue.push({ parent: node, children: children });
while (queue.length) {
const task = queue.pop();
if (task.children.length === 0) {
task.parent.data("subtreeWidth", task.parent.height() + 50);
continue;
}
const unprocessed = task?.children.filter((node) => {
return node.data("subtreeWidth") === undefined;
});
if (unprocessed.length !== 0) {
queue.push(task);
unprocessed.forEach((t) => {
queue.push({
parent: t,
children: t.cy().edges(`[source="${t.id()}"]`).targets(),
});
});
continue;
}
task?.parent.data(
"subtreeWidth",
task.children.reduce((p, n) => p + n.data("subtreeWidth"), 0)
);
}
const pos = { x: 0, y: 0 };
node.data("oldPos", pos);
queue.push({ task: children, parent: node });
while (queue.length) {
const task = queue.pop();
const oldPos = task.parent.data("oldPos");
let yoffset = oldPos.y - task.parent.data("subtreeWidth") / 2;
task.task.forEach((n) => {
const width = n.data("subtreeWidth");
n.data("oldPos", {
x: 250 * n.data("layer"),
y: yoffset + width / 2,
});
yoffset += width;
queue.push({
task: n.cy().edges(`[source="${n.id()}"]`).targets(),
parent: n,
});
});
}
node.cy().data("changed", false);
return pos;
} else {
const opos = node.data("oldPos");
if (opos) {
return opos;
}
}
}, // map of (node id) => (position obj); or function(node){ return somPos; }
zoom: undefined, // the zoom level to set (prob want fit = false if set)
pan: 1, // the pan level to set (prob want fit = false if set)
fit: false, // whether to fit to viewport
padding: 30, // padding on fit
animate: false, // whether to transition the node positions
animationDuration: 500, // duration of animation in ms if enabled
animationEasing: undefined, // easing of animation if enabled
animateFilter: function (node, i) {
return false;
}, // a function that determines whether the node should be animated. All nodes animated by default on animate enabled. Non-animated nodes are positioned immediately when the layout starts
ready: readyLO, // callback on layoutready
transform: function (node, position) {
return position;
}, // transform a given node position. Useful for changing flow direction in discrete layouts
};
return { layoutOptions };
return { removeAllPoppers, removePoppersById, createPoppers };
};

@ -1,221 +1,122 @@
import {
deleteQuestion,
updateQuestion,
getQuestionByContentId,
clearRuleForAll,
createResult,
} from "@root/questions/actions";
import { devlog } from "@frontend/kitui";
import { QuizQuestionResult } from "@model/questionTypes/result";
import { clearRuleForAll, getQuestionByContentId, updateQuestion } from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootContentId } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import type { CollectionReturnValue, Core, SingularElementArgument } from "cytoscape";
import type { MutableRefObject } from "react";
import type {
Core,
CollectionReturnValue,
PresetLayoutOptions,
} from "cytoscape";
import type {
AnyTypedQuizQuestion,
QuestionBranchingRule,
QuestionBranchingRuleMain,
} from "../../../../model/questionTypes/shared";
import { clearDataAfterRemoveNode } from "../helper";
type UseRemoveNodeArgs = {
cyRef: MutableRefObject<Core | null>;
layoutOptions: PresetLayoutOptions;
layoutsContainer: MutableRefObject<HTMLDivElement | null>;
plusesContainer: MutableRefObject<HTMLDivElement | null>;
crossesContainer: MutableRefObject<HTMLDivElement | null>;
gearsContainer: MutableRefObject<HTMLDivElement | null>;
cyRef: MutableRefObject<Core | null>;
runCyLayout: () => void;
removeButtons: (id: string) => void;
};
export const useRemoveNode = ({
cyRef,
layoutOptions,
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
cyRef,
runCyLayout,
removeButtons,
}: UseRemoveNodeArgs) => {
const { questions: trashQuestions } = useQuestionsStore();
const quiz = useCurrentQuiz();
const { questions: trashQuestions } = useQuestionsStore();
const quiz = useCurrentQuiz();
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
const removeNode = (targetNodeContentId: string) => {
const deleteNodes: string[] = [];
const deleteEdges: SingularElementArgument[] = [];
const cy = cyRef?.current;
const clearDataAfterRemoveNode = ({
targetQuestionContentId,
parentQuestionContentId,
}: {
targetQuestionContentId: string;
parentQuestionContentId: string;
}) => {
updateQuestion(targetQuestionContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.children = [];
question.content.rule.main = [];
question.content.rule.default = "";
});
const findChildrenToDelete = (node: CollectionReturnValue) => {
//Узнаём грани, идущие от этой ноды
cy
?.$('edge[source = "' + node.id() + '"]')
?.toArray()
.forEach((edge) => {
const edgeData = edge.data();
//Ищём родителя
const parentQuestion = getQuestionByContentId(parentQuestionContentId);
//записываем id грани для дальнейшего удаления
deleteEdges.push(edge);
//ищем ноду на конце грани, записываем её ID для дальнейшего удаления
const targetNode = cy?.$("#" + edgeData.target);
deleteNodes.push(targetNode.data().id);
//вызываем функцию для анализа потомков уже у этой ноды
findChildrenToDelete(targetNode);
});
};
//Делаем результат родителя активным
const parentResult = trashQuestions.find(q => q.type === "result" && q.content.rule.parentId === parentQuestionContentId)
if (parentResult) {
updateQuestion(parentResult.content.id, q => {
q.content.usage = true
})
} else {
createResult(quiz?.backendId, parentQuestionContentId)
}
const elementToDelete = cy?.getElementById(targetNodeContentId);
//чистим rule родителя
if (!parentQuestion?.type) {
return;
}
const newChildren = [...parentQuestion.content.rule.children];
newChildren.splice(
parentQuestion.content.rule.children.indexOf(targetQuestionContentId),
1
);
const newRule: QuestionBranchingRule = {
children: newChildren,
default:
parentQuestion.content.rule.default === targetQuestionContentId
? ""
: parentQuestion.content.rule.default,
//удаляем условия перехода от родителя к этому вопросу,
main: parentQuestion.content.rule.main.filter(
(data: QuestionBranchingRuleMain) =>
data.next !== targetQuestionContentId
),
parentId: parentQuestion.content.rule.parentId,
};
updateQuestion(parentQuestionContentId, (PQ) => {
PQ.content.rule = newRule;
});
};
const removeNode = (targetNodeContentId: string) => {
const deleteNodes: string[] = [];
const deleteEdges: any = [];
const cy = cyRef?.current;
const findChildrenToDelete = (node: CollectionReturnValue) => {
//Узнаём грани, идущие от этой ноды
cy?.$('edge[source = "' + node.id() + '"]')
?.toArray()
.forEach((edge) => {
const edgeData = edge.data();
//записываем id грани для дальнейшего удаления
deleteEdges.push(edge);
//ищем ноду на конце грани, записываем её ID для дальнейшего удаления
const targetNode = cy?.$("#" + edgeData.target);
deleteNodes.push(targetNode.data().id);
//вызываем функцию для анализа потомков уже у этой ноды
findChildrenToDelete(targetNode);
});
};
const elementToDelete = cy?.getElementById(targetNodeContentId);
if (elementToDelete) {
findChildrenToDelete(elementToDelete);
}
const targetQuestion = getQuestionByContentId(targetNodeContentId);
if (
targetQuestion?.type &&
targetQuestion.content.rule.parentId === "root" &&
quiz
) {
updateRootContentId(quiz?.id, "");
updateQuestion(targetNodeContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.children = [];
question.content.rule.default = "";
});
clearRuleForAll();
} else {
const parentQuestionContentId = cy
?.$('edge[target = "' + targetNodeContentId + '"]')
?.toArray()?.[0]
?.data()?.source;
if (targetNodeContentId && parentQuestionContentId) {
if (
quiz &&
cy?.edges(`[source="${parentQuestionContentId}"]`).length === 0
) {
console.log(parentQuestionContentId)
//createFrontResult(quiz.backendId, parentQuestionContentId);
if (elementToDelete) {
findChildrenToDelete(elementToDelete);
}
clearDataAfterRemoveNode({
targetQuestionContentId: targetNodeContentId,
parentQuestionContentId,
const targetQuestion = getQuestionByContentId(targetNodeContentId);
if (targetQuestion?.type && targetQuestion.content.rule.parentId === "root" && quiz) {
updateRootContentId(quiz.id, "");
updateQuestion(targetNodeContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.children = [];
question.content.rule.default = "";
});
clearRuleForAll();
} else {
const parentQuestionContentId = cy
?.$('edge[target = "' + targetNodeContentId + '"]')
?.toArray()?.[0]
?.data()?.source;
if (targetNodeContentId && parentQuestionContentId) {
if (quiz && cy?.edges(`[source="${parentQuestionContentId}"]`).length === 0) {
devlog(parentQuestionContentId);
//createFrontResult(quiz.backendId, parentQuestionContentId);
}
clearDataAfterRemoveNode({
quiz,
trashQuestions,
targetQuestionContentId: targetNodeContentId,
parentQuestionContentId,
});
cy?.remove(cy?.$("#" + targetNodeContentId));
}
}
//После всех манипуляций удаляем грани и ноды из CS Чистим rule потомков на беке
deleteNodes.forEach((nodeId) => {
//Ноды
cy?.remove(cy?.$("#" + nodeId));
removeButtons(nodeId);
updateQuestion(nodeId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.default = "";
question.content.rule.children = [];
});
});
cy?.remove(cy?.$("#" + targetNodeContentId))
.layout(layoutOptions)
.run();
}
}
//После всех манипуляций удаляем грани и ноды из CS Чистим rule потомков на беке
deleteEdges.forEach((edge: any) => {
//Грани
cy?.remove(edge);
});
deleteNodes.forEach((nodeId) => {
//Ноды
cy?.remove(cy?.$("#" + nodeId));
removeButtons(nodeId);
updateQuestion(nodeId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.default = "";
question.content.rule.children = [];
});
});
removeButtons(targetNodeContentId);
runCyLayout();
deleteEdges.forEach((edge: any) => {
//Грани
cy?.remove(edge);
});
//делаем result всех потомков неактивными
trashQuestions.forEach((qr) => {
if (
qr.type === "result" &&
(deleteNodes.includes(qr.content.rule.parentId || "") ||
(targetQuestion?.type && qr.content.rule.parentId === targetQuestion.content.id))
) {
updateQuestion<QuizQuestionResult>(qr.content.id, (q) => {
q.content.usage = false;
});
}
});
};
removeButtons(targetNodeContentId);
cy?.data("changed", true);
cy?.layout(layoutOptions).run();
//делаем result всех потомков неактивными
trashQuestions.forEach((qr) => {
if (
qr.type === "result" &&
(deleteNodes.includes(qr.content.rule.parentId || "") ||
(targetQuestion?.type &&
qr.content.rule.parentId === targetQuestion.content.id))
) {
updateQuestion(qr.content.id, q => {
q.content.usage = false
})
}
});
};
return { removeNode };
return { removeNode };
};

@ -1,54 +1,34 @@
import { Box } from "@mui/material";
import { FirstNodeField } from "./FirstNodeField";
import CsComponent from "./CsComponent";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useEffect, useState } from "react";
import { BranchingQuestionsModal } from "../BranchingQuestionsModal";
import { useUiTools } from "@root/uiTools/store";
import { BranchingQuestionsModal } from "../BranchingQuestionsModal";
import CsComponent from "./CsComponent";
import { FirstNodeField } from "./FirstNodeField";
export const BranchingMap = () => {
const quiz = useCurrentQuiz();
const { dragQuestionContentId } = useUiTools();
const [modalQuestionParentContentId, setModalQuestionParentContentId] =
useState<string>("");
const [modalQuestionTargetContentId, setModalQuestionTargetContentId] =
useState<string>("");
const [openedModalQuestions, setOpenedModalQuestions] =
useState<boolean>(false);
const quiz = useCurrentQuiz();
const dragQuestionContentId = useUiTools(state => state.dragQuestionContentId);
return (
<Box
id="cytoscape-container"
sx={{
overflow: "hidden",
padding: "20px",
background: "#FFFFFF",
borderRadius: "12px",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
marginBottom: "40px",
height: "568px",
border: dragQuestionContentId === null ? "none" : "#7e2aea 2px dashed",
}}
>
{quiz?.config.haveRoot ? (
<CsComponent
modalQuestionParentContentId={modalQuestionParentContentId}
modalQuestionTargetContentId={modalQuestionTargetContentId}
setOpenedModalQuestions={setOpenedModalQuestions}
setModalQuestionParentContentId={setModalQuestionParentContentId}
setModalQuestionTargetContentId={setModalQuestionTargetContentId}
/>
) : (
<FirstNodeField
setOpenedModalQuestions={setOpenedModalQuestions}
modalQuestionTargetContentId={modalQuestionTargetContentId}
/>
)}
<BranchingQuestionsModal
openedModalQuestions={openedModalQuestions}
setOpenedModalQuestions={setOpenedModalQuestions}
setModalQuestionTargetContentId={setModalQuestionTargetContentId}
/>
</Box>
);
return (
<Box
id="cytoscape-container"
sx={{
overflow: "hidden",
padding: "20px",
background: "#FFFFFF",
borderRadius: "12px",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
marginBottom: "40px",
height: "568px",
border: dragQuestionContentId === null ? "none" : "#7e2aea 2px dashed",
}}
>
{quiz?.config.haveRoot ? (
<CsComponent />
) : (
<FirstNodeField />
)}
<BranchingQuestionsModal />
</Box>
);
};

@ -1,4 +1,4 @@
#popper-pluses > .popper-plus {
.popper-plus {
cursor: pointer;
display: flex;
align-items: center;
@ -9,13 +9,13 @@
font-size: 0px;
}
#popper-pluses > .popper-plus::before {
.popper-plus::before {
content: "+";
color: rgba(154, 154, 175, 0.5);
font-size: inherit;
}
#popper-crosses > .popper-cross {
.popper-cross {
cursor: pointer;
display: flex;
align-items: center;
@ -25,14 +25,14 @@
font-size: 0px;
}
#popper-crosses > .popper-cross::before {
.popper-cross::before {
content: "+";
transform: rotate(45deg);
color: #fff;
font-size: inherit;
}
#popper-gears > .popper-gear {
.popper-gear {
cursor: pointer;
display: flex;
align-items: center;

@ -1,90 +1,84 @@
import { Box, Modal, Button, Typography } from "@mui/material";
import { useQuestionsStore } from "@root/questions/store";
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { useUiTools } from "@root/uiTools/store";
import { setModalQuestionTargetContentId, setOpenedModalQuestions } from "@root/uiTools/actions";
interface Props {
openedModalQuestions: boolean;
setModalQuestionTargetContentId: (contentId: string) => void;
setOpenedModalQuestions: (open: boolean) => void;
}
export const BranchingQuestionsModal = ({
openedModalQuestions,
setOpenedModalQuestions,
setModalQuestionTargetContentId,
}: Props) => {
const trashQuestions = useQuestionsStore().questions;
const questions = trashQuestions.filter(
(question) => question.type !== "result"
);
export const BranchingQuestionsModal = () => {
const trashQuestions = useQuestionsStore().questions;
const questions = trashQuestions.filter(
(question) => question.type !== "result"
);
const openedModalQuestions = useUiTools(state => state.openedModalQuestions);
const handleClose = () => {
setOpenedModalQuestions(false);
};
const handleClose = () => {
setOpenedModalQuestions(false);
};
const typedQuestions: AnyTypedQuizQuestion[] = questions.filter(
(question) =>
question.type &&
!question.content.rule.parentId &&
question.type !== "result"
) as AnyTypedQuizQuestion[];
const typedQuestions: AnyTypedQuizQuestion[] = questions.filter(
(question) =>
question.type &&
!question.content.rule.parentId &&
question.type !== "result"
) as AnyTypedQuizQuestion[];
if (typedQuestions.length === 0) return <></>;
if (typedQuestions.length === 0) return <></>;
return (
<Modal open={openedModalQuestions} onClose={handleClose}>
<Box
sx={{
position: "absolute",
overflow: "auto",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "620px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
padding: "30px 0",
height: "80vh",
}}
>
<Box sx={{ margin: "0 auto", maxWidth: "350px" }}>
{typedQuestions.map((question) => (
<Button
key={question.content.id}
onClick={() => {
setModalQuestionTargetContentId(question.content.id);
handleClose();
}}
sx={{
width: "100%",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
background: "#FFFFFF",
borderRadius: "8px",
marginBottom: "20px",
boxShadow: "0px 10px 30px #e7e7e7",
backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='rgb(154, 154, 175)' strokeWidth='2' stroke-dasharray='8 8' stroke-dashoffset='0' strokeLinecap='square'/%3e%3c/svg%3e");
border-radius: 8px;`,
"&:last-child": { marginBottom: 0 },
}}
>
<Typography
return (
<Modal open={openedModalQuestions} onClose={handleClose}>
<Box
sx={{
width: "100%",
color: "#000",
position: "absolute",
overflow: "auto",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "620px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
padding: "30px 0",
height: "80vh",
}}
>
{question.title || "нет заголовка"}
</Typography>
</Button>
))}
</Box>
</Box>
</Modal>
);
>
<Box sx={{ margin: "0 auto", maxWidth: "350px" }}>
{typedQuestions.map((question) => (
<Button
key={question.content.id}
onClick={() => {
setModalQuestionTargetContentId(question.content.id);
handleClose();
}}
sx={{
width: "100%",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
background: "#FFFFFF",
borderRadius: "8px",
marginBottom: "20px",
boxShadow: "0px 10px 30px #e7e7e7",
backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='rgb(154, 154, 175)' strokeWidth='2' stroke-dasharray='8 8' stroke-dashoffset='0' strokeLinecap='square'/%3e%3c/svg%3e");
border-radius: 8px;`,
"&:last-child": { marginBottom: 0 },
}}
>
<Typography
sx={{
width: "100%",
color: "#000",
}}
>
{question.title || "нет заголовка"}
</Typography>
</Button>
))}
</Box>
</Box>
</Modal>
);
};

@ -498,7 +498,7 @@ export const clearRuleForAll = () => {
};
export const createResult = async (
quizId: number,
quizId: number | undefined,
parentContentId?: string
) => requestQueue.enqueue(async () => {
if (!quizId || !parentContentId) {
@ -543,4 +543,3 @@ export const createResult = async (
}
}
});

@ -38,3 +38,7 @@ export const updateCanCreatePublic = (can: boolean) => useUiTools.setState({ can
export const updateModalInfoWhyCantCreate = (can: boolean) => useUiTools.setState({ openModalInfoWhyCantCreate: can });
export const updateDeleteId = (deleteNodeId: string | null = null) => useUiTools.setState({ deleteNodeId });
export const setModalQuestionParentContentId = (modalQuestionParentContentId: string) => useUiTools.setState({ modalQuestionParentContentId });
export const setModalQuestionTargetContentId = (modalQuestionTargetContentId: string) => useUiTools.setState({ modalQuestionTargetContentId });
export const setOpenedModalQuestions = (open: boolean) => useUiTools.setState({ openedModalQuestions: open });

@ -10,7 +10,10 @@ export type UiTools = {
canCreatePublic: boolean;
whyCantCreatePublic: Record<string, WhyCantCreatePublic>//ид вопроса и список претензий к нему
openModalInfoWhyCantCreate: boolean;
deleteNodeId: string | null;
deleteNodeId: string | null;
modalQuestionParentContentId: string;
modalQuestionTargetContentId: string;
openedModalQuestions: boolean;
};
export type WhyCantCreatePublic = {
name: string;
@ -27,13 +30,16 @@ const initialState: UiTools = {
canCreatePublic: false,
whyCantCreatePublic: {},
openModalInfoWhyCantCreate: false,
deleteNodeId: null,
deleteNodeId: null,
modalQuestionParentContentId: "",
modalQuestionTargetContentId: "",
openedModalQuestions: false,
};
export const useUiTools = create<UiTools>()(
devtools(() => initialState, {
name: "UiTools",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
})
devtools(() => initialState, {
name: "UiTools",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
})
);