diff --git a/src/pages/Questions/BranchingMap/CsComponent.tsx b/src/pages/Questions/BranchingMap/CsComponent.tsx index 1bf2f152..b2333f3d 100644 --- a/src/pages/Questions/BranchingMap/CsComponent.tsx +++ b/src/pages/Questions/BranchingMap/CsComponent.tsx @@ -1,117 +1,54 @@ -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 { Box, Button } from "@mui/material"; +import { clearRuleForAll } from "@root/questions/actions"; import { useQuestionsStore } from "@root/questions/store"; -import { useUiTools } from "@root/uiTools/store"; -import { - deleteQuestion, - updateQuestion, - getQuestionByContentId, - clearRuleForAll, - createResult, -} from "@root/questions/actions"; +import { updateRootContentId } from "@root/quizes/actions"; +import { useCurrentQuiz } from "@root/quizes/hooks"; import { + cleardragQuestionContentId, + setModalQuestionParentContentId, + setModalQuestionTargetContentId, updateModalInfoWhyCantCreate, updateOpenedModalSettingsId, } from "@root/uiTools/actions"; -import { cleardragQuestionContentId } from "@root/uiTools/actions"; -import { updateDeleteId } from "@root/uiTools/actions"; - -import { DeleteNodeModal } from "../DeleteNodeModal"; +import { useUiTools } from "@root/uiTools/store"; import { ProblemIcon } from "@ui_kit/ProblemIcon"; - -import { useRemoveNode } from "./hooks/useRemoveNode"; -import { usePopper } from "./hooks/usePopper"; - -import { storeToNodes } from "./helper"; -import { stylesheet } from "./style/stylesheet"; -import "./style/styles.css"; - import type { Core } from "cytoscape"; -import { nameCutter } from "./nameCutter"; +import { enqueueSnackbar } from "notistack"; +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import CytoscapeComponent from "react-cytoscapejs"; +import { withErrorBoundary } from "react-error-boundary"; +import { DeleteNodeModal } from "../DeleteNodeModal"; +import CsNodeButtons from "./CsNodeButtons"; +import { addNode, layoutOptions, storeToNodes } from "./helper"; +import { useRemoveNode } from "./hooks/useRemoveNode"; +import "./style/styles.css"; +import { stylesheet } from "./style/stylesheet"; -Cytoscape.use(popper); - -interface CsComponentProps { - modalQuestionParentContentId: string; - modalQuestionTargetContentId: string; - setOpenedModalQuestions: (open: boolean) => void; - setModalQuestionParentContentId: (id: string) => void; - setModalQuestionTargetContentId: (id: string) => void; -} - -function CsComponent({ - modalQuestionParentContentId, - modalQuestionTargetContentId, - setOpenedModalQuestions, - setModalQuestionParentContentId, - setModalQuestionTargetContentId, -}: CsComponentProps) { - const quiz = useCurrentQuiz(); - - const { - dragQuestionContentId, - desireToOpenABranchingModal, - canCreatePublic, - someWorkBackend, - } = useUiTools(); - const trashQuestions = useQuestionsStore().questions; - const questions = trashQuestions.filter( - (question) => - question.type !== "result" && question.type !== null && !question.deleted, +function CsComponent() { + const desireToOpenABranchingModal = useUiTools( + (state) => state.desireToOpenABranchingModal, ); - const [startCreate, setStartCreate] = useState(""); - const [startRemove, setStartRemove] = useState(""); - + 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 cyRef = useRef(null); - const layoutsContainer = useRef(null); - const plusesContainer = useRef(null); - const crossesContainer = useRef(null); - const gearsContainer = useRef(null); + const { removeNode } = useRemoveNode({ cyRef }); - const { layoutOptions } = usePopper({ - layoutsContainer, - plusesContainer, - crossesContainer, - gearsContainer, - setModalQuestionParentContentId, - setOpenedModalQuestions, - setStartCreate, - setStartRemove, - }); - const { removeNode } = useRemoveNode({ - cyRef, - layoutOptions, - layoutsContainer, - plusesContainer, - crossesContainer, - gearsContainer, - }); + const csElements = useMemo(() => { + const questions = trashQuestions.filter( + (question): question is AnyTypedQuizQuestion => + question.type !== null && question.type !== "result", + ); - function fitGraphToRootNode() { - const cy = cyRef.current; - if (!cy) return; - - const rootNode = cy.nodes().filter((n) => n.data("root"))[0]; - if (!rootNode) throw new Error("Root node not found"); - - const height = cy.height(); - const position = rootNode.position(); - const shift = rootNode.width() / 2; - - cy.pan({ - x: position.x + shift, - y: position.y + height / 2, - }); - } + return storeToNodes(questions); + }, [trashQuestions]); useLayoutEffect(() => { const cy = cyRef?.current; @@ -125,19 +62,14 @@ function CsComponent({ cy?.elements().data("eroticeyeblink", false); } }, [desireToOpenABranchingModal]); - //Техническая штучка. Гарантирует не отрисовку модалки по первому входу на страничку. И очистка данных по расскоменчиванию - //Быстро просто дешево и сердито :) - useLayoutEffect(() => { - updateOpenedModalSettingsId(); - // updateRootContentId(quiz.id, "") - // clearRuleForAll() - }, []); - //Отлов mouseup для отрисовки ноды + useEffect(() => { if ( modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0 ) { + if (!cyRef.current) return; + addNode({ parentNodeContentId: modalQuestionParentContentId, targetNodeContentId: modalQuestionTargetContentId, @@ -147,195 +79,27 @@ function CsComponent({ 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 parentQuestion = getQuestionByContentId(parentNodeContentId); - //Нельзя добавлять больше 1 ребёнка вопросам типа страница, ползунок, своё поле для ввода и дата - if ( - (parentQuestion?.type === "date" || - parentQuestion?.type === "text" || - parentQuestion?.type === "number" || - parentQuestion?.type === "page") && - parentQuestion.content.rule.children.length === 1 - ) { - enqueueSnackbar("у вопроса этого типа может быть только 1 потомок"); - return; - } - - //если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа - 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.page - : nameCutter(targetQuestion.title), - parentType: parentNodeContentId, - }, - }, - { - data: { - source: parentNodeContentId, - target: targetQuestion.content.id, - }, - }, - ]); - cy?.layout(layoutOptions).run(); - cy?.center(es); - } else { - enqueueSnackbar("Перетащите на плюсик вопрос"); - } - } else { - enqueueSnackbar("Quiz не найден"); - } - }; - - 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]); - - //Отработка первичного рендера странички графика - const firstRender = useRef(true); - useEffect(() => { - if (!someWorkBackend && firstRender.current) { - 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)); - fitGraphToRootNode(); - //cy?.layout().run() - firstRender.current = false; - } + useEffect(function onMount() { + updateOpenedModalSettingsId(); + document.addEventListener("pointerup", cleardragQuestionContentId); return () => { - document - .querySelector("#root") - ?.removeEventListener("mouseup", cleardragQuestionContentId); - layoutsContainer.current?.remove(); - plusesContainer.current?.remove(); - crossesContainer.current?.remove(); - gearsContainer.current?.remove(); + document.removeEventListener("pointerup", cleardragQuestionContentId); }; - }, [someWorkBackend]); + }, []); + + useEffect( + function rerunLayout() { + cyRef.current?.layout(layoutOptions).run(); + cyRef.current?.fit(undefined, 70); + }, + [csElements], + ); return ( <> - + + @@ -353,20 +119,22 @@ function CsComponent({ onClick={() => updateModalInfoWhyCantCreate(true)} /> - { cyRef.current = cy; }} autoungrabify={true} - zoom={0.6} - zoomingEnabled={false} + autounselectify={true} + boxSelectionEnabled={false} /> @@ -386,7 +154,7 @@ export default withErrorBoundary(CsComponent, { fallback: , onError: (error, info) => { enqueueSnackbar("Дерево порвалось"); - console.log(info); - console.log(error); + devlog(info); + devlog(error); }, }); diff --git a/src/pages/Questions/BranchingMap/CsNodeButtons.tsx b/src/pages/Questions/BranchingMap/CsNodeButtons.tsx new file mode 100644 index 00000000..b12bf6cc --- /dev/null +++ b/src/pages/Questions/BranchingMap/CsNodeButtons.tsx @@ -0,0 +1,366 @@ +import { Add, Close } from "@mui/icons-material"; +import { Box, IconButton } from "@mui/material"; +import { + cleardragQuestionContentId, + setModalQuestionParentContentId, + setOpenedModalQuestions, + updateDeleteId, + updateOpenedModalSettingsId, +} from "@root/uiTools/actions"; +import { Core, EventObject, NodeSingular } from "cytoscape"; +import { + MutableRefObject, + forwardRef, + useEffect, + useMemo, + useRef, +} from "react"; +import { createPortal } from "react-dom"; +import { + addNode, + isElementANode, + isNodeInViewport, + storeToNodes, +} from "./helper"; + +const csButtonTypes = ["delete", "add", "settings", "select"] as const; + +type CsButtonType = (typeof csButtonTypes)[number]; + +type CsNodeButtonsByType = Partial< + Record +>; + +type CsButtonsById = Record; + +interface Props { + csElements: ReturnType; + cyRef: MutableRefObject; +} + +export default function CsNodeButtons({ csElements, cyRef }: Props) { + const buttonRefsById = useRef({}); + + const buttons = useMemo(() => { + const nodeElements = csElements.filter(isElementANode); + buttonRefsById.current = nodeElements.reduce( + (acc, node) => ((acc[node.data.id] = {}), acc), + {}, + ); + + return ( + + {nodeElements.flatMap((csElement) => [ + { + const buttonData = buttonRefsById.current[csElement.data.id]; + if (buttonData) buttonData.delete = r; + }} + onClick={() => { + updateDeleteId(csElement.data.id); + }} + />, + { + const buttonData = buttonRefsById.current[csElement.data.id]; + if (buttonData) buttonData.add = r; + }} + onPointerUp={() => { + addNode({ parentNodeContentId: csElement.data.id }); + cleardragQuestionContentId(); + }} + />, + !csElement.data.isRoot && ( + { + const buttonData = buttonRefsById.current[csElement.data.id]; + if (buttonData) buttonData.settings = r; + }} + onClick={() => { + updateOpenedModalSettingsId(csElement.data.id); + }} + /> + ), + { + const buttonData = buttonRefsById.current[csElement.data.id]; + if (buttonData) buttonData.select = r; + }} + onClick={() => { + setModalQuestionParentContentId(csElement.data.id); + setOpenedModalQuestions(true); + }} + />, + ])} + + ); + }, [csElements]); + + useEffect(function attachViewportHandler() { + const cy = cyRef.current; + if (!cy) return; + + let rafId: number = 0; + function handleViewportChange(event: EventObject) { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + for (const nodeId in buttonRefsById.current) { + const buttonsByType = buttonRefsById.current[nodeId]; + if (!buttonsByType) continue; + + const node = event.cy.$id(nodeId); + if (!node) { + console.warn("Could not find node for id:" + nodeId); + continue; + } + + for (const buttonType of csButtonTypes) { + const button = buttonsByType[buttonType]; + if (!button) continue; + + applyButtonStyleByType[buttonType](button, node, event.cy.zoom()); + } + } + }); + } + + cy.on("viewport", handleViewportChange); + + return () => { + cy.off("viewport", handleViewportChange); + }; + }, []); + + const container = cyRef.current?.container(); + const buttonsPortal = container ? createPortal(buttons, container) : null; + + return buttonsPortal; +} + +const applyButtonStyleByType: Record< + CsButtonType, + (button: HTMLButtonElement, node: NodeSingular, zoom: number) => void +> = { + delete(button, node, zoom) { + const nodePosition = node.renderedPosition(); + const shiftX = node.renderedWidth() / 2 - (CLOSE_BUTTON_WIDTH / 2) * zoom; + const shiftY = node.renderedHeight() / 2 - (CLOSE_BUTTON_HEIGHT / 2) * zoom; + + if (!isNodeInViewport(node, 100)) { + return button.style.setProperty("display", "none"); + } + + button.style.setProperty("display", "flex"); + button.style.setProperty("left", `${nodePosition.x}px`); + button.style.setProperty("top", `${nodePosition.y}px`); + button.style.setProperty( + "transform", + `translate3d(calc(-50% + ${shiftX}px), calc(-50% - ${shiftY}px), 0) scale(${zoom})`, + ); + }, + add(button, node, zoom) { + const nodePosition = node.renderedPosition(); + const shiftX = node.renderedWidth() / 2 + (ADD_BUTTON_WIDTH / 2) * zoom; + + if (!isNodeInViewport(node, 100)) { + return button.style.setProperty("display", "none"); + } + + button.style.setProperty("display", "flex"); + button.style.setProperty("left", `${nodePosition.x}px`); + button.style.setProperty("top", `${nodePosition.y}px`); + button.style.setProperty( + "transform", + `translate3d(calc(-50% + ${shiftX}px), -50%, 0) scale(${zoom})`, + ); + }, + settings(button, node, zoom) { + const nodePosition = node.renderedPosition(); + const shiftX = + -node.renderedWidth() / 2 - (SETTINGS_BUTTON_WIDTH / 2) * zoom; + + if (!isNodeInViewport(node, 100)) { + return button.style.setProperty("display", "none"); + } + + button.style.setProperty("display", "flex"); + button.style.setProperty("left", `${nodePosition.x}px`); + button.style.setProperty("top", `${nodePosition.y}px`); + button.style.setProperty( + "transform", + `translate3d(calc(-50% + ${shiftX}px), -50%, 0) scale(${zoom})`, + ); + }, + select(button, node, zoom) { + const nodePosition = node.renderedPosition(); + + if (!isNodeInViewport(node, 100)) { + return button.style.setProperty("display", "none"); + } + + button.style.setProperty("display", "flex"); + button.style.setProperty("left", `${nodePosition.x}px`); + button.style.setProperty("top", `${nodePosition.y}px`); + button.style.setProperty( + "transform", + `translate3d(-50%, -50%, 0) scale(${zoom})`, + ); + }, +}; + +const CLOSE_BUTTON_WIDTH = 40; +const CLOSE_BUTTON_HEIGHT = CLOSE_BUTTON_WIDTH; + +const CsDeleteButton = forwardRef< + HTMLButtonElement, + { + onClick: () => void; + } +>(({ onClick }, ref) => ( + event.stopPropagation()} + onTouchStartCapture={(event) => event.stopPropagation()} + > + + +)); + +const ADD_BUTTON_WIDTH = 40; +const ADD_BUTTON_HEIGHT = ADD_BUTTON_WIDTH; + +const CsAddButton = forwardRef< + HTMLButtonElement, + { + onPointerUp: () => void; + } +>(({ onPointerUp }, ref) => ( + event.stopPropagation()} + onTouchStartCapture={(event) => event.stopPropagation()} + > + + +)); + +const SETTINGS_BUTTON_WIDTH = 70; +const SETTINGS_BUTTON_HEIGHT = 60; + +const CsSettingsButton = forwardRef< + HTMLButtonElement, + { + onClick: () => void; + } +>(({ onClick }, ref) => ( + event.stopPropagation()} + onTouchStartCapture={(event) => event.stopPropagation()} + > + + + + + + + +)); + +const SELECT_BUTTON_WIDTH = 130; +const SELECT_BUTTON_HEIGHT = SELECT_BUTTON_WIDTH; + +const CsSelectButton = forwardRef< + HTMLButtonElement, + { + onClick: () => void; + } +>(({ onClick }, ref) => ( + event.stopPropagation()} + onTouchStartCapture={(event) => event.stopPropagation()} + /> +)); diff --git a/src/pages/Questions/BranchingMap/FirstNodeField.tsx b/src/pages/Questions/BranchingMap/FirstNodeField.tsx index 41605490..de4619fe 100644 --- a/src/pages/Questions/BranchingMap/FirstNodeField.tsx +++ b/src/pages/Questions/BranchingMap/FirstNodeField.tsx @@ -1,35 +1,33 @@ import { Box } from "@mui/material"; -import { useEffect, useRef, useLayoutEffect } from "react"; import { - deleteQuestion, clearRuleForAll, - updateQuestion, createResult, + updateQuestion, } 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 { useUiTools } from "@root/uiTools/store"; - -interface Props { - setOpenedModalQuestions: (open: boolean) => void; - modalQuestionTargetContentId: string; -} -export const FirstNodeField = ({ +import { setOpenedModalQuestions, - modalQuestionTargetContentId, -}: Props) => { + updateOpenedModalSettingsId, +} from "@root/uiTools/actions"; +import { useUiTools } from "@root/uiTools/store"; +import { enqueueSnackbar } from "notistack"; +import { useEffect, useLayoutEffect, useRef } from "react"; + +export const FirstNodeField = () => { const quiz = useCurrentQuiz(); + const modalQuestionTargetContentId = useUiTools( + (state) => state.modalQuestionTargetContentId, + ); useLayoutEffect(() => { + if (!quiz) return; + updateOpenedModalSettingsId(); updateRootContentId(quiz.id, ""); clearRuleForAll(); }, []); - const { questions } = useQuestionsStore(); const { dragQuestionContentId } = useUiTools(); const Container = useRef(null); @@ -43,18 +41,18 @@ export const FirstNodeField = ({ dragQuestionContentId, (question) => (question.content.rule.parentId = "root"), ); - createResult(quiz?.backendId, dragQuestionContentId); + createResult(dragQuestionContentId); } } else { - enqueueSnackbar("Нет информации о взятом опросе"); + enqueueSnackbar("Нет информации о взятом опроснике"); } }; useEffect(() => { - Container.current?.addEventListener("mouseup", newRootNode); + Container.current?.addEventListener("pointerup", newRootNode); Container.current?.addEventListener("click", modalOpen); return () => { - Container.current?.removeEventListener("mouseup", newRootNode); + Container.current?.removeEventListener("pointerup", newRootNode); Container.current?.removeEventListener("click", modalOpen); }; }, [dragQuestionContentId]); @@ -67,10 +65,10 @@ export const FirstNodeField = ({ modalQuestionTargetContentId, (question) => (question.content.rule.parentId = "root"), ); - createResult(quiz?.backendId, modalQuestionTargetContentId); + createResult(modalQuestionTargetContentId); } } else { - enqueueSnackbar("Нет информации о взятом опросе"); + enqueueSnackbar("Нет информации о взятом опроснике"); } }, [modalQuestionTargetContentId]); diff --git a/src/pages/Questions/BranchingMap/helper.ts b/src/pages/Questions/BranchingMap/helper.ts index e99df876..22b3390a 100644 --- a/src/pages/Questions/BranchingMap/helper.ts +++ b/src/pages/Questions/BranchingMap/helper.ts @@ -1,34 +1,72 @@ -import { AnyTypedQuizQuestion } from "@model/questionTypes/shared"; -import { nameCutter } from "./nameCutter"; +import { QuizQuestionResult } from "@model/questionTypes/result"; +import { + AnyTypedQuizQuestion, + QuestionBranchingRule, + QuestionBranchingRuleMain, + UntypedQuizQuestion, +} from "@model/questionTypes/shared"; +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 { NodeSingular, PresetLayoutOptions } from "cytoscape"; +import { enqueueSnackbar } from "notistack"; -interface Nodes { +export interface Node { data: { + isRoot: boolean; id: string; label: string; parent?: string; }; + classes: string; } -interface Edges { + +export interface Edge { data: { source: string; target: string; }; } +export function isElementANode(element: Node | Edge): element is Node { + return !("source" in element.data && "target" in element.data); +} + +export function isNodeInViewport(node: NodeSingular, padding: number = 0) { + const extent = node.cy().extent(); + const bb = node.boundingBox(); + + return ( + bb.x2 > extent.x1 - padding && + bb.x1 < extent.x2 + padding && + bb.y2 > extent.y1 - padding && + bb.y1 < extent.y2 + padding + ); +} + export const storeToNodes = (questions: AnyTypedQuizQuestion[]) => { - const nodes: Nodes[] = []; - const edges: Edges[] = []; + const nodes: Node[] = []; + const edges: Edge[] = []; questions.forEach((question) => { if (question.content.rule.parentId) { + let label = + question.title === "" || question.title === " " + ? "noname" + : question.title; + if (label.length > 25) label = label.slice(0, 25) + "…"; + nodes.push({ data: { + isRoot: question.content.rule.parentId === "root", id: question.content.id, - label: - question.title === "" || question.title === " " - ? "noname №" + question.page - : nameCutter(question.title), - parentType: question.content.rule.parentId, + label, }, + classes: "multiline-auto", }); // nodes.push({ // data: { @@ -48,3 +86,260 @@ export const storeToNodes = (questions: AnyTypedQuizQuestion[]) => { }); return [...nodes, ...edges]; }; + +export 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, +}; + +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( + 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({ + trashQuestions, + targetQuestionContentId, + parentQuestionContentId, +}: { + 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(parentResult.content.id, (q) => { + q.content.usage = true; + }); + } else { + createResult(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 = ({ + parentNodeContentId, + targetNodeContentId, +}: { + parentNodeContentId: string; + targetNodeContentId?: string; +}) => { + //запрещаем работу родителя-ребенка если это один и тот же вопрос + if (parentNodeContentId === targetNodeContentId) return; + + //если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа + const targetQuestion = { + ...getQuestionByContentId( + targetNodeContentId || useUiTools.getState().dragQuestionContentId, + ), + } as AnyTypedQuizQuestion; + + if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId) { + clearDataAfterAddNode({ parentNodeContentId, targetQuestion }); + createResult(targetQuestion.content.id); + } else { + enqueueSnackbar("Добавляемый вопрос не найден"); + } +}; diff --git a/src/pages/Questions/BranchingMap/hooks/usePopper.ts b/src/pages/Questions/BranchingMap/hooks/usePopper.ts index 3e0c7c7d..0a543399 100644 --- a/src/pages/Questions/BranchingMap/hooks/usePopper.ts +++ b/src/pages/Questions/BranchingMap/hooks/usePopper.ts @@ -1,24 +1,19 @@ -import { updateOpenedModalSettingsId } from "@root/uiTools/actions"; - -import type { MutableRefObject } from "react"; +import { + cleardragQuestionContentId, + setModalQuestionParentContentId, + setOpenedModalQuestions, + updateDeleteId, + updateOpenedModalSettingsId, +} from "@root/uiTools/actions"; import type { - PresetLayoutOptions, - LayoutEventObject, - NodeSingular, AbstractEventObject, + Core, + NodeSingular, + SingularData, } from "cytoscape"; -import { getQuestionByContentId } from "@root/questions/actions"; - -type usePopperArgs = { - layoutsContainer: MutableRefObject; - plusesContainer: MutableRefObject; - crossesContainer: MutableRefObject; - gearsContainer: MutableRefObject; - setModalQuestionParentContentId: (id: string) => void; - setOpenedModalQuestions: (open: boolean) => void; - setStartCreate: (id: string) => void; - setStartRemove: (id: string) => void; -}; +import { getPopperInstance } from "cytoscape-popper"; +import { useCallback, type MutableRefObject, useRef } from "react"; +import { addNode } from "../helper"; type PopperItem = { id: () => string; @@ -37,231 +32,175 @@ type PopperConfig = { content: (items: PopperItem[]) => void; }; -type Popper = { - update: () => Promise; - setOptions: (modifiers: { modifiers?: Modifier[] }) => void; -}; +type PopperInstance = ReturnType>; type NodeSingularWithPopper = NodeSingular & { - popper: (config: PopperConfig) => Popper; + popper: (config: PopperConfig) => PopperInstance; }; +/** @deprecated */ 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, +}: { + cyRef: MutableRefObject; +}) => { + const popperContainerRef = useRef(null); + const popperInstancesRef = useRef([]); - const initialPopperIcons = ({ cy }: LayoutEventObject) => { - const container = - (document.body.querySelector( - ".__________cytoscape_container", - ) as HTMLDivElement) || null; + const removeAllPoppers = useCallback(() => { + cyRef.current?.removeListener("zoom render"); + + popperInstancesRef.current.forEach((p) => p.destroy()); + popperInstancesRef.current = []; + popperContainerRef.current?.remove(); + popperContainerRef.current = null; + }, []); + + const recreatePoppers = useCallback(() => { + removeAllPoppers(); + + const cy = cyRef.current; + if (!cy) return; + + const container = cy.container(); if (!container) { + console.warn("Cannot create popper container"); return; } - container.style.overflow = "hidden"; - - 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); + if (!popperContainerRef.current) { + popperContainerRef.current = document.createElement("div"); + popperContainerRef.current.setAttribute("id", "poppers-container"); + container.append(popperContainerRef.current); } - cy?.removeAllListeners(); + cy.nodes().forEach((item) => { + const node = item as NodeSingularWithPopper; - cy - .nodes() - .toArray() - ?.forEach((item) => { - const node = item as NodeSingularWithPopper; + 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 layoutsPopper = node.popper({ + const layoutElement = document.createElement("div"); + layoutElement.style.zIndex = "0"; + layoutElement.classList.add("popper-layout"); + layoutElement.setAttribute("data-id", item.id()); + layoutElement.addEventListener("pointerup", () => { + //Узнаём грани, идущие от этой ноды + setModalQuestionParentContentId(item.id()); + setOpenedModalQuestions(true); + }); + popperContainerRef.current?.appendChild(layoutElement); + + return layoutElement; + }, + }); + popperInstancesRef.current.push(layoutsPopper); + + 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; + } + + const plusElement = document.createElement("div"); + plusElement.classList.add("popper-plus"); + plusElement.setAttribute("data-id", item.id()); + plusElement.style.zIndex = "1"; + plusElement.addEventListener("pointerup", () => { + addNode({ parentNodeContentId: node.id() }); + cleardragQuestionContentId(); + }); + + popperContainerRef.current?.appendChild(plusElement); + + return plusElement; + }, + }); + popperInstancesRef.current.push(plusesPopper); + + 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("pointerup", () => { + 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 = layoutsContainer.current?.querySelector( - `.popper-layout[data-id='${itemId}']`, + + const itemElement = popperContainerRef.current?.querySelector( + `.popper-gear[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); + 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("pointerup", () => { + updateOpenedModalSettingsId(item.id()); }); - layoutElement.addEventListener("touchstart", () => { - //Узнаём грани, идущие от этой ноды - setModalQuestionParentContentId(item.id()); - setOpenedModalQuestions(true); - }); - layoutsContainer.current?.appendChild(layoutElement); - return layoutElement; + return gearElement; }, }); + popperInstancesRef.current.push(gearsPopper); + } - 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 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()); - }); - plusElement.addEventListener("touchstart", () => { - setStartCreate(node.id()); - }); - - plusesContainer.current?.appendChild(plusElement); - - return plusElement; - }, - }); - - 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 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()); - }); - crossElement.addEventListener("touchstart", () => { - setStartRemove(node.id()); - }); - - return crossElement; - }, - }); - let gearsPopper: Popper | null = null; - if (node.data().root !== true) { - const parentQuestion = getQuestionByContentId( - node.data("parentType"), - ); - - gearsPopper = node.popper({ - popper: { - placement: "left", - modifiers: [{ name: "flip", options: { boundary: node } }], - }, - content: ([item]) => { - const itemId = item.id(); - - const itemElement = gearsContainer.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"; - gearsContainer.current?.appendChild(gearElement); - gearElement.addEventListener("mouseup", () => { - updateOpenedModalSettingsId(item.id()); - }); - gearElement.addEventListener("touchstart", () => { - updateOpenedModalSettingsId(item.id()); - }); - - if ( - parentQuestion?.type === "date" || - parentQuestion?.type === "text" || - parentQuestion?.type === "number" || - parentQuestion?.type === "page" - ) { - gearElement.classList.add("popper-gear-none"); - } - - return gearElement; - }, - }); - } - const update = async () => { - await plusesPopper.update(); - await crossesPopper.update(); - await gearsPopper?.update(); - await layoutsPopper.update(); - }; - - const zoom = cy.zoom(); - - //update(); + const onZoom = (event: AbstractEventObject) => { + const zoom = event.cy.zoom(); crossesPopper.setOptions({ modifiers: [ @@ -279,7 +218,7 @@ export const usePopper = ({ plusesPopper.setOptions({ modifiers: [ { name: "flip", options: { boundary: node } }, - { name: "offset", options: { offset: [0, 0 * zoom] } }, + { name: "offset", options: { offset: [0, 0] } }, ], }); gearsPopper?.setOptions({ @@ -289,16 +228,16 @@ export const usePopper = ({ ], }); - layoutsContainer.current - ?.querySelectorAll("#popper-layouts > .popper-layout") + popperContainerRef.current + ?.querySelectorAll(".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") + popperContainerRef.current + ?.querySelectorAll(".popper-plus") .forEach((item) => { const element = item as HTMLDivElement; element.style.width = `${40 * zoom}px`; @@ -307,8 +246,8 @@ export const usePopper = ({ element.style.borderRadius = `${6 * zoom}px`; }); - crossesContainer.current - ?.querySelectorAll("#popper-crosses > .popper-cross") + popperContainerRef.current + ?.querySelectorAll(".popper-cross") .forEach((item) => { const element = item as HTMLDivElement; element.style.width = `${24 * zoom}px`; @@ -317,188 +256,18 @@ export const usePopper = ({ element.style.borderRadius = `${6 * zoom}px`; }); - gearsContainer?.current - ?.querySelectorAll("#popper-gears > .popper-gear") + popperContainerRef?.current + ?.querySelectorAll(".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("render", () => { - update(); - }); - }); - }; - - 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); + cy.on("zoom render", onZoom); }); + }, []); - 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, recreatePoppers }; }; diff --git a/src/pages/Questions/BranchingMap/hooks/useRemoveNode.ts b/src/pages/Questions/BranchingMap/hooks/useRemoveNode.ts index 254813e9..697ae87f 100644 --- a/src/pages/Questions/BranchingMap/hooks/useRemoveNode.ts +++ b/src/pages/Questions/BranchingMap/hooks/useRemoveNode.ts @@ -1,130 +1,34 @@ +import { devlog } from "@frontend/kitui"; +import { QuizQuestionResult } from "@model/questionTypes/result"; import { - deleteQuestion, - updateQuestion, - getQuestionByContentId, clearRuleForAll, - createResult, + 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 type { MutableRefObject } from "react"; +import { useCurrentQuiz } from "@root/quizes/hooks"; import type { - Core, CollectionReturnValue, - PresetLayoutOptions, + Core, + SingularElementArgument, } from "cytoscape"; -import type { - AnyTypedQuizQuestion, - QuestionBranchingRule, - QuestionBranchingRuleMain, -} from "../../../../model/questionTypes/shared"; +import type { MutableRefObject } from "react"; +import { clearDataAfterRemoveNode } from "../helper"; type UseRemoveNodeArgs = { cyRef: MutableRefObject; - layoutOptions: PresetLayoutOptions; - layoutsContainer: MutableRefObject; - plusesContainer: MutableRefObject; - crossesContainer: MutableRefObject; - gearsContainer: MutableRefObject; }; -export const useRemoveNode = ({ - cyRef, - layoutOptions, - layoutsContainer, - plusesContainer, - crossesContainer, - gearsContainer, -}: UseRemoveNodeArgs) => { +export const useRemoveNode = ({ cyRef }: UseRemoveNodeArgs) => { 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 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 parentQuestion = getQuestionByContentId(parentQuestionContentId); - if (parentQuestion.content.rule.children.length === 1) { - //если у родителя больше нет потомков - //Делаем результат родителя активным - 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); - } - } - - //чистим 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) => { + const deleteNodesRecursively = (node: CollectionReturnValue) => { //Узнаём грани, идущие от этой ноды cy ?.$('edge[source = "' + node.id() + '"]') @@ -132,20 +36,18 @@ export const useRemoveNode = ({ .forEach((edge) => { const edgeData = edge.data(); - //записываем id грани для дальнейшего удаления - deleteEdges.push(edge); //ищем ноду на конце грани, записываем её ID для дальнейшего удаления const targetNode = cy?.$("#" + edgeData.target); deleteNodes.push(targetNode.data().id); //вызываем функцию для анализа потомков уже у этой ноды - findChildrenToDelete(targetNode); + deleteNodesRecursively(targetNode); }); }; const elementToDelete = cy?.getElementById(targetNodeContentId); if (elementToDelete) { - findChildrenToDelete(elementToDelete); + deleteNodesRecursively(elementToDelete); } const targetQuestion = getQuestionByContentId(targetNodeContentId); @@ -155,7 +57,7 @@ export const useRemoveNode = ({ targetQuestion.content.rule.parentId === "root" && quiz ) { - updateRootContentId(quiz?.id, ""); + updateRootContentId(quiz.id, ""); updateQuestion(targetNodeContentId, (question) => { question.content.rule.parentId = ""; question.content.rule.main = []; @@ -173,16 +75,14 @@ export const useRemoveNode = ({ quiz && cy?.edges(`[source="${parentQuestionContentId}"]`).length === 0 ) { + devlog(parentQuestionContentId); //createFrontResult(quiz.backendId, parentQuestionContentId); } clearDataAfterRemoveNode({ + trashQuestions, targetQuestionContentId: targetNodeContentId, parentQuestionContentId, }); - cy - ?.remove(cy?.$("#" + targetNodeContentId)) - .layout(layoutOptions) - .run(); } } @@ -190,8 +90,6 @@ export const useRemoveNode = ({ deleteNodes.forEach((nodeId) => { //Ноды - cy?.remove(cy?.$("#" + nodeId)); - removeButtons(nodeId); updateQuestion(nodeId, (question) => { question.content.rule.parentId = ""; question.content.rule.main = []; @@ -200,15 +98,6 @@ export const useRemoveNode = ({ }); }); - deleteEdges.forEach((edge: any) => { - //Грани - cy?.remove(edge); - }); - - removeButtons(targetNodeContentId); - cy?.data("changed", true); - cy?.layout(layoutOptions).run(); - //делаем result всех потомков неактивными trashQuestions.forEach((qr) => { if ( @@ -217,7 +106,7 @@ export const useRemoveNode = ({ (targetQuestion?.type && qr.content.rule.parentId === targetQuestion.content.id)) ) { - updateQuestion(qr.content.id, (q) => { + updateQuestion(qr.content.id, (q) => { q.content.usage = false; }); } diff --git a/src/pages/Questions/BranchingMap/index.tsx b/src/pages/Questions/BranchingMap/index.tsx index 6e6c137b..2cd57941 100644 --- a/src/pages/Questions/BranchingMap/index.tsx +++ b/src/pages/Questions/BranchingMap/index.tsx @@ -1,20 +1,15 @@ 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(""); - const [modalQuestionTargetContentId, setModalQuestionTargetContentId] = - useState(""); - const [openedModalQuestions, setOpenedModalQuestions] = - useState(false); + const dragQuestionContentId = useUiTools( + (state) => state.dragQuestionContentId, + ); return ( { border: dragQuestionContentId === null ? "none" : "#7e2aea 2px dashed", }} > - {quiz?.config.haveRoot ? ( - - ) : ( - - )} - + {quiz?.config.haveRoot ? : } + ); }; diff --git a/src/pages/Questions/BranchingMap/style/styles.css b/src/pages/Questions/BranchingMap/style/styles.css index a8ed48ea..fe965139 100644 --- a/src/pages/Questions/BranchingMap/style/styles.css +++ b/src/pages/Questions/BranchingMap/style/styles.css @@ -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; diff --git a/src/pages/Questions/BranchingQuestionsModal/index.tsx b/src/pages/Questions/BranchingQuestionsModal/index.tsx index 8dee27c6..a8faf074 100644 --- a/src/pages/Questions/BranchingQuestionsModal/index.tsx +++ b/src/pages/Questions/BranchingQuestionsModal/index.tsx @@ -1,22 +1,20 @@ import { Box, Modal, Button, Typography } from "@mui/material"; import { useQuestionsStore } from "@root/questions/store"; import { AnyTypedQuizQuestion } from "@model/questionTypes/shared"; - -interface Props { - openedModalQuestions: boolean; - setModalQuestionTargetContentId: (contentId: string) => void; - setOpenedModalQuestions: (open: boolean) => void; -} - -export const BranchingQuestionsModal = ({ - openedModalQuestions, - setOpenedModalQuestions, +import { useUiTools } from "@root/uiTools/store"; +import { setModalQuestionTargetContentId, -}: Props) => { + setOpenedModalQuestions, +} from "@root/uiTools/actions"; + +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); diff --git a/src/stores/questions/actions.ts b/src/stores/questions/actions.ts index 084a5488..9b5976bf 100644 --- a/src/stores/questions/actions.ts +++ b/src/stores/questions/actions.ts @@ -26,6 +26,8 @@ import { useUiTools } from "../uiTools/store"; import { withErrorBoundary } from "react-error-boundary"; import { QuizQuestionResult } from "@model/questionTypes/result"; import { replaceEmptyLinesToSpace } from "../../utils/replaceEmptyLinesToSpace"; +import { useQuizPreviewStore } from "@root/quizPreview"; +import { useQuizStore } from "@root/quizes/store"; export const setQuestions = (questions: RawQuestion[] | null) => setProducedState( diff --git a/src/stores/uiTools/actions.ts b/src/stores/uiTools/actions.ts index 26a7743d..5d15d3dd 100644 --- a/src/stores/uiTools/actions.ts +++ b/src/stores/uiTools/actions.ts @@ -43,3 +43,12 @@ export const updateSomeWorkBackend = (someWorkBackend: boolean) => export const updateNextStep = (nextStep: number) => useUiTools.setState({ nextStep }); + +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 }); diff --git a/src/stores/uiTools/store.ts b/src/stores/uiTools/store.ts index d42e0e50..e0048e69 100644 --- a/src/stores/uiTools/store.ts +++ b/src/stores/uiTools/store.ts @@ -13,6 +13,9 @@ export type UiTools = { showConfirmLeaveModal: boolean; someWorkBackend: boolean; nextStep: number; + modalQuestionParentContentId: string; + modalQuestionTargetContentId: string; + openedModalQuestions: boolean; }; export type WhyCantCreatePublic = { @@ -32,6 +35,9 @@ const initialState: UiTools = { showConfirmLeaveModal: false, someWorkBackend: false, nextStep: -1, + modalQuestionParentContentId: "", + modalQuestionTargetContentId: "", + openedModalQuestions: false, }; export const useUiTools = create()(