diff --git a/.changeset/dull-pears-clap.md b/.changeset/dull-pears-clap.md new file mode 100644 index 00000000000..876204b78a3 --- /dev/null +++ b/.changeset/dull-pears-clap.md @@ -0,0 +1,5 @@ +--- +'graphiql': patch +--- + +Use the `DragResizeContainer` component from `@graphiql/react` for the sizing of the editors and the docs explorer diff --git a/.changeset/early-roses-shop.md b/.changeset/early-roses-shop.md new file mode 100644 index 00000000000..b359f685313 --- /dev/null +++ b/.changeset/early-roses-shop.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add `DragResizeContainer` utility component diff --git a/packages/graphiql-react/src/editor/header-editor.tsx b/packages/graphiql-react/src/editor/header-editor.tsx index e91ec87f189..2e8576e76e0 100644 --- a/packages/graphiql-react/src/editor/header-editor.tsx +++ b/packages/graphiql-react/src/editor/header-editor.tsx @@ -10,7 +10,6 @@ import { useKeyMap, useMergeQuery, usePrettifyEditors, - useResizeEditor, } from './hooks'; export type UseHeaderEditorArgs = { @@ -117,8 +116,6 @@ export function useHeaderEditor({ useKeyMap(headerEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(headerEditor, ['Shift-Ctrl-M'], merge); - useResizeEditor(headerEditor, ref); - return ref; } diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index 67e907678e8..6b3947f928a 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -122,23 +122,6 @@ export function useKeyMap( }, [editor, keys, callback]); } -export function useResizeEditor( - editor: CodeMirrorEditor | null, - ref: RefObject, -) { - const sizeRef = useRef(); - useEffect(() => { - if (!ref.current || !editor) { - return; - } - const size = ref.current.clientHeight; - if (size !== sizeRef.current) { - editor.setSize(null, null); // TODO: added the args here. double check no effects. might be version issue - } - sizeRef.current = size; - }); -} - export type CopyQueryCallback = (query: string) => void; export function useCopyQuery({ diff --git a/packages/graphiql-react/src/editor/query-editor.tsx b/packages/graphiql-react/src/editor/query-editor.tsx index e55be8ded1e..e6a38c0e328 100644 --- a/packages/graphiql-react/src/editor/query-editor.tsx +++ b/packages/graphiql-react/src/editor/query-editor.tsx @@ -28,7 +28,6 @@ import { useKeyMap, useMergeQuery, usePrettifyEditors, - useResizeEditor, } from './hooks'; import { CodeMirrorEditor, CodeMirrorType } from './types'; import { normalizeWhitespace } from './whitespace'; @@ -38,9 +37,10 @@ type OnClickReference = (reference: SchemaReference) => void; export type UseQueryEditorArgs = { editorTheme?: string; externalFragments?: string | FragmentDefinitionNode[]; + onClickReference?: OnClickReference; + onCopyQuery?: CopyQueryCallback; onEdit?(value: string, documentAST?: DocumentNode): void; onEditOperationName?: EditCallback; - onCopyQuery?: CopyQueryCallback; readOnly?: boolean; validationRules?: ValidationRule[]; }; @@ -48,9 +48,10 @@ export type UseQueryEditorArgs = { export function useQueryEditor({ editorTheme = 'graphiql', externalFragments, + onClickReference, + onCopyQuery, onEdit, onEditOperationName, - onCopyQuery, readOnly = false, validationRules, }: UseQueryEditorArgs = {}) { @@ -93,8 +94,9 @@ export function useQueryEditor({ } else if (reference.kind === 'EnumValue' && reference.type) { explorer.push({ name: reference.type.name, def: reference.type }); } + onClickReference?.(reference); }; - }, [explorer]); + }, [explorer, onClickReference]); useEffect(() => { let isActive = true; @@ -335,8 +337,6 @@ export function useQueryEditor({ ); useKeyMap(queryEditor, ['Shift-Ctrl-M'], merge); - useResizeEditor(queryEditor, ref); - return ref; } diff --git a/packages/graphiql-react/src/editor/response-editor.tsx b/packages/graphiql-react/src/editor/response-editor.tsx index c946a10f298..20cb3b9e48a 100644 --- a/packages/graphiql-react/src/editor/response-editor.tsx +++ b/packages/graphiql-react/src/editor/response-editor.tsx @@ -7,7 +7,7 @@ import { useSchemaContext } from '../schema'; import { commonKeys, importCodeMirror } from './common'; import { ImagePreview } from './components'; import { useEditorContext } from './context'; -import { useResizeEditor, useSynchronizeValue } from './hooks'; +import { useSynchronizeValue } from './hooks'; import { CodeMirrorEditor } from './types'; export type ResponseTooltipType = ComponentType<{ pos: Position }>; @@ -121,8 +121,6 @@ export function useResponseEditor({ useSynchronizeValue(responseEditor, value); - useResizeEditor(responseEditor, ref); - useEffect(() => { if (fetchError) { responseEditor?.setValue(fetchError); diff --git a/packages/graphiql-react/src/editor/variable-editor.tsx b/packages/graphiql-react/src/editor/variable-editor.tsx index 3f3e3b1b6d0..1c0f1472974 100644 --- a/packages/graphiql-react/src/editor/variable-editor.tsx +++ b/packages/graphiql-react/src/editor/variable-editor.tsx @@ -10,7 +10,6 @@ import { useKeyMap, useMergeQuery, usePrettifyEditors, - useResizeEditor, } from './hooks'; import { CodeMirrorType } from './types'; @@ -135,8 +134,6 @@ export function useVariableEditor({ useKeyMap(variableEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(variableEditor, ['Shift-Ctrl-M'], merge); - useResizeEditor(variableEditor, ref); - return ref; } diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 0e7e63fe890..44137b4e7c0 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -39,6 +39,7 @@ import { StorageContextProvider, useStorageContext, } from './storage'; +import { useDragResize } from './utility/resize'; import type { EditorContextType, @@ -96,6 +97,8 @@ export { StorageContext, StorageContextProvider, useStorageContext, + // utility/resize + useDragResize, }; export type { diff --git a/packages/graphiql-react/src/utility/resize.tsx b/packages/graphiql-react/src/utility/resize.tsx new file mode 100644 index 00000000000..43466fdc27c --- /dev/null +++ b/packages/graphiql-react/src/utility/resize.tsx @@ -0,0 +1,280 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { useStorageContext } from '../storage'; +import debounce from './debounce'; + +type ResizableElement = 'first' | 'second'; + +type UseDragResizeArgs = { + defaultSizeRelation?: number; + direction: 'horizontal' | 'vertical'; + initiallyHidden?: ResizableElement; + onHiddenElementChange?(hiddenElement: ResizableElement | null): void; + sizeThresholdFirst?: number; + sizeThresholdSecond?: number; + storageKey?: string; +}; + +export function useDragResize({ + defaultSizeRelation = DEFAULT_FLEX, + direction, + initiallyHidden, + onHiddenElementChange, + sizeThresholdFirst = 100, + sizeThresholdSecond = 100, + storageKey, +}: UseDragResizeArgs) { + const storage = useStorageContext(); + + const store = useCallback( + debounce(500, (value: string) => { + if (storage && storageKey) { + storage.set(storageKey, value); + } + }), + [storage, storageKey], + ); + + const [hiddenElement, _setHiddenElement] = useState( + () => { + const storedValue = + storage && storageKey ? storage.get(storageKey) : null; + if (storedValue === HIDE_FIRST || initiallyHidden === 'first') { + return 'first'; + } + if (storedValue === HIDE_SECOND || initiallyHidden === 'second') { + return 'second'; + } + return null; + }, + ); + + const setHiddenElement = useCallback( + (element: ResizableElement | null) => { + _setHiddenElement(element); + onHiddenElementChange?.(element); + }, + [onHiddenElementChange], + ); + + const firstRef = useRef(null); + const dragBarRef = useRef(null); + const secondRef = useRef(null); + + const defaultFlexRef = useRef(`${defaultSizeRelation}`); + + /** + * Set initial flex values + */ + useLayoutEffect(() => { + const storedValue = + storage && storageKey + ? storage.get(storageKey) || defaultFlexRef.current + : defaultFlexRef.current; + const flexDirection = direction === 'horizontal' ? 'row' : 'column'; + + if (firstRef.current) { + firstRef.current.style.display = 'flex'; + firstRef.current.style.flexDirection = flexDirection; + firstRef.current.style.flex = + storedValue === HIDE_FIRST || storedValue === HIDE_SECOND + ? defaultFlexRef.current + : storedValue; + } + + if (secondRef.current) { + secondRef.current.style.display = 'flex'; + secondRef.current.style.flexDirection = flexDirection; + secondRef.current.style.flex = '1'; + } + + if (dragBarRef.current) { + dragBarRef.current.style.display = 'flex'; + dragBarRef.current.style.flexDirection = flexDirection; + } + }, [direction, storage, storageKey]); + + const hide = useCallback((resizableElement: ResizableElement) => { + const element = + resizableElement === 'first' ? firstRef.current : secondRef.current; + if (!element) { + return; + } + + // We hide elements off screen because of codemirror. If the page is loaded + // and the codemirror container would have zero width, the layout isn't + // instant pretty. By always giving the editor some width we avoid any + // layout shifts when the editor reappears. + element.style.left = '-1000px'; + element.style.position = 'absolute'; + element.style.opacity = '0'; + element.style.height = '500px'; + element.style.width = '500px'; + + // Make sure that the flex value of the first item is at least equal to one + // so that the entire space of the parent element is filled up + if (firstRef.current) { + const flex = parseFloat(firstRef.current.style.flex); + if (!Number.isFinite(flex) || flex < 1) { + firstRef.current.style.flex = '1'; + } + firstRef.current.style.flex; + } + }, []); + + const show = useCallback( + (resizableElement: ResizableElement) => { + const element = + resizableElement === 'first' ? firstRef.current : secondRef.current; + if (!element) { + return; + } + + element.style.width = ''; + element.style.height = ''; + element.style.opacity = ''; + element.style.position = ''; + element.style.left = ''; + + if (firstRef.current && storage && storageKey) { + const storedValue = storage?.get(storageKey); + if ( + storedValue && + storedValue !== HIDE_FIRST && + storedValue !== HIDE_SECOND + ) { + firstRef.current.style.flex = storedValue; + } + } + }, + [storage, storageKey], + ); + + /** + * Hide and show items when state changes + */ + useLayoutEffect(() => { + if (hiddenElement === 'first') { + hide('first'); + } else { + show('first'); + } + if (hiddenElement === 'second') { + hide('second'); + } else { + show('second'); + } + }, [hiddenElement, hide, show]); + + useEffect(() => { + if (!dragBarRef.current || !firstRef.current || !secondRef.current) { + return; + } + const dragBarContainer = dragBarRef.current; + const firstContainer = firstRef.current; + const wrapper = firstContainer.parentElement!; + + const eventProperty = direction === 'horizontal' ? 'clientX' : 'clientY'; + const rectProperty = direction === 'horizontal' ? 'left' : 'top'; + const adjacentRectProperty = + direction === 'horizontal' ? 'right' : 'bottom'; + const sizeProperty = + direction === 'horizontal' ? 'clientWidth' : 'clientHeight'; + + function handleMouseDown(downEvent: MouseEvent) { + downEvent.preventDefault(); + + // Distance between the start of the drag bar and the exact point where + // the user clicked on the drag bar. + const offset = + downEvent[eventProperty] - + dragBarContainer.getBoundingClientRect()[rectProperty]; + + function handleMouseMove(moveEvent: MouseEvent) { + if (moveEvent.buttons === 0) { + return handleMouseUp(); + } + + const firstSize = + moveEvent[eventProperty] - + wrapper.getBoundingClientRect()[rectProperty] - + offset; + const secondSize = + wrapper.getBoundingClientRect()[adjacentRectProperty] - + moveEvent[eventProperty] + + offset - + dragBarContainer[sizeProperty]; + + if (firstSize < sizeThresholdFirst) { + // Hide the first display + setHiddenElement('first'); + store(HIDE_FIRST); + } else if (secondSize < sizeThresholdSecond) { + // Hide the second display + setHiddenElement('second'); + store(HIDE_SECOND); + } else { + // Show both and adjust the flex value of the first one (the flex + // value for the second one is always `1`) + setHiddenElement(null); + const newFlex = `${firstSize / secondSize}`; + firstContainer.style.flex = newFlex; + store(newFlex); + } + } + + function handleMouseUp() { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + } + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + dragBarContainer.addEventListener('mousedown', handleMouseDown); + + function reset() { + if (firstRef.current) { + firstRef.current.style.flex = defaultFlexRef.current; + } + store(defaultFlexRef.current); + setHiddenElement(null); + } + + dragBarContainer.addEventListener('dblclick', reset); + + return () => { + dragBarContainer.removeEventListener('mousedown', handleMouseDown); + dragBarContainer.removeEventListener('dblclick', reset); + }; + }, [ + direction, + setHiddenElement, + sizeThresholdFirst, + sizeThresholdSecond, + store, + ]); + + return useMemo( + () => ({ + dragBarRef, + hiddenElement, + firstRef, + setHiddenElement, + secondRef, + }), + [hiddenElement, setHiddenElement], + ); +} + +const DEFAULT_FLEX = 1; +const HIDE_FIRST = 'hide-first'; +const HIDE_SECOND = 'hide-second'; diff --git a/packages/graphiql/__mocks__/@graphiql/react.ts b/packages/graphiql/__mocks__/@graphiql/react.ts index 2f8d575e5c5..8a052d8a575 100644 --- a/packages/graphiql/__mocks__/@graphiql/react.ts +++ b/packages/graphiql/__mocks__/@graphiql/react.ts @@ -15,6 +15,7 @@ import { StorageContextProvider, useAutoCompleteLeafs, useCopyQuery, + useDragResize, useEditorContext, useExecutionContext, useExplorerContext, @@ -65,6 +66,7 @@ export { StorageContextProvider, useAutoCompleteLeafs, useCopyQuery, + useDragResize, useEditorContext, useExecutionContext, useExplorerContext, @@ -154,6 +156,7 @@ function useMockedEditor( setValue(newValue: string) { setCode(newValue); }, + refresh() {}, }); }); diff --git a/packages/graphiql/cypress/integration/docs.spec.ts b/packages/graphiql/cypress/integration/docs.spec.ts index 2c8b9fc0c40..056fc39c864 100644 --- a/packages/graphiql/cypress/integration/docs.spec.ts +++ b/packages/graphiql/cypress/integration/docs.spec.ts @@ -12,7 +12,7 @@ describe('GraphiQL DocExplorer - button', () => { it('Toggles doc pane back off', () => { // there are two components with .docExplorerHide, one in query history cy.get('.docExplorerWrap button.docExplorerHide').click(); - cy.get('.doc-explorer').should('not.exist'); + cy.get('.doc-explorer').should('not.be.visible'); }); }); diff --git a/packages/graphiql/cypress/integration/init.spec.ts b/packages/graphiql/cypress/integration/init.spec.ts index 8e1cf4cf40c..9489738f7d3 100644 --- a/packages/graphiql/cypress/integration/init.spec.ts +++ b/packages/graphiql/cypress/integration/init.spec.ts @@ -48,7 +48,7 @@ describe('GraphiQL On Initialization', () => { it('Shows the expected error when the schema is invalid', () => { cy.visit(`/?bad=true`); cy.wait(200); - cy.get('section#graphiql-result-viewer').should(element => { + cy.get('section.result-window').should(element => { expect(element.get(0).innerText).to.contain('Names must'); }); }); diff --git a/packages/graphiql/cypress/support/commands.ts b/packages/graphiql/cypress/support/commands.ts index 09d74e7b706..6dfabad043f 100644 --- a/packages/graphiql/cypress/support/commands.ts +++ b/packages/graphiql/cypress/support/commands.ts @@ -121,7 +121,7 @@ Cypress.Commands.add('assertQueryResult', (op, mockSuccess, timeout = 200) => { cy.visitWithOp(op); cy.clickExecuteQuery(); cy.wait(timeout); - cy.get('section#graphiql-result-viewer').should(element => { + cy.get('section.result-window').should(element => { expect(normalizeWhitespace(element.get(0).innerText)).to.equal( JSON.stringify(mockSuccess, null, 2), ); diff --git a/packages/graphiql/src/components/DocExplorer.tsx b/packages/graphiql/src/components/DocExplorer.tsx index 7635f666e87..b5eb85b12a8 100644 --- a/packages/graphiql/src/components/DocExplorer.tsx +++ b/packages/graphiql/src/components/DocExplorer.tsx @@ -19,13 +19,17 @@ import SearchBox from './DocExplorer/SearchBox'; import SearchResults from './DocExplorer/SearchResults'; import TypeDoc from './DocExplorer/TypeDoc'; +type DocExplorerProps = { + onClose?(): void; +}; + /** * DocExplorer * * Shows documentations for GraphQL definitions from the schema. * */ -export function DocExplorer() { +export function DocExplorer(props: DocExplorerProps) { const { fetchError, isFetching, @@ -122,6 +126,7 @@ export function DocExplorer() { className="docExplorerHide" onClick={() => { hide(); + props.onClose?.(); }} aria-label="Close Documentation Explorer"> {'\u2715'} diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index 9802c0fb4d2..a796dbab5df 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -8,7 +8,6 @@ import React, { ComponentType, PropsWithChildren, - MouseEventHandler, ReactNode, forwardRef, ForwardRefExoticComponent, @@ -31,6 +30,7 @@ import { StorageContextProvider, useAutoCompleteLeafs, useCopyQuery, + useDragResize, useEditorContext, useExecutionContext, useExplorerContext, @@ -57,20 +57,16 @@ import { ToolbarMenu, ToolbarMenuItem } from './ToolbarMenu'; import { QueryEditor } from './QueryEditor'; import { VariableEditor } from './VariableEditor'; import { HeaderEditor } from './HeaderEditor'; -import { ResultViewer, RESULT_VIEWER_ID } from './ResultViewer'; +import { ResultViewer } from './ResultViewer'; import { DocExplorer } from './DocExplorer'; import { QueryHistory } from './QueryHistory'; -import debounce from '../utility/debounce'; import find from '../utility/find'; -import { getLeft, getTop } from '../utility/elementPosition'; import { formatError, formatResult } from '@graphiql/toolkit'; import type { Fetcher, GetDefaultFieldNamesFn } from '@graphiql/toolkit'; import { Tab, TabAddButton, Tabs } from './Tabs'; -const DEFAULT_DOC_EXPLORER_WIDTH = 350; - const majorVersion = parseInt(React.version.slice(0, 2), 10); if (majorVersion < 16) { @@ -87,13 +83,6 @@ declare namespace window { export let g: GraphiQL; } -export type Maybe = T | null | undefined; - -type OnMouseMoveFn = Maybe< - (moveEvent: MouseEvent | React.MouseEvent) => void ->; -type OnMouseUpFn = Maybe<() => void>; - export type GraphiQLToolbarConfig = { additionalContent?: React.ReactNode; }; @@ -299,16 +288,6 @@ export type GraphiQLProps = { children?: ReactNode; }; -export type GraphiQLState = { - editorFlex: number; - secondaryEditorOpen: boolean; - secondaryEditorHeight: number; - variableEditorActive: boolean; - headerEditorActive: boolean; - headerEditorEnabled: boolean; - docExplorerWidth: number; -}; - /** * The top-level React component for GraphiQL, intended to encompass the entire * browser viewport. @@ -510,6 +489,7 @@ type GraphiQLWithContextProviderProps = Omit< | 'defaultQuery' | 'docExplorerOpen' | 'fetcher' + | 'headers' | 'inputValueDeprecation' | 'introspectionQueryName' | 'maxHistoryLength' @@ -519,15 +499,13 @@ type GraphiQLWithContextProviderProps = Omit< | 'schema' | 'schemaDescription' | 'storage' + | 'variables' >; const GraphiQLConsumeContexts = forwardRef< GraphiQLWithContext, GraphiQLWithContextProviderProps ->(function GraphiQLConsumeContexts( - { getDefaultFieldNames, onCopyQuery, ...props }, - ref, -) { +>(function GraphiQLConsumeContexts({ getDefaultFieldNames, ...props }, ref) { const editorContext = useEditorContext({ nonNull: true }); const executionContext = useExecutionContext({ nonNull: true }); const explorerContext = useExplorerContext(); @@ -536,10 +514,49 @@ const GraphiQLConsumeContexts = forwardRef< const storageContext = useStorageContext(); const autoCompleteLeafs = useAutoCompleteLeafs({ getDefaultFieldNames }); - const copy = useCopyQuery({ onCopyQuery }); + const copy = useCopyQuery({ onCopyQuery: props.onCopyQuery }); const merge = useMergeQuery(); const prettify = usePrettifyEditors(); + const docResize = useDragResize({ + defaultSizeRelation: 3, + direction: 'horizontal', + initiallyHidden: explorerContext?.isVisible ? undefined : 'second', + onHiddenElementChange: resizableElement => { + if (resizableElement === 'second') { + explorerContext?.hide(); + } else { + explorerContext?.show(); + } + }, + sizeThresholdSecond: 200, + storageKey: 'docExplorerFlex', + }); + const editorResize = useDragResize({ + direction: 'horizontal', + storageKey: 'editorFlex', + }); + const secondaryEditorResize = useDragResize({ + defaultSizeRelation: 3, + direction: 'vertical', + initiallyHidden: (() => { + // initial secondary editor pane open + if (props.defaultVariableEditorOpen !== undefined) { + return props.defaultVariableEditorOpen ? undefined : 'second'; + } + + if (props.defaultSecondaryEditorOpen !== undefined) { + return props.defaultSecondaryEditorOpen ? undefined : 'second'; + } + + return editorContext.initialVariables || editorContext.initialHeaders + ? undefined + : 'second'; + })(), + sizeThresholdSecond: 60, + storageKey: 'secondaryEditorFlex', + }); + return ( ); @@ -560,7 +580,7 @@ const GraphiQLConsumeContexts = forwardRef< type GraphiQLWithContextConsumerProps = Omit< GraphiQLWithContextProviderProps, - 'fetcher' | 'getDefaultFieldNames' | 'onCopyQuery' + 'fetcher' | 'getDefaultFieldNames' > & { editorContext: EditorContextType; executionContext: ExecutionContextType; @@ -573,55 +593,25 @@ type GraphiQLWithContextConsumerProps = Omit< copy(): void; merge(): void; prettify(): void; + + docResize: ReturnType; + editorResize: ReturnType; + secondaryEditorResize: ReturnType; +}; + +export type GraphiQLState = { + activeSecondaryEditor: 'variable' | 'header'; }; class GraphiQLWithContext extends React.Component< GraphiQLWithContextConsumerProps, GraphiQLState > { - // refs - graphiqlContainer: Maybe; - editorBarComponent: Maybe; - constructor(props: GraphiQLWithContextConsumerProps) { super(props); - const variables = - props.variables ?? props.storageContext?.get('variables') ?? undefined; - - const headers = - props.headers ?? props.storageContext?.get('headers') ?? undefined; - - // initial secondary editor pane open - let secondaryEditorOpen; - if (props.defaultVariableEditorOpen !== undefined) { - secondaryEditorOpen = props.defaultVariableEditorOpen; - } else if (props.defaultSecondaryEditorOpen !== undefined) { - secondaryEditorOpen = props.defaultSecondaryEditorOpen; - } else { - secondaryEditorOpen = Boolean(variables || headers); - } - - const headerEditorEnabled = props.headerEditorEnabled ?? true; - // Initialize state - this.state = { - editorFlex: Number(this.props.storageContext?.get('editorFlex')) || 1, - secondaryEditorOpen, - secondaryEditorHeight: - Number(this.props.storageContext?.get('secondaryEditorHeight')) || 200, - variableEditorActive: - this.props.storageContext?.get('variableEditorActive') === 'true' || - props.headerEditorEnabled - ? this.props.storageContext?.get('headerEditorActive') !== 'true' - : true, - headerEditorActive: - this.props.storageContext?.get('headerEditorActive') === 'true', - headerEditorEnabled, - docExplorerWidth: - Number(this.props.storageContext?.get('docExplorerWidth')) || - DEFAULT_DOC_EXPLORER_WIDTH, - }; + this.state = { activeSecondaryEditor: 'variable' }; } render() { @@ -675,192 +665,215 @@ class GraphiQLWithContext extends React.Component< isChildComponentType(child, GraphiQL.Footer), ); - const queryWrapStyle = { - WebkitFlex: this.state.editorFlex, - flex: this.state.editorFlex, - }; - - const docWrapStyle = { - display: 'block', - width: this.state.docExplorerWidth, - }; - const docExplorerWrapClasses = - 'docExplorerWrap' + - (this.state.docExplorerWidth < 200 ? ' doc-explorer-narrow' : ''); - - const secondaryEditorOpen = this.state.secondaryEditorOpen; - const secondaryEditorStyle = { - height: secondaryEditorOpen - ? this.state.secondaryEditorHeight - : undefined, - }; - return ( -
{ - this.graphiqlContainer = n; - }} - data-testid="graphiql-container" - className="graphiql-container"> - {this.props.historyContext?.isVisible && ( -
- -
- )} -
-
- {this.props.beforeTopBarContent} -
- {logo} - - {toolbar} +
+
+ {this.props.historyContext?.isVisible && ( +
+
- {this.props.explorerContext && - !this.props.explorerContext.isVisible && ( - - )} -
- {this.props.tabs ? ( - - {this.props.editorContext.tabs.map((tab, index) => ( - 1} - onSelect={() => { - this.props.executionContext.stop(); - this.props.editorContext.changeTab(index); - }} - onClose={() => { - if (this.props.editorContext.activeTabIndex === index) { + )} +
+
+ {this.props.beforeTopBarContent} +
+ {logo} + + {toolbar} +
+ {this.props.explorerContext && + !this.props.explorerContext.isVisible && ( + + )} +
+ {this.props.tabs ? ( + + {this.props.editorContext.tabs.map((tab, index) => ( + 1} + onSelect={() => { this.props.executionContext.stop(); - } - this.props.editorContext.closeTab(index); - }} - tabProps={{ - 'aria-controls': 'sessionWrap', - id: `session-tab-${index}`, + this.props.editorContext.changeTab(index); + }} + onClose={() => { + if (this.props.editorContext.activeTabIndex === index) { + this.props.executionContext.stop(); + } + this.props.editorContext.closeTab(index); + }} + tabProps={{ + 'aria-controls': 'sessionWrap', + id: `session-tab-${index}`, + }} + /> + ))} + { + this.props.editorContext.addTab(); }} /> - ))} - { - this.props.editorContext.addTab(); - }} - /> - - ) : null} -
{ - this.editorBarComponent = n; - }} - role="tabpanel" - id="sessionWrap" - className="editorBar" - aria-labelledby={`session-tab-${this.props.editorContext.activeTabIndex}`} - onDoubleClick={this.handleResetResize} - onMouseDown={this.handleResizeStart}> -
- -
-
-
- Query Variables + + ) : null} +
+
+
+
+ { + if (this.props.docResize.hiddenElement === 'second') { + this.props.docResize.setHiddenElement(null); + } + }} + onCopyQuery={this.props.onCopyQuery} + onEdit={this.props.onEditQuery} + onEditOperationName={this.props.onEditOperationName} + readOnly={this.props.readOnly} + validationRules={this.props.validationRules} + />
- {this.state.headerEditorEnabled && ( +
- Request Headers + className="secondary-editor-title variable-editor-title" + id="secondary-editor-title"> +
{ + if ( + this.props.secondaryEditorResize.hiddenElement === + 'second' + ) { + this.props.secondaryEditorResize.setHiddenElement( + null, + ); + } + this.setState( + { + activeSecondaryEditor: 'variable', + }, + () => { + this.props.editorContext.variableEditor?.refresh(); + }, + ); + }}> + Query Variables +
+ {this.props.headerEditorEnabled && ( +
{ + if ( + this.props.secondaryEditorResize.hiddenElement === + 'second' + ) { + this.props.secondaryEditorResize.setHiddenElement( + null, + ); + } + this.setState( + { + activeSecondaryEditor: 'header', + }, + () => { + this.props.editorContext.headerEditor?.refresh(); + }, + ); + }}> + Request Headers +
+ )}
- )} +
+
+
+ + {this.props.headerEditorEnabled && ( + + )} +
+
- - {this.state.headerEditorEnabled && ( - +
+
+
+
+
+ {this.props.executionContext.isFetching && ( +
+
+
+ )} + - )} -
-
-
- {this.props.executionContext.isFetching && ( -
-
+ {footer}
- )} - - {footer} +
- {this.props.explorerContext?.isVisible && ( -
-
+
+
+
+
+ this.props.docResize.setHiddenElement('second')} /> -
- )} +
); } @@ -889,217 +902,6 @@ class GraphiQLWithContext extends React.Component< public autoCompleteLeafs() { return this.props.autoCompleteLeafs(); } - - // Private methods - - private handleResizeStart = (downEvent: React.MouseEvent) => { - if (!this._didClickDragBar(downEvent)) { - return; - } - - downEvent.preventDefault(); - - const offset = downEvent.clientX - getLeft(downEvent.target as HTMLElement); - - let onMouseMove: OnMouseMoveFn = moveEvent => { - if (moveEvent.buttons === 0) { - return onMouseUp!(); - } - - const editorBar = this.editorBarComponent as HTMLElement; - const leftSize = moveEvent.clientX - getLeft(editorBar) - offset; - const rightSize = editorBar.clientWidth - leftSize; - this.setState({ editorFlex: leftSize / rightSize }); - debounce(500, () => - this.props.storageContext?.set( - 'editorFlex', - JSON.stringify(this.state.editorFlex), - ), - )(); - }; - - let onMouseUp: OnMouseUpFn = () => { - document.removeEventListener('mousemove', onMouseMove!); - document.removeEventListener('mouseup', onMouseUp!); - onMouseMove = null; - onMouseUp = null; - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; - - handleResetResize = () => { - this.setState({ editorFlex: 1 }); - this.props.storageContext?.set( - 'editorFlex', - JSON.stringify(this.state.editorFlex), - ); - }; - - private _didClickDragBar(event: React.MouseEvent) { - // Only for primary unmodified clicks - if (event.button !== 0 || event.ctrlKey) { - return false; - } - const target = event.target; - if (!(target instanceof Element)) { - return false; - } - // We use codemirror's gutter as the drag bar. - if (target.className.indexOf('CodeMirror-gutter') !== 0) { - return false; - } - // Specifically the result window's drag bar. - const resultWindow = target.closest('section'); - return resultWindow ? resultWindow.id === RESULT_VIEWER_ID : false; - } - - private handleDocsResizeStart: MouseEventHandler< - HTMLDivElement - > = downEvent => { - downEvent.preventDefault(); - - const hadWidth = this.state.docExplorerWidth; - const offset = downEvent.clientX - getLeft(downEvent.target as HTMLElement); - - let onMouseMove: OnMouseMoveFn = moveEvent => { - if (moveEvent.buttons === 0) { - return onMouseUp!(); - } - - const app = this.graphiqlContainer as HTMLElement; - const cursorPos = moveEvent.clientX - getLeft(app) - offset; - const docsSize = app.clientWidth - cursorPos; - - if (docsSize < 100) { - this.props.explorerContext?.hide(); - } else { - this.props.explorerContext?.show(); - this.setState({ docExplorerWidth: Math.min(docsSize, 650) }); - debounce(500, () => - this.props.storageContext?.set( - 'docExplorerWidth', - JSON.stringify(this.state.docExplorerWidth), - ), - )(); - } - }; - - let onMouseUp: OnMouseUpFn = () => { - if (this.props.explorerContext && !this.props.explorerContext.isVisible) { - this.setState({ docExplorerWidth: hadWidth }); - debounce(500, () => - this.props.storageContext?.set( - 'docExplorerWidth', - JSON.stringify(this.state.docExplorerWidth), - ), - )(); - } - - document.removeEventListener('mousemove', onMouseMove!); - document.removeEventListener('mouseup', onMouseUp!); - onMouseMove = null; - onMouseUp = null; - }; - - document.addEventListener('mousemove', onMouseMove!); - document.addEventListener('mouseup', onMouseUp); - }; - - private handleDocsResetResize = () => { - this.setState({ - docExplorerWidth: DEFAULT_DOC_EXPLORER_WIDTH, - }); - debounce(500, () => - this.props.storageContext?.set( - 'docExplorerWidth', - JSON.stringify(this.state.docExplorerWidth), - ), - )(); - }; - - // Prevent clicking on the tab button from propagating to the resizer. - private handleTabClickPropagation: MouseEventHandler< - HTMLDivElement - > = downEvent => { - downEvent.preventDefault(); - downEvent.stopPropagation(); - }; - - private handleOpenHeaderEditorTab: MouseEventHandler< - HTMLDivElement - > = _clickEvent => { - this.setState({ - headerEditorActive: true, - variableEditorActive: false, - secondaryEditorOpen: true, - }); - }; - - private handleOpenVariableEditorTab: MouseEventHandler< - HTMLDivElement - > = _clickEvent => { - this.setState({ - headerEditorActive: false, - variableEditorActive: true, - secondaryEditorOpen: true, - }); - }; - - private handleSecondaryEditorResizeStart: MouseEventHandler< - HTMLDivElement - > = downEvent => { - downEvent.preventDefault(); - - let didMove = false; - const wasOpen = this.state.secondaryEditorOpen; - const hadHeight = this.state.secondaryEditorHeight; - const offset = downEvent.clientY - getTop(downEvent.target as HTMLElement); - - let onMouseMove: OnMouseMoveFn = moveEvent => { - if (moveEvent.buttons === 0) { - return onMouseUp!(); - } - - didMove = true; - - const editorBar = this.editorBarComponent as HTMLElement; - const topSize = moveEvent.clientY - getTop(editorBar) - offset; - const bottomSize = editorBar.clientHeight - topSize; - if (bottomSize < 60) { - this.setState({ - secondaryEditorOpen: false, - secondaryEditorHeight: hadHeight, - }); - } else { - this.setState({ - secondaryEditorOpen: true, - secondaryEditorHeight: bottomSize, - }); - } - debounce(500, () => - this.props.storageContext?.set( - 'secondaryEditorHeight', - JSON.stringify(this.state.secondaryEditorHeight), - ), - )(); - }; - - let onMouseUp: OnMouseUpFn = () => { - if (!didMove) { - this.setState({ secondaryEditorOpen: !wasOpen }); - } - - document.removeEventListener('mousemove', onMouseMove!); - document.removeEventListener('mouseup', onMouseUp!); - onMouseMove = null; - onMouseUp = null; - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }; } // // Configure the UI by providing this Component as a child of GraphiQL. diff --git a/packages/graphiql/src/components/ResultViewer.tsx b/packages/graphiql/src/components/ResultViewer.tsx index 60fdbd9ad69..e74a893e94e 100644 --- a/packages/graphiql/src/components/ResultViewer.tsx +++ b/packages/graphiql/src/components/ResultViewer.tsx @@ -8,8 +8,6 @@ import { useResponseEditor, UseResponseEditorArgs } from '@graphiql/react'; import React from 'react'; -export const RESULT_VIEWER_ID = 'graphiql-result-viewer'; - /** * ResultViewer * @@ -20,7 +18,6 @@ export function ResultViewer(props: UseResponseEditorArgs) { const ref = useResponseEditor(props); return (
{ }); it('defaults to closed docExplorer', () => { const { container } = render(); - expect(container.querySelector('.docExplorerWrap')).not.toBeInTheDocument(); + expect(container.querySelector('.docExplorerWrap')).not.toBeVisible(); }); it('accepts a defaultVariableEditorOpen param', () => { @@ -139,21 +139,21 @@ describe('GraphiQL', () => { ); const queryVariables = container1.querySelector('.variable-editor'); - expect(queryVariables.style.height).toEqual(''); + expect(queryVariables).not.toBeVisible(); const secondaryEditorTitle = container1.querySelector( '#secondary-editor-title', ); fireEvent.mouseDown(secondaryEditorTitle); - fireEvent.mouseMove(secondaryEditorTitle); - expect(queryVariables.style.height).toEqual('200px'); + fireEvent.mouseMove(secondaryEditorTitle, { buttons: 1, clientY: 50 }); + expect(queryVariables).toBeVisible(); const { container: container2 } = render( , ); expect( - container2.querySelector('[aria-label="Query Variables"]')?.style.height, - ).toEqual('200px'); + container2.querySelector('[aria-label="Query Variables"]'), + ).toBeVisible(); const { container: container3 } = render( { />, ); const queryVariables3 = container3.querySelector('.variable-editor'); - expect(queryVariables3?.style.height).toEqual(''); + expect(queryVariables3).not.toBeVisible(); }); it('defaults to closed history panel', () => { @@ -490,40 +490,50 @@ describe('GraphiQL', () => { }); it('readjusts the query wrapper flex style field when the result panel is resized', async () => { - const spy = jest + // Mock the drag bar width + const clientWitdhSpy = jest .spyOn(Element.prototype, 'clientWidth', 'get') - .mockReturnValue(900); + .mockReturnValue(0); + // Mock the container width + const boundingClientRectSpy = jest + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockReturnValue({ left: 0, right: 900 }); const { container } = render(); await wait(); - const codeMirrorGutter = container.querySelector( - '.result-window .CodeMirror-gutter', - ); + const dragBar = container.querySelector('.editor-drag-bar'); const queryWrap = container.querySelector('.queryWrap'); - fireEvent.mouseDown(codeMirrorGutter, { + fireEvent.mouseDown(dragBar, { button: 0, ctrlKey: false, }); - fireEvent.mouseMove(codeMirrorGutter, { + fireEvent.mouseMove(dragBar, { buttons: 1, clientX: 700, }); - fireEvent.mouseUp(codeMirrorGutter); + fireEvent.mouseUp(dragBar); - expect(queryWrap.style.flex).toEqual('3.5'); + // 700 / (900 - 700) = 3.5 + expect(queryWrap.parentElement.style.flex).toEqual('3.5'); - spy.mockRestore(); + clientWitdhSpy.mockRestore(); + boundingClientRectSpy.mockRestore(); }); it('allows for resizing the doc explorer correctly', () => { - const spy = jest + // Mock the drag bar width + const clientWidthSpy = jest .spyOn(Element.prototype, 'clientWidth', 'get') - .mockReturnValue(1200); + .mockReturnValue(0); + // Mock the container width + const boundingClientRectSpy = jest + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockReturnValue({ left: 0, right: 1200 }); const { container, getByLabelText } = render( , @@ -545,11 +555,13 @@ describe('GraphiQL', () => { fireEvent.mouseUp(docExplorerResizer); - expect(container.querySelector('.docExplorerWrap').style.width).toBe( - '403px', - ); + // 797 / (1200 - 797) = 1.977667493796526 + expect( + container.querySelector('.editorWrap').parentElement.style.flex, + ).toBe('1.977667493796526'); - spy.mockRestore(); + clientWidthSpy.mockRestore(); + boundingClientRectSpy.mockRestore(); }); describe('Tabs', () => { diff --git a/packages/graphiql/src/css/app.css b/packages/graphiql/src/css/app.css index 24ae6b4d2fc..3667e2239ee 100644 --- a/packages/graphiql/src/css/app.css +++ b/packages/graphiql/src/css/app.css @@ -106,7 +106,6 @@ } .graphiql-container .resultWrap { - border-left: solid 1px #e0e0e0; display: flex; flex-direction: column; flex: 1; @@ -119,6 +118,7 @@ background: white; box-shadow: 0 0 8px rgba(0, 0, 0, 0.15); position: relative; + width: 100%; z-index: 3; } @@ -130,9 +130,7 @@ .graphiql-container .docExplorerResizer { cursor: col-resize; height: 100%; - left: -5px; position: absolute; - top: 0; width: 10px; z-index: 10; } @@ -155,7 +153,7 @@ .graphiql-container .secondary-editor { display: flex; flex-direction: column; - height: 30px; + height: 100%; position: relative; } @@ -164,6 +162,7 @@ border-bottom: 1px solid #d6d6d6; border-top: 1px solid #e0e0e0; color: #777; + cursor: row-resize; font-variant: small-caps; font-weight: bold; letter-spacing: 1px; @@ -209,8 +208,15 @@ } .graphiql-container .result-window .CodeMirror-gutters { + background-color: #f6f7f8; + border: none; +} + +.editor-drag-bar { + width: 12px; background-color: #eeeeee; - border-color: #e0e0e0; + border-left: 1px solid #e0e0e0; + border-right: 1px solid #e0e0e0; cursor: col-resize; } diff --git a/packages/graphiql/src/css/doc-explorer.css b/packages/graphiql/src/css/doc-explorer.css index 56589b1121b..e24096aa490 100644 --- a/packages/graphiql/src/css/doc-explorer.css +++ b/packages/graphiql/src/css/doc-explorer.css @@ -38,10 +38,6 @@ line-height: 14px; } -.doc-explorer-narrow .doc-explorer-back { - width: 0; -} - .graphiql-container .doc-explorer-back:before { border-left: 2px solid #3b5998; border-top: 2px solid #3b5998; @@ -71,10 +67,6 @@ top: 47px; } -.graphiql-container .doc-explorer-contents { - min-width: 300px; -} - .graphiql-container .doc-type-description p:first-child, .graphiql-container .doc-type-description blockquote:first-child { margin-top: 0; diff --git a/packages/graphiql/src/utility/elementPosition.ts b/packages/graphiql/src/utility/elementPosition.ts deleted file mode 100644 index 7ca55c272e2..00000000000 --- a/packages/graphiql/src/utility/elementPosition.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2021 GraphQL Contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * Utility functions to get a pixel distance from left/top of the window. - */ - -export function getLeft(initialElem: HTMLElement) { - let pt = 0; - let elem = initialElem; - while (elem.offsetParent) { - pt += elem.offsetLeft; - elem = elem.offsetParent as HTMLElement; - } - return pt; -} - -export function getTop(initialElem: HTMLElement) { - let pt = 0; - let elem = initialElem; - while (elem.offsetParent) { - pt += elem.offsetTop; - elem = elem.offsetParent as HTMLElement; - } - return pt; -}