Skip to content

Commit

Permalink
Merge pull request #165 from kobra-dev/kbar
Browse files Browse the repository at this point in the history
Command Pallete
  • Loading branch information
Merlin04 authored Jan 18, 2022
2 parents 9c293cb + 90e85a4 commit 3657943
Show file tree
Hide file tree
Showing 13 changed files with 905 additions and 47 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"graphql": "15.5.0",
"intro.js-react": "^0.5.0",
"js-cookie": "2.2.1",
"kbar": "^0.1.0-beta.25",
"kobra.js": "0.1.6",
"next": "11.1.3",
"notistack": "^1.0.9",
Expand Down
47 changes: 6 additions & 41 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ export default function Editor() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getProjectDetailsData.data?.project.id]);

useEffect(() => {
window.addEventListener("kobranewproject", newEmptyProject);
return () =>
window.removeEventListener("kobranewproject", newEmptyProject);
}, []);

if (openProjectId && getProjectDetailsData.loading) {
return (
<Loader>
Expand Down Expand Up @@ -339,47 +345,6 @@ export default function Editor() {
return !(newData.errors || !newData.data);
}

/*async function fork() {
if (!user && !(await login())) {
return;
}
// If the user just logged in the hook hasn't updated yet
const currentUser = user ?? firebase.auth().currentUser;
if (!currentUser)
throw new Error("User is undefined when trying to fork");
const newData = await gqlAddProject({
variables: {
name: openProjectName,
isPublic: false,
projectJson: getSaveData(),
description:
getProjectDetailsData.data?.project
?.description,
summary:
getProjectDetailsData.data?.project?.summary,
parentId: openProjectId
}
});
if (newData.errors || !newData.data) {
enqueueSnackbar(
"Fork failed" + newData?.errors?.[0].message
? `: ${newData.errors[0].message}`
: "",
{
variant: "error"
}
);
} else {
const id = newData.data.addProject.id;
setOpenProjectId(id);
setQueryString(openProjectId + TITLE_SUFFIX, "?id=" + id);
enqueueSnackbar("Save successful!", { variant: "success" });
}
}*/

// The function called by the autosaver provider
// It will only ever be called if the project exists in the DB so no need to worry about forking or creating a project
async function autosave(): Promise<boolean> {
Expand Down
7 changes: 6 additions & 1 deletion src/components/auth/FinishSignupDialogProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ export default function FinishSignupDialogProvider(props: {
useEffect(() => {
// The user is signed in with SSO and the username is not set
const user = firebase.auth().currentUser;
if (user && user.providerData[0] && !usernameLoading && !username) {
if (
user &&
user.providerData[0].providerId !== "password" &&
!usernameLoading &&
!username
) {
setFsOpen(true);
}
// No need to depend on user because useUsername does that for us
Expand Down
7 changes: 6 additions & 1 deletion src/components/auth/LoginDialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Button, Dialog } from "@material-ui/core";
import React, { createContext, useCallback, useContext, useState } from "react";
import { getKBarLock, releaseKBarLock } from "../kbar/kbar";
import Login, { LoginTab } from "./Login";

type LoginFunction = { (initialTab?: LoginTab): Promise<boolean> };
Expand All @@ -15,9 +16,13 @@ export default function LoginDialogProvider(props: {

const loginFn = useCallback(
(initialTab?: LoginTab) => {
const kbarLock = getKBarLock();
setLoginOpen(initialTab ?? LoginTab.LOGIN);
return new Promise<boolean>((resolve) => {
setOpenResolve(() => resolve);
setOpenResolve(() => (val: boolean) => {
resolve(val);
releaseKBarLock(kbarLock);
});
});
},
[setLoginOpen, setOpenResolve]
Expand Down
235 changes: 235 additions & 0 deletions src/components/kbar/InternalEvents_patched.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// This is copied from https://github.com/timc1/kbar/blob/main/src/InternalEvents.tsx
// and is patched to remove `useDocumentLock` so we can use the MUI modal

import { useKBar, VisualState } from "kbar";
import * as React from "react";

/* code copied from utils.ts, because it isn't exported from the kbar module */
const SSR = typeof window === "undefined";
const isMac = !SSR && window.navigator.platform === "MacIntel";

export function isModKey(
event: KeyboardEvent | MouseEvent | React.KeyboardEvent
) {
return isMac ? event.metaKey : event.ctrlKey;
}

export function shouldRejectKeystrokes(
{
ignoreWhenFocused
}: {
ignoreWhenFocused: string[];
} = { ignoreWhenFocused: [] }
) {
const inputs = ["input", "textarea", ...ignoreWhenFocused].map((el) =>
el.toLowerCase()
);

const activeElement = document.activeElement;
const ignoreStrokes =
activeElement &&
(inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1 ||
activeElement.attributes.getNamedItem("role")?.value ===
"textbox" ||
activeElement.attributes.getNamedItem("contenteditable")?.value ===
"true");

return ignoreStrokes;
}
/* end code copied from utils.ts */

type Timeout = ReturnType<typeof setTimeout>;

export function InternalEvents() {
useToggleHandler();
useShortcuts();
useFocusHandler();
return null;
}

/**
* `useToggleHandler` handles the keyboard events for toggling kbar.
*/
function useToggleHandler() {
const { query, options, visualState, showing } = useKBar((state) => ({
visualState: state.visualState,
showing: state.visualState !== VisualState.hidden
}));

React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (
isModKey(event) &&
event.key === "k" &&
event.defaultPrevented === false
) {
event.preventDefault();
query.toggle();

if (showing) {
options.callbacks?.onClose?.();
} else {
options.callbacks?.onOpen?.();
}
}
if (event.key === "Escape") {
if (showing) {
event.stopPropagation();
options.callbacks?.onClose?.();
}

query.setVisualState((vs) => {
if (
vs === VisualState.hidden ||
vs === VisualState.animatingOut
) {
return vs;
}
return VisualState.animatingOut;
});
}
}

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [options.callbacks, query, showing]);

const timeoutRef = React.useRef<Timeout>();
const runAnimateTimer = React.useCallback(
(vs: VisualState.animatingIn | VisualState.animatingOut) => {
let ms = 0;
if (vs === VisualState.animatingIn) {
ms = options.animations?.enterMs || 0;
}
if (vs === VisualState.animatingOut) {
ms = options.animations?.exitMs || 0;
}

clearTimeout(timeoutRef.current as Timeout);
timeoutRef.current = setTimeout(() => {
let backToRoot = false;

// TODO: setVisualState argument should be a function or just a VisualState value.
query.setVisualState(() => {
const finalVs =
vs === VisualState.animatingIn
? VisualState.showing
: VisualState.hidden;

if (finalVs === VisualState.hidden) {
backToRoot = true;
}

return finalVs;
});

if (backToRoot) {
query.setCurrentRootAction(null);
}
}, ms);
},
[options.animations?.enterMs, options.animations?.exitMs, query]
);

React.useEffect(() => {
switch (visualState) {
case VisualState.animatingIn:
case VisualState.animatingOut:
runAnimateTimer(visualState);
break;
}
}, [runAnimateTimer, visualState]);
}

/**
* `useShortcuts` registers and listens to keyboard strokes and
* performs actions for patterns that match the user defined `shortcut`.
*/
function useShortcuts() {
const { actions, query, options } = useKBar((state) => ({
actions: state.actions
}));

React.useEffect(() => {
const actionsList = Object.keys(actions).map((key) => actions[key]);

let buffer: string[] = [];
let lastKeyStrokeTime = Date.now();

function handleKeyDown(event: KeyboardEvent) {
const key = event.key?.toLowerCase();

if (shouldRejectKeystrokes() || event.metaKey || key === "shift") {
return;
}

const currentTime = Date.now();

if (currentTime - lastKeyStrokeTime > 400) {
buffer = [];
}

buffer.push(key);
lastKeyStrokeTime = currentTime;
const bufferString = buffer.join("");

for (let action of actionsList) {
if (!action.shortcut) {
continue;
}
if (action.shortcut.join("") === bufferString) {
event.preventDefault();
if (action.children?.length) {
query.setCurrentRootAction(action.id);
query.toggle();
options.callbacks?.onOpen?.();
} else {
action.command?.perform();
options.callbacks?.onSelectAction?.(action);
}

buffer = [];
break;
}
}
}

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actions, query]);
}

/**
* `useFocusHandler` ensures that focus is set back on the element which was
* in focus prior to kbar being triggered.
*/
function useFocusHandler() {
const { isShowing } = useKBar((state) => ({
isShowing:
state.visualState === VisualState.showing ||
state.visualState === VisualState.animatingIn
}));

const activeElementRef = React.useRef<HTMLElement | null>(null);

React.useEffect(() => {
if (isShowing) {
activeElementRef.current = document.activeElement as HTMLElement;
return;
}

// This fixes an issue on Safari where closing kbar causes the entire
// page to scroll to the bottom. The reason this was happening was due
// to the search input still in focus when we removed it from the dom.
const currentActiveElement = document.activeElement as HTMLElement;
if (currentActiveElement?.tagName.toLowerCase() === "input") {
currentActiveElement.blur();
}

const activeElement = activeElementRef.current;
if (activeElement) {
activeElement.focus();
}
}, [isShowing]);
}
18 changes: 18 additions & 0 deletions src/components/kbar/KBarContextProvider_patched.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// This is (mostly) copied from https://github.com/timc1/kbar/blob/main/src/KBarContextProvider.tsx
// and is patched to use our custom InternalEvents_patched component

import { useStore } from "./useStore_patched";
import * as React from "react";
import { InternalEvents } from "./InternalEvents_patched";
import { KBarContext, KBarProviderProps } from "kbar";

export const KBarProvider: React.FC<KBarProviderProps> = (props) => {
const contextValue = useStore(props);

return (
<KBarContext.Provider value={contextValue}>
<InternalEvents />
{props.children}
</KBarContext.Provider>
);
};
30 changes: 30 additions & 0 deletions src/components/kbar/KBarPositioner_patched.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { forwardRef } from "react";

// This is a custom component using the styles from KBarPositioner that forwards the ref correctly
type KBarPositionerFwdRefProps = {
children: React.ReactNode;
style?: React.CSSProperties;
};
// eslint-disable-next-line react/display-name
const KBarPositionerFwdRef = forwardRef<
HTMLDivElement,
KBarPositionerFwdRefProps
>((props: KBarPositionerFwdRefProps, ref) => (
<div
ref={ref}
style={{
position: "fixed",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
width: "100%",
inset: "0px",
padding: "14vh 16px 16px",
...(props.style ?? {})
}}
>
{props.children}
</div>
));

export default KBarPositionerFwdRef;
Loading

0 comments on commit 3657943

Please sign in to comment.