diff --git a/app/package.json b/app/package.json index dddc8fc6..e6e227a7 100644 --- a/app/package.json +++ b/app/package.json @@ -81,7 +81,7 @@ "eslint": "^8.3.0", "file-saver": "^2.0.5", "framer-motion": "^4.1.17", - "graph-selector": "^0.8.6", + "graph-selector": "^0.9.1", "gray-matter": "^4.0.2", "highlight.js": "^11.7.0", "immer": "^9.0.16", diff --git a/app/src/components/Graph.tsx b/app/src/components/Graph.tsx index 666a4bf9..c4bd40b7 100644 --- a/app/src/components/Graph.tsx +++ b/app/src/components/Graph.tsx @@ -3,6 +3,7 @@ import coseBilkent from "cytoscape-cose-bilkent"; import dagre from "cytoscape-dagre"; import klay from "cytoscape-klay"; import cytoscapeSvg from "cytoscape-svg"; +import { operate } from "graph-selector"; import throttle from "lodash.throttle"; import React, { memo, @@ -19,7 +20,11 @@ import { useDebouncedCallback } from "use-debounce"; import { buildStylesForGraph } from "../lib/buildStylesForGraph"; import { cytoscape } from "../lib/cytoscape"; import { getGetSize, TGetSize } from "../lib/getGetSize"; -import { getLayout } from "../lib/getLayout"; +import { + defaultLayout, + getLayout, + validLayoutsForFixedNodes, +} from "../lib/getLayout"; import { getUserStyle } from "../lib/getUserStyle"; import { DEFAULT_GRAPH_PADDING } from "../lib/graphOptions"; import { @@ -35,6 +40,7 @@ import { useContextMenuState } from "../lib/useContextMenuState"; import { Doc, useDoc, useParseError } from "../lib/useDoc"; import { useGraphStore } from "../lib/useGraphStore"; import { useHoverLine } from "../lib/useHoverLine"; +import { getIsFrozen } from "../lib/useIsFrozen"; import { Box } from "../slang"; import { getNodePositionsFromCy } from "./getNodePositionsFromCy"; import styles from "./Graph.module.css"; @@ -154,15 +160,42 @@ const Graph = memo(function Graph({ shouldResize }: { shouldResize: number }) { export default Graph; -function handleDragFree() { - const nodePositions = getNodePositionsFromCy(); +function handleDragFree(event: cytoscape.EventObject) { + const { target } = event; + const position = target.position() as { x: number; y: number }; + const lineNumber = target.data("lineNumber"); + const id = target.id(); + const text = useDoc.getState().text; + const isFrozen = getIsFrozen(); + + // get the current layout name + const layoutName = useGraphStore.getState().layout.name ?? ""; + + // change layout if it's not valid with fixed nodes + if (!validLayoutsForFixedNodes.includes(layoutName)) return; + + let newText = text; + + // only add fixed class if everything isn't frozen + if (!isFrozen) { + newText = operate(text, { + lineNumber, + operation: ["addClassesToNode", { classNames: ["fixed"] }], + }); + } + + // update x and y in meta useDoc.setState( (state) => { return { ...state, + text: newText, meta: { ...state.meta, - nodePositions, + nodePositions: { + ...(state.meta?.nodePositions ?? {}), + [id]: { x: round(position.x), y: round(position.y) }, + }, }, }; }, @@ -171,6 +204,13 @@ function handleDragFree() { ); } +/** + * This function is used to round numbers to 2 decimal places + */ +function round(num: number) { + return Math.round(num * 100) / 100; +} + /** * This function sets up cytoscape and initializes the graph * but it doesn't set @@ -197,6 +237,10 @@ function useInitializeGraph({ wheelSensitivity: 0.2, boxSelectionEnabled: true, // autoungrabify: true, + // DEFAULT LAYOUT MUST BE PRESET TO SUPPORT "FIXED" NODES + layout: { + name: "preset", + }, }); window.__cy = cy.current; const cyCurrent = cy.current; @@ -332,14 +376,26 @@ function getGraphUpdater({ isGraphInitialized.current && elements.length < 200 && isAnimationEnabled; - cy.current + cy.current.elements; + + // If not using a layout which supports individually frozen + // nodes then run the layout on all nodes + const selection = validLayoutsForFixedNodes.includes(layout.name) + ? cy.current.elements("*").difference(".fixed") + : cy.current; + + selection .layout({ animate: shouldAnimate, animationDuration: shouldAnimate ? 333 : 0, ...layout, padding: DEFAULT_GRAPH_PADDING, + fit: false, }) - .run(); + .run() + .listen("layoutstop", () => { + cy.current?.fit(undefined, DEFAULT_GRAPH_PADDING); + }); // Reinitialize to avoid missing errors cyErrorCatcher.current.destroy(); diff --git a/app/src/components/GraphFloatingMenu.tsx b/app/src/components/GraphFloatingMenu.tsx index 7b6811e1..db65b43f 100644 --- a/app/src/components/GraphFloatingMenu.tsx +++ b/app/src/components/GraphFloatingMenu.tsx @@ -5,7 +5,7 @@ import { FaBomb, FaRegSnowflake } from "react-icons/fa"; import { MdFitScreen } from "react-icons/md"; import { DEFAULT_GRAPH_PADDING } from "../lib/graphOptions"; -import { unfreezeDoc, useIsFrozen } from "../lib/useIsFrozen"; +import { toggleDocFrozen, useIsFrozen } from "../lib/useIsFrozen"; import { useUnmountStore } from "../lib/useUnmountStore"; import { Tooltip } from "./Shared"; @@ -58,13 +58,12 @@ export function GraphFloatingMenu() { }); }} /> - {isFrozen ? ( - } - label={t`Unfreeze`} - onClick={unfreezeDoc} - /> - ) : null} + } + label={isFrozen ? t`Unfreeze` : t`Freeze`} + onClick={toggleDocFrozen} + data-state-active={isFrozen ? true : false} + /> ); } @@ -82,7 +81,9 @@ function CustomIconButton({ icon, label, ...props }: CustomIconButtonProps) { return ( diff --git a/app/src/components/Tabs/EditLayoutTab.tsx b/app/src/components/Tabs/EditLayoutTab.tsx index 28b24d10..426ede07 100644 --- a/app/src/components/Tabs/EditLayoutTab.tsx +++ b/app/src/components/Tabs/EditLayoutTab.tsx @@ -1,5 +1,6 @@ import { t, Trans } from "@lingui/macro"; import produce from "immer"; +import { PushPin } from "phosphor-react"; import { FaRegSnowflake } from "react-icons/fa"; import { GraphOptionsObject } from "../../lib/constants"; @@ -8,7 +9,11 @@ import { directions, layouts } from "../../lib/graphOptions"; import { hasOwnProperty } from "../../lib/helpers"; import { useIsValidSponsor } from "../../lib/hooks"; import { useDoc } from "../../lib/useDoc"; -import { unfreezeDoc, useIsFrozen } from "../../lib/useIsFrozen"; +import { + toggleDocFrozen, + useHasFixedNodes, + useIsFrozen, +} from "../../lib/useIsFrozen"; import styles from "./EditLayoutTab.module.css"; import { CustomSelect, @@ -41,6 +46,7 @@ export function EditLayoutTab() { layouts.find((l) => l.value === layoutName)?.label() ?? "???"; const isFrozen = useIsFrozen(); + const hasFixedNodes = useHasFixedNodes(); let direction = layout?.["rankDir"] ?? graphLayout.rankDir; @@ -181,6 +187,7 @@ export function EditLayoutTab() { )} + {hasFixedNodes && } {!isValidSponsor && ( @@ -200,9 +207,40 @@ function FrozenLayout() { - + Unfreeze ); } + +function FixedNodesWarning() { + return ( + + + + + Contains Fixed Nodes + + + Your graph contains nodes with class fixed. Fixed nodes only + work correctly when using basic, deterministic layouts.{" "} + { + useDoc.setState((state) => { + return { + ...state, + text: state.text.replace(/\.fixed\b/g, ""), + }; + }); + }} + > + Remove fixed class from all nodes + + . + + + + ); +} diff --git a/app/src/lib/getLayout.test.ts b/app/src/lib/getLayout.test.ts index aebacf82..e40b56e9 100644 --- a/app/src/lib/getLayout.test.ts +++ b/app/src/lib/getLayout.test.ts @@ -7,7 +7,6 @@ describe("getLayout", () => { const layout = getLayout(doc); expect(layout).toEqual({ name: "dagre", - fit: true, animate: true, spacingFactor: 1.25, rankDir: "TB", @@ -30,12 +29,13 @@ describe("getLayout", () => { expect(layout.elk).toEqual({ algorithm: "mrtree" }); }); - test("moves nodePositions into positions and makes layout 'preset'", () => { + test("makes layout 'preset' if isFrozen", () => { const doc = { ...initialDoc, meta: { layout: { name: "random" }, nodePositions: { a: { x: 1, y: 2 } }, + isFrozen: true, }, }; const layout = getLayout(doc); diff --git a/app/src/lib/getLayout.ts b/app/src/lib/getLayout.ts index bb9c5d28..f5e38ce4 100644 --- a/app/src/lib/getLayout.ts +++ b/app/src/lib/getLayout.ts @@ -2,7 +2,7 @@ import { Doc } from "./useDoc"; export const defaultLayout: any = { name: "dagre", - fit: true, + // fit: true, animate: true, spacingFactor: 1.25, }; @@ -58,12 +58,16 @@ export function getLayout(doc: Doc) { ...layout, }; - // Apply the preset layout if nodePositions is defined - if (meta?.nodePositions && typeof meta.nodePositions === "object") { - layoutToReturn.positions = { ...meta.nodePositions }; + // if isFrozen, change to preset layout + if (meta.isFrozen) { layoutToReturn.name = "preset"; } + // Forward nodePositions onto layout + if (meta.nodePositions && typeof meta.nodePositions === "object") { + layoutToReturn.positions = { ...meta.nodePositions }; + } + // Remove spacingFactor if using preset layout if (layoutToReturn.name === "preset" && layoutToReturn.spacingFactor) { delete layoutToReturn.spacingFactor; @@ -71,3 +75,17 @@ export function getLayout(doc: Doc) { return layoutToReturn; } + +/** + * Not all auto-layouts work when individual nodes are frozen + * + * Store the list of layout names that are valid with partially frozen nodes */ +export const validLayoutsForFixedNodes = [ + "dagre", + "klay", + "breadthfirst", + "concentric", + "circle", + "grid", + "preset", +]; diff --git a/app/src/lib/graphOptions.ts b/app/src/lib/graphOptions.ts index ca53a301..4360c292 100644 --- a/app/src/lib/graphOptions.ts +++ b/app/src/lib/graphOptions.ts @@ -16,14 +16,13 @@ export const layouts: SelectOption[] = [ { label: () => `Dagre`, value: "dagre" }, { label: () => `Klay`, value: "klay" }, { label: () => t`Breadthfirst`, value: "breadthfirst" }, - { label: () => `CoSE`, value: "cose" }, { label: () => t`Concentric`, value: "concentric" }, { label: () => t`Circle`, value: "circle" }, - { label: () => t`Random`, value: "random" }, { label: () => t`Grid`, value: "grid" }, + // Non-deterministic layouts + { label: () => `CoSE`, value: "cose" }, // Elk layouts { label: () => "Box", value: "elk-box", sponsorOnly: true }, - { label: () => "Force", value: "elk-force", sponsorOnly: true }, { label: () => "Layered", value: "elk-layered", sponsorOnly: true }, { label: () => "Tree", value: "elk-mrtree", sponsorOnly: true }, { label: () => "Stress", value: "elk-stress", sponsorOnly: true }, diff --git a/app/src/lib/parsers.ts b/app/src/lib/parsers.ts index 1470c945..cf2de608 100644 --- a/app/src/lib/parsers.ts +++ b/app/src/lib/parsers.ts @@ -32,7 +32,9 @@ export function universalParse( getSize: TGetSize ): ElementDefinition[] { switch (parser) { - case "graph-selector": + case "graph-selector": { + const nodePositions = (useDoc.getState().meta?.nodePositions ?? + {}) as Record; return toCytoscapeElements(parse(text)).map((element) => { let size: Record = {}; if ("w" in element.data || "h" in element.data) { @@ -47,14 +49,26 @@ export function universalParse( ); } - return { + let node = { ...element, data: { ...element.data, ...size, }, }; + + // if class "fixed" and x & y are set, add position to node + const id = element.data.id; + if (id && element.classes?.includes("fixed") && nodePositions[id]) { + node = { + ...node, + position: nodePositions[id], + }; + } + + return node; }); + } case "v1": return parseText(stripComments(text), getSize); default: diff --git a/app/src/lib/useIsFrozen.ts b/app/src/lib/useIsFrozen.ts index e3ccd2d5..0d9b2719 100644 --- a/app/src/lib/useIsFrozen.ts +++ b/app/src/lib/useIsFrozen.ts @@ -1,13 +1,20 @@ import produce from "immer"; -import { getLayout } from "./getLayout"; +import { getNodePositionsFromCy } from "../components/getNodePositionsFromCy"; import { useDoc } from "./useDoc"; +import { useGraphStore } from "./useGraphStore"; -export function unfreezeDoc() { +export function toggleDocFrozen() { useDoc.setState( (state) => { return produce(state, (draft) => { - delete draft.meta.nodePositions; + if (draft.meta.isFrozen) { + delete draft.meta.isFrozen; + } else { + draft.meta.isFrozen = true; + // get node positions + draft.meta.nodePositions = getNodePositionsFromCy(); + } }); }, false, @@ -15,10 +22,22 @@ export function unfreezeDoc() { ); } +/** + * Whether the graph is fully frozen + */ export function useIsFrozen() { - const doc = useDoc(); - const rendered = getLayout(doc); - const frozen = "positions" in rendered; + return useDoc((state) => state.meta?.isFrozen ?? false); +} + +export function getIsFrozen() { + return useDoc.getState().meta?.isFrozen ?? false; +} - return frozen; +/** + * Whether the graph has individually-fixed nodes in it + */ +export function useHasFixedNodes() { + const elements = useGraphStore((state) => state.elements); + // check if any have the class fixed + return elements.some((el) => el.classes?.includes("fixed")); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60027057..d0fed503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,8 +249,8 @@ importers: specifier: ^4.1.17 version: 4.1.17(react-dom@17.0.2)(react@17.0.2) graph-selector: - specifier: ^0.8.6 - version: 0.8.6 + specifier: ^0.9.1 + version: 0.9.1 gray-matter: specifier: ^4.0.2 version: 4.0.3 @@ -9296,8 +9296,8 @@ packages: '@tone-row/strip-comments': 2.0.6 dev: false - /graph-selector@0.8.6: - resolution: {integrity: sha512-w8QSWtA/HNv6kVzdZaOO4A/zsRbKzBRl22Kc3ke4S8NlI+HEVF32wfncboNHr+/tB+NTObrvQh4GIke1pqVjAg==} + /graph-selector@0.9.1: + resolution: {integrity: sha512-ylfAyrSNLOx/qPqaoC+SKaywfN0a6NdwwFkyBnitfMcdOodfSytfDj343WgdbA2T/KQWr3+/gSXwL4mHLLU2Hg==} dependencies: '@tone-row/strip-comments': 2.0.6 html-entities: 2.3.3
+ Your graph contains nodes with class fixed. Fixed nodes only + work correctly when using basic, deterministic layouts.{" "} + { + useDoc.setState((state) => { + return { + ...state, + text: state.text.replace(/\.fixed\b/g, ""), + }; + }); + }} + > + Remove fixed class from all nodes + + . +