import { useEffect, useLayoutEffect, useRef, useState } from "react"; import Cytoscape from "cytoscape"; import CytoscapeComponent from "react-cytoscapejs"; import popper from "cytoscape-popper"; import { useCurrentQuiz } from "@root/quizes/hooks"; import { updateRootContentId } from "@root/quizes/actions" import { AnyTypedQuizQuestion } from "@model/questionTypes/shared" import { useQuestionsStore } from "@root/questions/store"; import { cleardragQuestionContentId, updateQuestion, updateOpenedModalSettingsId, getQuestionByContentId } from "@root/questions/actions"; import { storeToNodes } from "./helper"; import "./styles.css"; import type { Stylesheet, Core, NodeSingular, AbstractEventObject, ElementDefinition, } from "cytoscape"; import { QuestionsList } from "../BranchingPanel/QuestionsList"; import { enqueueSnackbar } from "notistack"; type PopperItem = { id: () => string; }; type Modifier = { name: string; options: unknown; }; type PopperConfig = { popper: { placement: string; modifiers?: Modifier[]; }; content: (items: PopperItem[]) => void; }; type Popper = { update: () => Promise; setOptions: (modifiers: { modifiers?: Modifier[] }) => void; }; type NodeSingularWithPopper = NodeSingular & { popper: (config: PopperConfig) => Popper; }; const stylesheet: Stylesheet[] = [ { selector: "node", style: { shape: "round-rectangle", width: 130, height: 130, backgroundColor: "#FFFFFF", label: "data(label)", "font-size": "16", color: "#4D4D4D", "text-halign": "center", "text-valign": "center", "text-wrap": "wrap", "text-max-width": "80", }, }, { selector: ".multiline-auto", style: { "text-wrap": "wrap", "text-max-width": "80", }, }, { selector: "edge", style: { width: 30, "line-color": "#DEDFE7", "curve-style": "taxi", "taxi-direction": "horizontal", "taxi-turn": 60, }, }, { selector: ":selected", style: { "border-style": "solid", "border-width": 1.5, "border-color": "#9A9AAF", }, }, ]; Cytoscape.use(popper); interface Props { modalQuestionParentContentId: string; modalQuestionTargetContentId: string; setOpenedModalQuestions: (open: boolean) => void; setModalQuestionParentContentId: (id: string) => void; setModalQuestionTargetContentId: (id: string) => void; } export const CsComponent = ({ modalQuestionParentContentId, modalQuestionTargetContentId, setOpenedModalQuestions, setModalQuestionParentContentId, setModalQuestionTargetContentId }: Props) => { const quiz = useCurrentQuiz(); const { dragQuestionContentId, questions } = useQuestionsStore() const [startCreate, setStartCreate] = useState(""); const [startRemove, setStartRemove] = useState(""); const cyRef = useRef(null); const layoutsContainer = useRef(null); const plusesContainer = useRef(null); const crossesContainer = useRef(null); const gearsContainer = useRef(null); useLayoutEffect(() => { updateOpenedModalSettingsId() }, []) useEffect(() => { if (modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0) { addNode({ parentNodeContentId: modalQuestionParentContentId, targetNodeContentId: modalQuestionTargetContentId }) } setModalQuestionParentContentId("") setModalQuestionTargetContentId("") }, [modalQuestionTargetContentId]) const addNode = ({ parentNodeContentId, targetNodeContentId }: { parentNodeContentId: string, targetNodeContentId?: string }) => { const cy = cyRef?.current const parentNodeChildren = cy?.$('edge[source = "' + parentNodeContentId + '"]')?.length //если есть инфо о выбранном вопросе из модалки - берём родителя из инфо модалки. Иначе из значения дропа const targetQuestion = { ...getQuestionByContentId(targetNodeContentId || dragQuestionContentId) } as AnyTypedQuizQuestion if (Object.keys(targetQuestion).length !== 0 && Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) { clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren }) cy?.data('changed', true) cy?.add([ { data: { id: targetQuestion.content.id, label: targetQuestion.title || "noname" } }, { data: { source: parentNodeContentId, target: targetQuestion.content.id } } ]) cy?.layout(lyopts).run() } else { enqueueSnackbar("Добавляемый вопрос не найден") } } const clearDataAfterAddNode = ({ parentNodeContentId, targetQuestion, parentNodeChildren }: { parentNodeContentId: string, targetQuestion: AnyTypedQuizQuestion, parentNodeChildren: number }) => { //предупреждаем добавленный вопрос о том, кто его родитель updateQuestion(targetQuestion.content.id, question => { question.content.rule.parentId = parentNodeContentId question.content.rule.main = [] }) //Если детей больше 1 - предупреждаем стор вопросов об открытии модалки ветвления if (parentNodeChildren >= 1) { updateOpenedModalSettingsId(targetQuestion.content.id) } else { //Если ребёнок первый - добавляем его родителю как дефолтный updateQuestion(parentNodeContentId, question => question.content.rule.default = targetQuestion.content.id) } } const removeNode = ({ targetNodeContentId }: { targetNodeContentId: string }) => { const deleteNodes = [] as string[] const deleteEdges: any = [] const cy = cyRef?.current const findChildrenToDelete = (node) => { //Узнаём грани, идущие от этой ноды 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) }) } findChildrenToDelete(cy?.getElementById(targetNodeContentId)) const targetQuestion = getQuestionByContentId(targetNodeContentId) if (targetQuestion.content.rule.parentId === "root" && quiz) { updateRootContentId(quiz?.id, "") updateQuestion(targetNodeContentId, question => { question.content.rule.parentId = "" question.content.rule.main = [] question.content.rule.default = "" }) } else { const parentQuestionContentId = cy?.$('edge[target = "' + targetNodeContentId + '"]')?.toArray()?.[0]?.data()?.source if (targetNodeContentId && parentQuestionContentId) { clearDataAfterRemoveNode({ targetQuestionContentId: targetNodeContentId, parentQuestionContentId }) cy?.remove(cy?.$('#' + targetNodeContentId)).layout(lyopts).run() } } //После всех манипуляций удаляем грани из CS и ноды из бекенда deleteNodes.forEach((nodeId) => {//Ноды cy?.remove(cy?.$("#" + nodeId)) removeButtons(nodeId) updateQuestion(nodeId, question => { question.content.rule.parentId = "" question.content.rule.main = [] question.content.rule.default = "" }) }) deleteEdges.forEach((edge: any) => {//Грани cy?.remove(edge) }) removeButtons(targetNodeContentId) cy?.data('changed', true) cy?.layout(lyopts).run() } const clearDataAfterRemoveNode = ({ targetQuestionContentId, parentQuestionContentId }: { targetQuestionContentId: string, parentQuestionContentId: string }) => { updateQuestion(targetQuestionContentId, question => { question.content.rule.parentId = "" question.content.rule.main = [] question.content.rule.default = "" }) updateQuestion(parentQuestionContentId, question => { //Заменяем id удаляемого вопроса либо на id одного из оставшихся, либо на пустую строку if (question.content.rule.default === targetQuestionContentId) question.content.rule.default = "" }) } useEffect(() => { if (startCreate) { addNode({ parentNodeContentId: startCreate }); cleardragQuestionContentId() setStartCreate(""); } }, [startCreate]); useEffect(() => { if (startRemove) { removeNode({ targetNodeContentId: startRemove }); setStartRemove(""); } }, [startRemove]); const readyLO = (e) => { if (e.cy.data('firstNode') === 'nonroot') { e.cy.data('firstNode', 'root') e.cy.nodes().sort((a, b) => (a.data('root') ? 1 : -1)).layout(lyopts).run() } else { e.cy.data('changed', false) e.cy.removeData('firstNode') } //удаляем иконки e.cy.nodes().forEach((ele: any) => { const data = ele.data() data.id && removeButtons(data.id); }) initialPopperIcons(e) } const lyopts = { name: 'preset', positions: (e) => { if (!e.cy().data('changed')) { return e.data('oldPos') } else { e.removeData('oldPos') } const id = e.id() const incomming = e.cy().edges(`[target="${id}"]`) const layer = 0 e.removeData('lastChild') if (incomming.length === 0) { if (e.cy().data('firstNode') === undefined) e.cy().data('firstNode', 'root') e.data('root', true) const children = e.cy().edges(`[source="${id}"]`).targets() e.data('layer', layer) e.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 = e.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: e, children: children }) while (queue.length) { const task = queue.pop() if (task.children.length === 0) { task.parent.data('subtreeWidth', task.parent.height()) continue } const unprocessed = task?.children.filter(e => { return (e.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 } e.data('oldPos', pos) return pos } else { if (e.cy().data('firstNode') !== 'root') { e.cy().data('firstNode', 'nonroot') return { x: 0, y: 0 } } if (e.cy().data('firstNode') === undefined) e.cy().data('firstNode', 'nonroot') const parent = e.cy().edges(`[target="${e.id()}"]`)[0].source() const wing = (parent.data('children') === 1) ? 0 : parent.data('subtreeWidth') / 2 + 50 const lastOffset = parent.data('lastChild') const step = wing * 2 / (parent.data('children') - 1) //e.removeData('subtreeWidth') if (lastOffset !== undefined) { parent.data('lastChild', lastOffset + step) const pos = { x: 250 * e.data('layer'), y: (lastOffset + step) } e.data('oldPos', pos) return pos } else { parent.data('lastChild', parent.position().y - wing) const pos = { x: 250 * e.data('layer'), y: (parent.position().y - wing) } e.data('oldPos', pos) return pos } } }, // 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: true, // 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 true; }, // 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 } useEffect(() => { document.querySelector("#root")?.addEventListener("mouseup", cleardragQuestionContentId); const cy = cyRef.current; const eles = cy?.add(storeToNodes(questions)) cy.data('changed', true) const elecs = eles.layout(lyopts).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(); }; }, []); 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 initialPopperIcons = (e) => { const cy = e.cy const container = (document.body.querySelector( ".__________cytoscape_container" ) as HTMLDivElement) || null; if (!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); } 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 }) nodesInView .toArray() ?.forEach((item) => { const node = item as NodeSingularWithPopper; 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; } 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); return layoutElement; }, }); 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()); }); 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()) } ); return crossElement; }, }); const gearsPopper = node.popper({ popper: { placement: "left", modifiers: [{ name: "flip", options: { boundary: node } }], }, content: ([item]) => { const itemId = item.id(); if (item.cy().edges(`[target="${itemId}"]`).sources().length === 0) { return; } 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", (e) => { updateOpenedModalSettingsId(item.id()) }); return gearElement; }, }); 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] } }, ], }); 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); cy?.on("pan zoom resize render", onZoom); }); }; return ( <> { cyRef.current = cy; }} /> {/* */} ); };