feat: Trigger cmd+k from sidebar (#3149)

* feat: Trigger cmd+k from sidebar

* Add hint when opening command bar from sidebar
This commit is contained in:
Tom Moor
2022-02-22 20:13:56 -08:00
committed by GitHub
parent d75af27267
commit 63265b49ea
15 changed files with 173 additions and 67 deletions

View File

@@ -19,14 +19,19 @@ import {
githubIssuesUrl, githubIssuesUrl,
} from "@shared/utils/urlHelpers"; } from "@shared/utils/urlHelpers";
import stores from "~/stores"; import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts"; import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections"; import {
NavigationSection,
NoSection,
RecentSearchesSection,
} from "~/actions/sections";
import history from "~/utils/history"; import history from "~/utils/history";
import { import {
settingsPath, settingsPath,
homePath, homePath,
searchUrl, searchPath,
draftsPath, draftsPath,
templatesPath, templatesPath,
archivePath, archivePath,
@@ -42,14 +47,24 @@ export const navigateToHome = createAction({
visible: ({ location }) => location.pathname !== homePath(), visible: ({ location }) => location.pathname !== homePath(),
}); });
export const navigateToSearch = createAction({ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: ({ t }) => t("Search"), createAction({
section: NavigationSection, section: RecentSearchesSection,
shortcut: ["/"], name: searchQuery.query,
icon: <SearchIcon />, icon: <SearchIcon />,
perform: () => history.push(searchUrl()), perform: () => history.push(searchPath(searchQuery.query)),
visible: ({ location }) => location.pathname !== searchUrl(), });
});
export const navigateToSearchQuery = (searchQuery: string) =>
createAction({
id: "search",
section: NoSection,
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
icon: <SearchIcon />,
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToDrafts = createAction({ export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"), name: ({ t }) => t("Drafts"),
@@ -70,6 +85,7 @@ export const navigateToTemplates = createAction({
export const navigateToArchive = createAction({ export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"), name: ({ t }) => t("Archive"),
section: NavigationSection, section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />, icon: <ArchiveIcon />,
perform: () => history.push(archivePath()), perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(), visible: ({ location }) => location.pathname !== archivePath(),
@@ -145,7 +161,6 @@ export const logout = createAction({
export const rootNavigationActions = [ export const rootNavigationActions = [
navigateToHome, navigateToHome,
navigateToSearch,
navigateToDrafts, navigateToDrafts,
navigateToTemplates, navigateToTemplates,
navigateToArchive, navigateToArchive,

View File

@@ -1,6 +1,6 @@
import { flattenDeep } from "lodash"; import { flattenDeep } from "lodash";
import * as React from "react"; import * as React from "react";
import { $Diff } from "utility-types"; import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { import {
Action, Action,
@@ -10,17 +10,10 @@ import {
MenuItemWithChildren, MenuItemWithChildren,
} from "~/types"; } from "~/types";
export function createAction( export function createAction(definition: Optional<Action, "id">): Action {
definition: $Diff<
Action,
{
id?: string;
}
>
): Action {
return { return {
id: uuidv4(),
...definition, ...definition,
id: uuidv4(),
}; };
} }

View File

@@ -11,3 +11,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People"); export const UserSection = ({ t }: ActionContext) => t("People");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
export const NoSection = "";

View File

@@ -11,7 +11,7 @@ import Sidebar from "~/components/Sidebar";
import SettingsSidebar from "~/components/Sidebar/Settings"; import SettingsSidebar from "~/components/Sidebar/Settings";
import history from "~/utils/history"; import history from "~/utils/history";
import { import {
searchUrl, searchPath,
matchDocumentSlug as slug, matchDocumentSlug as slug,
newDocumentPath, newDocumentPath,
settingsPath, settingsPath,
@@ -49,7 +49,7 @@ class AuthenticatedLayout extends React.Component<Props> {
if (!ev.metaKey && !ev.ctrlKey) { if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
history.push(searchUrl()); history.push(searchPath());
} }
}; };

View File

@@ -1,24 +1,24 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar"; import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { QuestionMarkIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Portal } from "react-portal"; import { Portal } from "react-portal";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "~/components/CommandBarResults"; import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root"; import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions"; import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import { CommandBarAction } from "~/types"; import { CommandBarAction } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
export const CommandBarOptions = { import Text from "./Text";
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() { function CommandBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { ui } = useStores();
useCommandBarActions(rootActions); useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({ const { rootAction } = useKBar((state) => ({
@@ -30,6 +30,8 @@ function CommandBar() {
})); }));
return ( return (
<>
<SearchActions />
<KBarPortal> <KBarPortal>
<Positioner> <Positioner>
<Animator> <Animator>
@@ -41,9 +43,21 @@ function CommandBar() {
}`} }`}
/> />
<CommandBarResults /> <CommandBarResults />
{ui.showModKHint && (
<Hint size="small" type="tertiary">
<QuestionMarkIcon size={18} color="currentColor" />
{t(
"Open search from anywhere with the {{ shortcut }} shortcut",
{
shortcut: `${metaDisplay} + k`,
}
)}
</Hint>
)}
</Animator> </Animator>
</Positioner> </Positioner>
</KBarPortal> </KBarPortal>
</>
); );
} }
@@ -59,6 +73,20 @@ function KBarPortal({ children }: { children: React.ReactNode }) {
return <Portal>{children}</Portal>; return <Portal>{children}</Portal>;
} }
const Hint = styled(Text)`
display: flex;
align-items: center;
gap: 4px;
background: ${(props) => props.theme.secondaryBackground};
border-top: 1px solid ${(props) => props.theme.background};
margin: 1px 0 0;
padding: 6px 16px;
width: 100%;
position: absolute;
bottom: 0;
`;
const Positioner = styled(KBarPositioner)` const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar}; z-index: ${(props) => props.theme.depths.commandBar};
`; `;

View File

@@ -1,14 +1,18 @@
import { useMatches, KBarResults } from "kbar"; import { useMatches, KBarResults } from "kbar";
import { orderBy } from "lodash";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import CommandBarItem from "~/components/CommandBarItem"; import CommandBarItem from "~/components/CommandBarItem";
import { NoSection } from "~/actions/sections";
export default function CommandBarResults() { export default function CommandBarResults() {
const { results, rootActionId } = useMatches(); const { results, rootActionId } = useMatches();
return ( return (
<KBarResults <KBarResults
items={results} items={orderBy(results, (item) =>
typeof item !== "string" && item.section === NoSection ? -1 : 1
)}
maxHeight={400} maxHeight={400}
onRender={({ item, active }) => onRender={({ item, active }) =>
typeof item === "string" ? ( typeof item === "string" ? (

View File

@@ -7,7 +7,7 @@ import styled, { useTheme } from "styled-components";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard"; import { isModKey } from "~/utils/keyboard";
import { searchUrl } from "~/utils/routeHelpers"; import { searchPath } from "~/utils/routeHelpers";
import Input from "./Input"; import Input from "./Input";
type Props = { type Props = {
@@ -51,7 +51,7 @@ function InputSearchPage({
if (ev.key === "Enter") { if (ev.key === "Enter") {
ev.preventDefault(); ev.preventDefault();
history.push( history.push(
searchUrl(ev.currentTarget.value, { searchPath(ev.currentTarget.value, {
collectionId, collectionId,
ref: source, ref: source,
}) })

View File

@@ -0,0 +1,26 @@
import { useKBar } from "kbar";
import * as React from "react";
import {
navigateToRecentSearchQuery,
navigateToSearchQuery,
} from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
searches.fetchPage({});
}, [searches]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
useCommandBarActions(searchQuery ? [navigateToSearchQuery(searchQuery)] : []);
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
return null;
}

View File

@@ -1,3 +1,4 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { import {
EditIcon, EditIcon,
@@ -10,6 +11,7 @@ import * as React from "react";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Bubble from "~/components/Bubble"; import Bubble from "~/components/Bubble";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
@@ -21,10 +23,10 @@ import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu"; import AccountMenu from "~/menus/AccountMenu";
import { import {
homePath, homePath,
searchUrl,
draftsPath, draftsPath,
templatesPath, templatesPath,
settingsPath, settingsPath,
searchPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink"; import ArchiveLink from "./components/ArchiveLink";
@@ -38,9 +40,12 @@ import TrashLink from "./components/TrashLink";
function MainSidebar() { function MainSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { policies, documents } = useStores(); const { ui, policies, documents } = useStores();
const team = useCurrentTeam(); const team = useCurrentTeam();
const user = useCurrentUser(); const user = useCurrentUser();
const { query } = useKBar();
const location = useLocation();
const history = useHistory();
React.useEffect(() => { React.useEffect(() => {
documents.fetchDrafts(); documents.fetchDrafts();
@@ -57,6 +62,16 @@ function MainSidebar() {
); );
const can = policies.abilities(team.id); const can = policies.abilities(team.id);
const handleSearch = React.useCallback(() => {
const isSearching = location.pathname.startsWith(searchPath());
if (isSearching) {
history.push(searchPath());
} else {
ui.enableModKHint();
query.toggle();
}
}, [ui, location, history, query]);
return ( return (
<Sidebar ref={handleSidebarRef}> <Sidebar ref={handleSidebarRef}>
{dndArea && ( {dndArea && (
@@ -81,12 +96,7 @@ function MainSidebar() {
label={t("Home")} label={t("Home")}
/> />
<SidebarLink <SidebarLink
to={{ onClick={handleSearch}
pathname: searchUrl(),
state: {
fromMenu: true,
},
}}
icon={<SearchIcon color="currentColor" />} icon={<SearchIcon color="currentColor" />}
label={t("Search")} label={t("Search")}
exact={false} exact={false}

View File

@@ -8,7 +8,6 @@ import { Router } from "react-router-dom";
import { initI18n } from "@shared/i18n"; import { initI18n } from "@shared/i18n";
import stores from "~/stores"; import stores from "~/stores";
import Analytics from "~/components/Analytics"; import Analytics from "~/components/Analytics";
import { CommandBarOptions } from "~/components/CommandBar";
import Dialogs from "~/components/Dialogs"; import Dialogs from "~/components/Dialogs";
import ErrorBoundary from "~/components/ErrorBoundary"; import ErrorBoundary from "~/components/ErrorBoundary";
import PageTheme from "~/components/PageTheme"; import PageTheme from "~/components/PageTheme";
@@ -53,6 +52,17 @@ if ("serviceWorker" in window.navigator) {
// Make sure to return the specific export containing the feature bundle. // Make sure to return the specific export containing the feature bundle.
const loadFeatures = () => import("./utils/motion").then((res) => res.default); const loadFeatures = () => import("./utils/motion").then((res) => res.default);
const commandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
callbacks: {
onClose: () => stores.ui.disableModKHint(),
onQueryChange: () => stores.ui.disableModKHint(),
},
};
if (element) { if (element) {
const App = () => ( const App = () => (
<React.StrictMode> <React.StrictMode>
@@ -60,7 +70,7 @@ if (element) {
<Analytics> <Analytics>
<Theme> <Theme>
<ErrorBoundary> <ErrorBoundary>
<KBarProvider actions={[]} options={CommandBarOptions}> <KBarProvider actions={[]} options={commandBarOptions}>
<LazyMotion features={loadFeatures}> <LazyMotion features={loadFeatures}>
<Router history={history}> <Router history={history}>
<> <>

View File

@@ -23,7 +23,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Text from "~/components/Text"; import Text from "~/components/Text";
import withStores from "~/components/withStores"; import withStores from "~/components/withStores";
import { searchUrl } from "~/utils/routeHelpers"; import { searchPath } from "~/utils/routeHelpers";
import { decodeURIComponentSafe } from "~/utils/urls"; import { decodeURIComponentSafe } from "~/utils/urls";
import CollectionFilter from "./components/CollectionFilter"; import CollectionFilter from "./components/CollectionFilter";
import DateFilter from "./components/DateFilter"; import DateFilter from "./components/DateFilter";
@@ -247,7 +247,7 @@ class Search extends React.Component<Props> {
updateLocation = (query: string) => { updateLocation = (query: string) => {
this.props.history.replace({ this.props.history.replace({
pathname: searchUrl(query), pathname: searchPath(query),
search: this.props.location.search, search: this.props.location.search,
}); });
}; };

View File

@@ -9,7 +9,7 @@ import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { hover } from "~/styles"; import { hover } from "~/styles";
import { searchUrl } from "~/utils/routeHelpers"; import { searchPath } from "~/utils/routeHelpers";
function RecentSearches() { function RecentSearches() {
const { searches } = useStores(); const { searches } = useStores();
@@ -26,7 +26,7 @@ function RecentSearches() {
<List> <List>
{searches.recent.map((searchQuery) => ( {searches.recent.map((searchQuery) => (
<ListItem key={searchQuery.id}> <ListItem key={searchQuery.id}>
<RecentSearch to={searchUrl(searchQuery.query)}> <RecentSearch to={searchPath(searchQuery.query)}>
{searchQuery.query} {searchQuery.query}
<Tooltip tooltip={t("Remove search")} delay={150}> <Tooltip tooltip={t("Remove search")} delay={150}>
<RemoveButton <RemoveButton

View File

@@ -39,6 +39,9 @@ class UiStore {
@observable @observable
observingUserId: string | undefined; observingUserId: string | undefined;
@observable
showModKHint = false;
@observable @observable
progressBarVisible = false; progressBarVisible = false;
@@ -211,6 +214,16 @@ class UiStore {
this.mobileSidebarVisible = !this.mobileSidebarVisible; this.mobileSidebarVisible = !this.mobileSidebarVisible;
}; };
@action
enableModKHint = () => {
this.showModKHint = true;
};
@action
disableModKHint = () => {
this.showModKHint = false;
};
@action @action
hideMobileSidebar = () => { hideMobileSidebar = () => {
this.mobileSidebarVisible = false; this.mobileSidebarVisible = false;

View File

@@ -91,7 +91,7 @@ export function newDocumentPath(
return `/collection/${collectionId}/new?${queryString.stringify(params)}`; return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
} }
export function searchUrl( export function searchPath(
query?: string, query?: string,
params: { params: {
collectionId?: string; collectionId?: string;

View File

@@ -26,6 +26,7 @@
"Create template": "Create template", "Create template": "Create template",
"Home": "Home", "Home": "Home",
"Search": "Search", "Search": "Search",
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
"Drafts": "Drafts", "Drafts": "Drafts",
"Templates": "Templates", "Templates": "Templates",
"Archive": "Archive", "Archive": "Archive",
@@ -49,6 +50,7 @@
"Document": "Document", "Document": "Document",
"Navigation": "Navigation", "Navigation": "Navigation",
"People": "People", "People": "People",
"Recent searches": "Recent searches",
"currently editing": "currently editing", "currently editing": "currently editing",
"currently viewing": "currently viewing", "currently viewing": "currently viewing",
"previously edited": "previously edited", "previously edited": "previously edited",
@@ -59,6 +61,7 @@
"Collapse": "Collapse", "Collapse": "Collapse",
"Expand": "Expand", "Expand": "Expand",
"Type a command or search": "Type a command or search", "Type a command or search": "Type a command or search",
"Open search from anywhere with the {{ shortcut }} shortcut": "Open search from anywhere with the {{ shortcut }} shortcut",
"Server connection lost": "Server connection lost", "Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online", "Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu", "Submenu": "Submenu",
@@ -364,11 +367,8 @@
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}", "Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}", "Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
"Document updated by {{userName}}": "Document updated by {{userName}}", "Document updated by {{userName}}": "Document updated by {{userName}}",
"This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?", "You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?", "Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"Untitled template": "Untitled template",
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…", "Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
"Hide contents": "Hide contents", "Hide contents": "Hide contents",
"Show contents": "Show contents", "Show contents": "Show contents",
@@ -382,7 +382,10 @@
"Publish": "Publish", "Publish": "Publish",
"Publishing": "Publishing", "Publishing": "Publishing",
"Sorry, it looks like you dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document", "Sorry, it looks like you dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document",
"Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.": "Youre editing a template. Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents from this template.", "This template will be permanently deleted in <2></2> unless restored.": "This template will be permanently deleted in <2></2> unless restored.",
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
"Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <2></2> control to add placeholders that can be filled out when creating new documents",
"Youre editing a template": "Youre editing a template",
"Archived by {{userName}}": "Archived by {{userName}}", "Archived by {{userName}}": "Archived by {{userName}}",
"Deleted by {{userName}}": "Deleted by {{userName}}", "Deleted by {{userName}}": "Deleted by {{userName}}",
"Observing {{ userName }}": "Observing {{ userName }}", "Observing {{ userName }}": "Observing {{ userName }}",
@@ -506,7 +509,6 @@
"Past week": "Past week", "Past week": "Past week",
"Past month": "Past month", "Past month": "Past month",
"Past year": "Past year", "Past year": "Past year",
"Recent searches": "Recent searches",
"Remove search": "Remove search", "Remove search": "Remove search",
"Active documents": "Active documents", "Active documents": "Active documents",
"Documents in collections you are able to access": "Documents in collections you are able to access", "Documents in collections you are able to access": "Documents in collections you are able to access",