diff --git a/CHANGELOG.md b/CHANGELOG.md index 144187220..e3ddcfa34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - implemented support for `intent` states in code editor - `Label` component - added `additionalElements` property to display elements at the end of the label +- `` + - `resizeDirections` to specifiy the axis that can be used to resize the node + - `resizeMaxDimensions` to add maximum values for resizing height/width ## [24.0.1] - 2025-02-06 diff --git a/package.json b/package.json index 7aa2554aa..3abae77c1 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "jshint": "^2.13.6", "lodash": "^4.17.21", "n3": "^1.23.1", - "re-resizable": "^6.10.1", + "re-resizable": "^6.10.3", "react": "^16.13.1", "react-dom": "^16.13.1", "react-flow-renderer": "9.7.4", diff --git a/src/components/Menu/menu.scss b/src/components/Menu/menu.scss index dff9f4961..431bc386e 100644 --- a/src/components/Menu/menu.scss +++ b/src/components/Menu/menu.scss @@ -48,6 +48,7 @@ $menu-background-color: transparent !default; @import "~@blueprintjs/core/src/components/menu/menu"; .#{$ns}-menu { + min-width: auto; padding: 0; .#{$ns}-popover2-content > & { diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index 3a040b06f..727a78c6d 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -1,7 +1,6 @@ import React, { useMemo, useRef } from "react"; import { defaultKeymap, indentWithTab } from "@codemirror/commands"; import { foldKeymap } from "@codemirror/language"; -import { lintGutter } from "@codemirror/lint"; import { EditorState, Extension } from "@codemirror/state"; import { DOMEventHandlers, EditorView, KeyBinding, keymap, Rect, ViewUpdate } from "@codemirror/view"; import { minimalSetup } from "codemirror"; @@ -29,6 +28,7 @@ import { adaptedHighlightSpecialChars, adaptedLineNumbers, adaptedPlaceholder, + adaptedLintGutter, } from "./tests/codemirrorTestHelper"; import { ExtensionCreator } from "./types"; @@ -212,7 +212,7 @@ export const CodeEditor = ({ return []; } - const values = [lintGutter()]; + const values = [adaptedLintGutter()]; const linters = ModeLinterMap.get(mode); if (linters) { @@ -320,24 +320,26 @@ export const CodeEditor = ({ parent: parent.current, }); - if (height) { - view.dom.style.height = typeof height === "string" ? height : `${height}px`; - } + if (view?.dom) { + if (height) { + view.dom.style.height = typeof height === "string" ? height : `${height}px`; + } - if (disabled) { - view.dom.className += ` ${eccgui}-disabled`; - } + if (disabled) { + view.dom.className += ` ${eccgui}-disabled`; + } - if (intent) { - view.dom.className += ` ${eccgui}-intent--${intent}`; - } + if (intent) { + view.dom.className += ` ${eccgui}-intent--${intent}`; + } - if (autoFocus) { - view.focus(); - } + if (autoFocus) { + view.focus(); + } - if (setEditorView) { - setEditorView(view); + if (setEditorView) { + setEditorView(view); + } } return () => { diff --git a/src/extensions/codemirror/tests/codemirrorTestHelper.ts b/src/extensions/codemirror/tests/codemirrorTestHelper.ts index f7a38162c..8d1c35e26 100644 --- a/src/extensions/codemirror/tests/codemirrorTestHelper.ts +++ b/src/extensions/codemirror/tests/codemirrorTestHelper.ts @@ -10,6 +10,7 @@ import { EditorView, placeholder, highlightSpecialChars, lineNumbers, highlightActiveLine } from "@codemirror/view"; import { syntaxHighlighting, foldGutter, codeFolding } from "@codemirror/language"; import { Extension } from "@codemirror/state"; +import { lintGutter } from "@codemirror/lint"; /** placeholder extension, current error '_view.placeholder is not a function' */ export const adaptedPlaceholder = (text?: string) => @@ -55,3 +56,6 @@ export const adaptedFoldGutter = (props?: any) => export const adaptedCodeFolding = (props?: any) => typeof codeFolding === "function" ? codeFolding(props) : emptyExtension; + +export const adaptedLintGutter = (props?: any) => + typeof lintGutter === "function" ? lintGutter(props) : emptyExtension; diff --git a/src/extensions/react-flow/_config.scss b/src/extensions/react-flow/_config.scss index ad836e86d..d5855aaf4 100644 --- a/src/extensions/react-flow/_config.scss +++ b/src/extensions/react-flow/_config.scss @@ -15,3 +15,4 @@ $reactflow-edge-stroke-color-selected: $eccgui-color-accent !default; $reactflow-transition-time: 0.25s !default; $reactflow-transition-function: "" !default; $reactflow-transition-anglestart: -90deg; +$reactflow-cursor-delimiter-offset: 0.9 * $eccgui-size-block-whitespace; diff --git a/src/extensions/react-flow/nodes/NodeContent.tsx b/src/extensions/react-flow/nodes/NodeContent.tsx index b072f51d2..99add1745 100644 --- a/src/extensions/react-flow/nodes/NodeContent.tsx +++ b/src/extensions/react-flow/nodes/NodeContent.tsx @@ -23,10 +23,15 @@ type NodeContentHandleNextProps = HandleNextProps; export type NodeContentHandleProps = NodeContentHandleLegacyProps | NodeContentHandleNextProps; type NodeDimensions = { - width: number; - height: number; + width?: number; + height?: number; }; +type ResizeDirections = + | { right: true; bottom?: false } + | { right?: false; bottom: true } + | { right: true; bottom: true }; + type IntroductionTime = { /** * The delay time in ms before the introduction animation is displayed. @@ -210,6 +215,10 @@ export interface NodeContentProps * width and height dimensions of the node (Optional) */ nodeDimensions?: NodeDimensions; + /** if node is resizable, this allows direction of specificity */ + resizeDirections?: ResizeDirections; + /** determines how much width a node can be resized to */ + resizeMaxDimensions?: Partial; } interface MemoHandlerLegacyProps extends HandleProps { @@ -317,20 +326,24 @@ export function NodeContent({ //handles = defaultHandles(), adaptHeightForHandleMinCount, adaptSizeIncrement = 15, + // FIXME: getMinimalTooltipData is just being ignored, only used in `NodeDefault` getMinimalTooltipData = getDefaultMinimalTooltipData, style = {}, showUnconnectableHandles = false, animated = false, introductionTime = 0, + // resizing onNodeResize, nodeDimensions, + resizeDirections = { bottom: true, right: true }, + resizeMaxDimensions, // forwarded props targetPosition = Position.Left, sourcePosition = Position.Right, isConnectable = true, selected, letPassWheelEvents = false, - // businessData is just being ignored + // FIXME: businessData is just being ignored businessData, // other props for DOM element ...otherDomProps @@ -341,11 +354,16 @@ export function NodeContent({ const { handles = defaultHandles(flowVersionCheck), ...otherProps } = otherDomProps; - const isResizeable = !!onNodeResize && minimalShape === "none"; - const [width, setWidth] = React.useState(nodeDimensions?.width ?? 0); - const [height, setHeight] = React.useState(nodeDimensions?.height ?? 0); + const hasValidResizeDirection = resizeDirections.bottom || resizeDirections.right; + const isResizable = typeof onNodeResize === "function" && hasValidResizeDirection && minimalShape === "none"; + + const [width, setWidth] = React.useState(nodeDimensions?.width ?? undefined); + const [height, setHeight] = React.useState(nodeDimensions?.height ?? undefined); + // Keeps the initial size of the element + const originalSize = React.useRef({}) + let zoom = 1; - if (isResizeable) + if (isResizable) try { [, , zoom] = flowVersionCheck === "legacy" @@ -371,29 +389,71 @@ export function NodeContent({ handleStack[Position.Left] = flowVersionCheck === "legacy" ? ([] as NodeContentHandleLegacyProps[]) : ([] as NodeContentHandleNextProps[]); - // initial dimension before resize + const saveOriginalSize = () => { + originalSize.current.width = nodeContentRef.current.offsetWidth as number; + originalSize.current.height = nodeContentRef.current.offsetHeight as number; + } + React.useEffect(() => { - if (!!onNodeResize && minimalShape === "none") { - if (!nodeDimensions) { - setWidth(nodeContentRef.current.offsetWidth); - setHeight(nodeContentRef.current.offsetHeight); - onNodeResize({ - height: nodeContentRef.current.offsetHeight, - width: nodeContentRef.current.offsetWidth, - }); - } - nodeContentRef.current.className = nodeContentRef.current.className + " is-resizeable"; + if(nodeContentRef.current && !(originalSize.current.width || originalSize.current.height)) { + saveOriginalSize(); } - }, [nodeContentRef, onNodeResize, minimalShape, nodeDimensions]); + }, [!!nodeContentRef.current, !(originalSize.current.width || originalSize.current.height)]) - // update node dimensions when resized + // Update width and height when node dimensions parameters has changed React.useEffect(() => { - if (nodeDimensions) { - setWidth(nodeDimensions.width); - setHeight(nodeDimensions.height); + const updateWidth = nodeDimensions?.width ? validateWidth(nodeDimensions?.width) : undefined; + const updateHeight = nodeDimensions?.height ? validateHeight(nodeDimensions?.height) : undefined; + setWidth(updateWidth); + setHeight(updateHeight); + if (!nodeDimensions?.width && !nodeDimensions?.height) { + // provoke new measuring if no dimensions are set + saveOriginalSize(); } }, [nodeDimensions]); + const isResizingActive = React.useCallback((): boolean => { + const currentClassNames = nodeContentRef.current.classList; + return resizeDirections.right === currentClassNames.contains("is-resizable-horizontal") && + resizeDirections.bottom === currentClassNames.contains("is-resizable-vertical"); + }, []) + + // force default size when resizing is activated but no dimensions are set + React.useEffect(() => { + const resizingActive = isResizingActive(); + + if (isResizable && !resizingActive) { + if (!width || !height) { + const newWidth = validateWidth(width ?? originalSize.current?.width as number); + const newHeight = validateHeight(height ?? originalSize.current?.height as number); + setWidth(newWidth); + setHeight(newHeight); + } + } + }, [nodeContentRef.current, onNodeResize, minimalShape, resizeDirections?.bottom, resizeDirections?.right, width, height]); // need to be done everytime a property is changed and the element is re-rendered, otherwise the resizing class is lost + + // conditional enhancements for activated resizing + React.useEffect(() => { + const currentClassNames = nodeContentRef.current.classList; + const resizingActive = isResizingActive(); + + if (isResizable && !resizingActive) { + if (currentClassNames.contains("is-resizable-horizontal")) { + nodeContentRef.current.classList.remove("is-resizable-horizontal"); + } + if (currentClassNames.contains("is-resizable-vertical")) { + nodeContentRef.current.classList.remove("is-resizable-vertical"); + } + + if (resizeDirections.right) { + nodeContentRef.current.classList.add("is-resizable-horizontal"); + } + if (resizeDirections.bottom) { + nodeContentRef.current.classList.add("is-resizable-vertical"); + } + } + }); // need to be done everytime a property is changed and the element is re-rendered, otherwise the resizing class is lost + // remove introduction class React.useEffect(() => { if (nodeContentRef && introductionTime) { @@ -460,7 +520,14 @@ export function NodeContent({ ); const resizableStyles = - !!onNodeResize === true && minimalShape === "none" && width + height > 0 ? { width, height } : {}; + isResizable && (width ?? 0) + (height ?? 0) > 0 + ? { + width, + height, + maxWidth: resizeMaxDimensions?.width ?? undefined, + maxHeight: resizeMaxDimensions?.height ?? undefined, + } + : {}; const introductionStyles = introductionTime && !introductionDone @@ -470,6 +537,7 @@ export function NodeContent({ }ms`, } as React.CSSProperties) : {}; + const nodeContent = ( <>
({ ); - const resizableNode = () => ( - { - if (nodeContentRef.current) { - nodeContentRef.current.style.width = width + d.width + "px"; - nodeContentRef.current.style.height = height + d.height + "px"; + const validateWidth = (resizedWidth: number): number | undefined => { + // only allow value if resize direction is allowed + if (!resizeDirections.right) { + return undefined; + } + // we need to check because there is probably a min value defined via CSS + const min = parseFloat(getComputedStyle(nodeContentRef.current).getPropertyValue("min-width")); + // we need to check for a given max value + const max = resizeMaxDimensions?.width ?? Infinity; + const validatedWidth = Math.max(Math.min(resizedWidth, max), min); + return validatedWidth; + }; + + const validateHeight = (resizedHeight: number): number | undefined => { + if (!resizeDirections.bottom) { + return undefined; + } + // we need to check because there is probably a min value defined via CSS + const min = parseFloat(getComputedStyle(nodeContentRef.current).getPropertyValue("min-height")); + const max = resizeMaxDimensions?.height ?? Infinity; + const validatedHeight = Math.max(Math.min(resizedHeight, max), min); + return validatedHeight; + }; + + const resizableNode = () => { + const size = { height: height ?? "auto", width: width ?? "auto" }; + return ( + { - setWidth(width + d.width); - setHeight(height + d.height); - onNodeResize && - onNodeResize({ - height: height + d.height, - width: width + d.width, - }); - }} - > - {nodeContent} - - ); + handleWrapperClass={`${eccgui}-graphviz__node__resizer--cursorhandles` + " nodrag"} + size={size} + maxHeight={resizeMaxDimensions?.height ?? undefined} + maxWidth={resizeMaxDimensions?.width ?? undefined} + enable={resizeDirections.bottom && resizeDirections.right ? { bottomRight: true } : resizeDirections} + scale={zoom} + onResize={(_0, _1, _2, d) => { + if (nodeContentRef.current) { + const nextWidth = resizeDirections.right + ? (width ?? originalSize.current.width ?? 0) + d.width + : undefined; + const nextHeight = resizeDirections.bottom + ? (height ?? originalSize.current.height ?? 0) + d.height + : undefined; + if (nextWidth) { + nodeContentRef.current.style.width = `${nextWidth}px`; + } + if (nextHeight) { + nodeContentRef.current.style.height = `${nextHeight}px`; + } + } + }} + onResizeStop={(_0, _1, _2, d) => { + const nextWidth = validateWidth((width ?? originalSize.current.width ?? 0) + d.width); + const nextHeight = validateHeight((height ?? originalSize.current.height ?? 0) + d.height); + setWidth(nextWidth); + setHeight(nextHeight); + if (onNodeResize) { + onNodeResize({ + height: nextHeight, + width: nextWidth, + }); + } + }} + > + {nodeContent} + + ); + }; - return isResizeable ? resizableNode() : nodeContent; + return isResizable ? resizableNode() : nodeContent; } const evaluateHighlightColors = ( @@ -644,7 +758,7 @@ const evaluateHighlightColors = ( let customColor = Color("#ffffff"); try { customColor = Color(color); - } catch (ex) { + } catch { // eslint-disable-next-line no-console console.warn("Received invalid color for highlight: " + color); } diff --git a/src/extensions/react-flow/nodes/_nodes.scss b/src/extensions/react-flow/nodes/_nodes.scss index f7207f958..f87bb701b 100644 --- a/src/extensions/react-flow/nodes/_nodes.scss +++ b/src/extensions/react-flow/nodes/_nodes.scss @@ -49,6 +49,38 @@ &:hover { box-shadow: 0 0 0 6 * $reactflow-node-border-width rgba($reactflow-edge-stroke-color-selected, 0.05); } + + &.is-resizable-vertical { + max-height: unset; + } +} + +.#{$eccgui}-graphviz__node--tiny { + width: $reactflow-node-basesize * 4; + min-height: $reactflow-node-basesize; + max-height: $reactflow-node-basesize * 4; +} + +.#{$eccgui}-graphviz__node--small { + width: $reactflow-node-basesize * 5; + min-height: $reactflow-node-basesize; + max-height: $reactflow-node-basesize * 8; +} + +.#{$eccgui}-graphviz__node--medium { + width: $reactflow-node-basesize * 8; + min-height: $reactflow-node-basesize; + max-height: $reactflow-node-basesize * 13; +} + +.#{$eccgui}-graphviz__node--large { + width: $reactflow-node-basesize * 13; + min-height: $reactflow-node-basesize; + max-height: $reactflow-node-basesize * 13; +} + +.#{$eccgui}-graphviz__node--fullwidth { + width: 100%; } .#{$eccgui}-graphviz__node--minimal-rectangular, @@ -200,54 +232,58 @@ background-color: $reactflow-node-background-color; } -// Node sizes +// Node Resizer -.#{$eccgui}-graphviz__node__resizer, -.#{$eccgui}-graphviz__node.is-resizeable { +.#{$eccgui}-graphviz__node.is-resizable-horizontal, +.#{$eccgui}-graphviz__node.is-resizable-vertical { min-width: 2.5 * $reactflow-node-basesize; min-height: 2.5 * $reactflow-node-basesize; } +.#{$eccgui}-graphviz__node__resizer--cursorhandles { + position: absolute; + height: 0; + width: 0; + bottom: 0; + right: 0; + overflow: visible; + display: none; + + & > div { + overflow: visible; + z-index: 0 !important; + height: $reactflow-cursor-delimiter-offset * 3 !important; + width: $reactflow-cursor-delimiter-offset * 3 !important; + top: unset !important; + left: unset !important; + bottom: -1 * $reactflow-cursor-delimiter-offset !important; + right: -1 * $reactflow-cursor-delimiter-offset !important; + border-bottom: $reactflow-node-border-width solid $reactflow-node-border-color; + border-right: $reactflow-node-border-width solid $reactflow-node-border-color; + + .#{$eccgui}-graphviz__node__resizer--right:not(.#{$eccgui}-graphviz__node__resizer--bottom) & { + bottom: 0 !important; + border-bottom: none; + } + .#{$eccgui}-graphviz__node__resizer--bottom:not(.#{$eccgui}-graphviz__node__resizer--right) & { + right: 0 !important; + border-right: none; + } + } +} + .#{$eccgui}-graphviz__node__resizer { + min-width: 2.5 * $reactflow-node-basesize; + min-height: 2.5 * $reactflow-node-basesize; + .selected &, &:hover { .#{$eccgui}-graphviz__node__resizer--cursorhandles { - & > div { - overflow: auto; - resize: both; - } + display: block; } } } -.#{$eccgui}-graphviz__node--tiny:not(.is-resizeable) { - width: $reactflow-node-basesize * 4; - min-height: $reactflow-node-basesize; - max-height: $reactflow-node-basesize * 4; -} - -.#{$eccgui}-graphviz__node--small:not(.is-resizeable) { - width: $reactflow-node-basesize * 5; - min-height: $reactflow-node-basesize; - max-height: $reactflow-node-basesize * 8; -} - -.#{$eccgui}-graphviz__node--medium:not(.is-resizeable) { - width: $reactflow-node-basesize * 8; - min-height: $reactflow-node-basesize; - max-height: $reactflow-node-basesize * 13; -} - -.#{$eccgui}-graphviz__node--large:not(.is-resizeable) { - width: $reactflow-node-basesize * 13; - min-height: $reactflow-node-basesize; - max-height: $reactflow-node-basesize * 13; -} - -.#{$eccgui}-graphviz__node--fullwidth:not(.is-resizeable) { - width: 100%; -} - // Node border overwrites .#{$eccgui}-graphviz__node--border-solid { diff --git a/src/extensions/react-flow/nodes/stories/NodeContent.stories.tsx b/src/extensions/react-flow/nodes/stories/NodeContent.stories.tsx index 828fbf8b0..68f76a284 100644 --- a/src/extensions/react-flow/nodes/stories/NodeContent.stories.tsx +++ b/src/extensions/react-flow/nodes/stories/NodeContent.stories.tsx @@ -15,9 +15,9 @@ import { OverflowText, Tag, TagList, + NodeContent, + NodeContentExtension, } from "./../../../../index"; -import { NodeContent } from "./../NodeContent"; -import { NodeContentExtension } from "./../NodeContentExtension"; import { Default as ContentExtensionExample, SlideOutOfNode as ContentExtensionExampleSlideOut, @@ -131,7 +131,6 @@ export default { const NodeContentExample = (args: any) => { const [reactflowInstance, setReactflowInstance] = useState(null); const [elements, setElements] = useState([] as Elements); - //const [edgeTools, setEdgeTools] = useState(<>); useEffect(() => { setElements([ @@ -206,7 +205,11 @@ Default.args = { export const Resizeable = Template.bind({}); Resizeable.args = { ...Default.args, + resizeMaxDimensions: { width: 1000, height: 500 }, + nodeDimensions: {}, + resizeDirections: { bottom: true, right: true }, onNodeResize: (dimensions) => { + // eslint-disable-next-line no-console console.log("onNodeResize", `new dimensions: ${dimensions.width}x${dimensions.height}`); }, };