Skip to content

Commit

Permalink
add editor context and useHeaderEditor hook to @graphiql/react (#…
Browse files Browse the repository at this point in the history
…2404)

* handle chunks with vite

* add editor context and useHeaderEditor hook

* use --emitDeclarationOnly when running tsc

* fix error message

* rename file

* move back state reducer functions

* add setHeaders

* specify files in package.json

* add changeset

* create reusable hooks for editor functionality

* fix docs build

* fix for loop in useKeyMap

* rename useSyncValue

* loosen check for detecting macOS

* avoid type casting and error on closing tab

* add types package as dev dependency for escape-html
  • Loading branch information
thomasheyenbrock authored May 17, 2022
1 parent 7f695b1 commit 029ddf8
Show file tree
Hide file tree
Showing 13 changed files with 561 additions and 277 deletions.
6 changes: 6 additions & 0 deletions .changeset/wicked-roses-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'graphiql': minor
'@graphiql/react': minor
---

Add a context provider for editors and move the logic of the headers editor from the `graphiql` package into a hook `useHeaderEditor` provided by `@graphiql/react`
19 changes: 16 additions & 3 deletions packages/graphiql-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,33 @@
"url": "https://github.com/graphql/graphiql/issues?q=issue+label:graphiql-react"
},
"license": "MIT",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"main": "dist/graphiql-react.cjs.js",
"module": "dist/graphiql-react.es.js",
"types": "types/index.d.ts",
"files": [
"dist",
"src",
"types"
],
"scripts": {
"dev": "vite",
"build": "tsc && rimraf tsc && vite build",
"build": "tsc --emitDeclarationOnly && vite build",
"preview": "vite preview"
},
"peerDependencies": {
"graphql": "^15.5.0 || ^16.0.0",
"react": "^16.8.0 | ^17.0.0 | ^18.0.0",
"react-dom": "^16.8.0 | ^17.0.0 | ^18.0.0"
},
"dependencies": {
"codemirror": "^5.65.3",
"codemirror-graphql": "^1.3.0",
"escape-html": "^1.0.3",
"markdown-it": "^12.2.0"
},
"devDependencies": {
"@types/codemirror": "^5.60.5",
"@types/escape-html": "^1.0.1",
"@vitejs/plugin-react": "^1.3.0",
"graphql": "^16.4.0",
"react": "^17.0.2",
Expand Down
53 changes: 53 additions & 0 deletions packages/graphiql-react/src/editor/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
let isMacOs = false;

if (typeof window === 'object') {
isMacOs = window.navigator.platform.toLowerCase().indexOf('mac') === 0;
}

export const commonKeys = {
// Persistent search box in Query Editor
[isMacOs ? 'Cmd-F' : 'Ctrl-F']: 'findPersistent',
'Cmd-G': 'findPersistent',
'Ctrl-G': 'findPersistent',

// Editor improvements
'Ctrl-Left': 'goSubwordLeft',
'Ctrl-Right': 'goSubwordRight',
'Alt-Left': 'goGroupLeft',
'Alt-Right': 'goGroupRight',
};

export const commonCodeMirrorAddons = [
import('codemirror/addon/hint/show-hint'),
import('codemirror/addon/edit/matchbrackets'),
import('codemirror/addon/edit/closebrackets'),
import('codemirror/addon/fold/brace-fold'),
import('codemirror/addon/fold/foldgutter'),
import('codemirror/addon/lint/lint'),
import('codemirror/addon/search/searchcursor'),
import('codemirror/addon/search/jump-to-line'),
import('codemirror/addon/dialog/dialog'),
// @ts-expect-error
import('codemirror/keymap/sublime'),
];

/**
* Dynamically import codemirror and dependencies
* This works for codemirror 5, not sure if the same imports work for 6
*/
export async function importCodeMirror(
addons: Promise<any>[],
options?: { useCommonAddons?: boolean },
) {
const CodeMirror = await import('codemirror').then(c =>
// Depending on bundler and settings the dynamic import either returns a
// function (e.g. parcel) or an object containing a `default` property
typeof c === 'function' ? c : c.default,
);
const allAddons =
options?.useCommonAddons === false
? addons
: commonCodeMirrorAddons.concat(addons);
await Promise.all(allAddons.map(addon => addon));
return CodeMirror;
}
107 changes: 107 additions & 0 deletions packages/graphiql-react/src/editor/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Editor, EditorChange } from 'codemirror';

import {
GraphQLNonNull,
GraphQLList,
GraphQLType,
GraphQLField,
} from 'graphql';
import escapeHTML from 'escape-html';
import MD from 'markdown-it';
import { importCodeMirror } from './common';

const md = new MD();

/**
* Render a custom UI for CodeMirror's hint which includes additional info
* about the type and description for the selected context.
*/
export default function onHasCompletion(
_cm: Editor,
data: EditorChange | undefined,
onHintInformationRender: (el: HTMLDivElement) => void,
) {
importCodeMirror([], { useCommonAddons: false }).then(CodeMirror => {
let information: HTMLDivElement | null;
let deprecation: HTMLDivElement | null;
CodeMirror.on(
data,
'select',
// @ts-expect-error
(ctx: GraphQLField<{}, {}, {}>, el: HTMLDivElement) => {
// Only the first time (usually when the hint UI is first displayed)
// do we create the information nodes.
if (!information) {
const hintsUl = el.parentNode as Node & ParentNode;

// This "information" node will contain the additional info about the
// highlighted typeahead option.
information = document.createElement('div');
information.className = 'CodeMirror-hint-information';
hintsUl.appendChild(information);

// This "deprecation" node will contain info about deprecated usage.
deprecation = document.createElement('div');
deprecation.className = 'CodeMirror-hint-deprecation';
hintsUl.appendChild(deprecation);

// When CodeMirror attempts to remove the hint UI, we detect that it was
// removed and in turn remove the information nodes.
let onRemoveFn: EventListener | null;
hintsUl.addEventListener(
'DOMNodeRemoved',
(onRemoveFn = (event: Event) => {
if (event.target === hintsUl) {
hintsUl.removeEventListener('DOMNodeRemoved', onRemoveFn);
information = null;
deprecation = null;
onRemoveFn = null;
}
}),
);
}

// Now that the UI has been set up, add info to information.
const description = ctx.description
? md.render(ctx.description)
: 'Self descriptive.';
const type = ctx.type
? '<span class="infoType">' + renderType(ctx.type) + '</span>'
: '';

information.innerHTML =
'<div class="content">' +
(description.slice(0, 3) === '<p>'
? '<p>' + type + description.slice(3)
: type + description) +
'</div>';

if (ctx && deprecation && ctx.deprecationReason) {
const reason = ctx.deprecationReason
? md.render(ctx.deprecationReason)
: '';
deprecation.innerHTML =
'<span class="deprecation-label">Deprecated</span>' + reason;
deprecation.style.display = 'block';
} else if (deprecation) {
deprecation.style.display = 'none';
}

// Additional rendering?
if (onHintInformationRender) {
onHintInformationRender(information);
}
},
);
});
}

function renderType(type: GraphQLType): string {
if (type instanceof GraphQLNonNull) {
return `${renderType(type.ofType)}!`;
}
if (type instanceof GraphQLList) {
return `[${renderType(type.ofType)}]`;
}
return `<a class="typeName">${escapeHTML(type.name)}</a>`;
}
25 changes: 25 additions & 0 deletions packages/graphiql-react/src/editor/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Editor } from 'codemirror';
import { createContext, ReactNode, useState } from 'react';

export type EditorContextType = {
headerEditor: Editor | null;
setHeaderEditor(newEditor: Editor): void;
};

export const EditorContext = createContext<EditorContextType>({
headerEditor: null,
setHeaderEditor() {},
});

export function EditorContextProvider(props: {
children: ReactNode;
initialValue?: string;
}) {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<EditorContext.Provider
value={{ headerEditor: editor, setHeaderEditor: setEditor }}>
{props.children}
</EditorContext.Provider>
);
}
117 changes: 117 additions & 0 deletions packages/graphiql-react/src/editor/header-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useContext, useEffect, useRef } from 'react';

import { commonKeys, importCodeMirror } from './common';
import { EditorContext } from './context';
import {
CompletionCallback,
EditCallback,
EmptyCallback,
useChangeHandler,
useCompletion,
useKeyMap,
useResizeEditor,
useSynchronizeValue,
} from './hooks';

export type UseHeaderEditorArgs = {
editorTheme?: string;
onEdit?: EditCallback;
onHintInformationRender?: CompletionCallback;
onPrettifyQuery?: EmptyCallback;
onMergeQuery?: EmptyCallback;
onRunQuery?: EmptyCallback;
readOnly?: boolean;
value?: string;
};

export function useHeaderEditor({
editorTheme = 'graphiql',
onEdit,
onHintInformationRender,
onMergeQuery,
onPrettifyQuery,
onRunQuery,
readOnly = false,
value,
}: UseHeaderEditorArgs = {}) {
const context = useContext(EditorContext);
const ref = useRef<HTMLDivElement>(null);

if (!context) {
throw new Error(
'Tried to call the `useHeaderEditor` hook without the necessary context. Make sure that the `EditorContextProvider` from `@graphiql/react` is rendered higher in the tree.',
);
}

const { headerEditor, setHeaderEditor } = context;

useEffect(() => {
importCodeMirror([
// @ts-expect-error
import('codemirror/mode/javascript/javascript'),
]).then(CodeMirror => {
const container = ref.current;
if (!container) {
return;
}

const newEditor = CodeMirror(container, {
lineNumbers: true,
tabSize: 2,
mode: { name: 'javascript', json: true },
theme: editorTheme,
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
showCursorWhenSelecting: true,
readOnly: readOnly ? 'nocursor' : false,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
extraKeys: commonKeys,
});

newEditor.addKeyMap({
'Cmd-Space'() {
newEditor.showHint({ completeSingle: false, container });
},
'Ctrl-Space'() {
newEditor.showHint({ completeSingle: false, container });
},
'Alt-Space'() {
newEditor.showHint({ completeSingle: false, container });
},
'Shift-Space'() {
newEditor.showHint({ completeSingle: false, container });
},
});

newEditor.on('keyup', (editorInstance, event) => {
const code = event.keyCode;
if (
(code >= 65 && code <= 90) || // letters
(!event.shiftKey && code >= 48 && code <= 57) || // numbers
(event.shiftKey && code === 189) || // underscore
(event.shiftKey && code === 222) // "
) {
editorInstance.execCommand('autocomplete');
}
});

setHeaderEditor(newEditor);
});
}, [editorTheme, readOnly, setHeaderEditor]);

useSynchronizeValue(headerEditor, value);

useChangeHandler(headerEditor, onEdit);

useCompletion(headerEditor, onHintInformationRender);

useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], onRunQuery);
useKeyMap(headerEditor, ['Shift-Ctrl-P'], onPrettifyQuery);
useKeyMap(headerEditor, ['Shift-Ctrl-M'], onMergeQuery);

useResizeEditor(headerEditor, ref);

return ref;
}
Loading

0 comments on commit 029ddf8

Please sign in to comment.