mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78dd038d2f | |||
| 2e0e89b596 | |||
| dbb2f315c3 | |||
| 2c89b48b47 | |||
| 8edd5101f7 | |||
| 45fdef9bce | |||
| 3a67d94eb0 | |||
| 2070b1ffc5 | |||
| cf69c4949e | |||
| bff1351e66 | |||
| cd9aecf238 | |||
| 6012da1405 | |||
| 9fb99b925c | |||
| 405dbc48a6 | |||
| 8f018a2eee | |||
| 3a30f80063 | |||
| 400e2b99d0 | |||
| 0974f7e296 |
@@ -19,6 +19,7 @@ import { type MenuItem as TMenuItem } from "types";
|
||||
|
||||
type Props = {|
|
||||
items: TMenuItem[],
|
||||
hide?: Function,
|
||||
|};
|
||||
|
||||
const Disclosure = styled(ExpandedIcon)`
|
||||
@@ -144,7 +145,7 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
}
|
||||
|
||||
if (item.type === "heading") {
|
||||
return <Header>{item.title}</Header>;
|
||||
return <Header key={index}>{item.title}</Header>;
|
||||
}
|
||||
|
||||
console.warn("Unrecognized menu item", item);
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {|
|
||||
...InputProps,
|
||||
placeholder?: string,
|
||||
value?: string,
|
||||
onChange: (event: SyntheticInputEvent<>) => mixed,
|
||||
onChange?: (event: SyntheticInputEvent<>) => mixed,
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|
||||
|};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import Guide from "components/Guide";
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import QuickMenu from "components/QuickMenu";
|
||||
import Sidebar from "components/Sidebar";
|
||||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
@@ -83,7 +84,7 @@ class Layout extends React.Component<Props> {
|
||||
this.keyboardShortcutsOpen = false;
|
||||
};
|
||||
|
||||
@keydown(["t", "/", `${meta}+k`])
|
||||
@keydown(["t", "/"])
|
||||
goToSearch(ev: SyntheticEvent<>) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -175,6 +176,7 @@ class Layout extends React.Component<Props> {
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Guide>
|
||||
<QuickMenu />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ const Modal = ({
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScrollhideOnEsc
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(props) => (
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// @flow
|
||||
import { motion } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
import InputSearch from "../InputSearch";
|
||||
import useStores from "hooks/useStores";
|
||||
import { fadeAndSlideUp } from "styles/animations";
|
||||
|
||||
function QuickMenu() {
|
||||
const { quickMenu } = useStores();
|
||||
const dialog = useDialogState({ modal: true, animated: 250 });
|
||||
const [isClicked, setIsClicked] = React.useState(false);
|
||||
const [activeCommand, setActiveCommand] = React.useState<number>(1);
|
||||
const activeCommandRef = React.useRef();
|
||||
const { t } = useTranslation();
|
||||
let order = 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveCommand(1);
|
||||
}, [quickMenu.resolvedMenuItems]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dialog.visible) {
|
||||
quickMenu.reset();
|
||||
setActiveCommand(1);
|
||||
}
|
||||
}, [quickMenu, dialog.visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "k") {
|
||||
dialog.show();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeCommandRef.current) {
|
||||
scrollIntoView(activeCommandRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
quickMenu.setSearchTerm(event.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dialog.hide();
|
||||
quickMenu.reset();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setActiveCommand((prev) => (prev < order ? ++prev : prev));
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
setActiveCommand((prev) => (prev > 1 ? --prev : prev));
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && activeCommandRef.current) {
|
||||
activeCommandRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimation = () => {
|
||||
setIsClicked(true);
|
||||
setTimeout(() => setIsClicked(false), 100);
|
||||
};
|
||||
|
||||
const constructBlock = (item, order, setActiveCommand) => {
|
||||
return (
|
||||
<CommandItem
|
||||
tabIndex="-1"
|
||||
data-order={order}
|
||||
key={order}
|
||||
role="option"
|
||||
ref={activeCommand === order ? activeCommandRef : undefined}
|
||||
onMouseEnter={() => setActiveCommand(order)}
|
||||
aria-selected={activeCommand === order}
|
||||
selected={activeCommand === order}
|
||||
onClick={(e) => {
|
||||
if (item.items) {
|
||||
handleAnimation();
|
||||
quickMenu.handleNestedItems(item);
|
||||
} else {
|
||||
dialog.hide();
|
||||
item.onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Container align="center">
|
||||
<MenuIconWrapper>{item.icon}</MenuIconWrapper>
|
||||
{item.title}
|
||||
</Container>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const data = quickMenu.resolvedMenuItems?.map((context) => {
|
||||
return (
|
||||
<div key={context.title}>
|
||||
<Header>{context.title}</Header>
|
||||
<CommandList role="group">
|
||||
{context.items.map((item) =>
|
||||
constructBlock(item, ++order, setActiveCommand)
|
||||
)}
|
||||
</CommandList>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const term = quickMenu.searchTerm;
|
||||
const variant = {
|
||||
open: { scale: 0.9, transition: "ease" },
|
||||
closed: { scale: 1, transition: "ease" },
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(props) => (
|
||||
<Backdrop {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={t("Quick menu")}
|
||||
hideOnEsc
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{(props) => (
|
||||
<motion.div
|
||||
animate={isClicked ? "open" : "closed"}
|
||||
variants={variant}
|
||||
>
|
||||
<Content {...props} column>
|
||||
<Badges>
|
||||
{quickMenu.path.map((b) => (
|
||||
<Path
|
||||
onClick={() => {
|
||||
handleAnimation();
|
||||
quickMenu.handlePathClick(b);
|
||||
}}
|
||||
key={b}
|
||||
>
|
||||
{b}
|
||||
</Path>
|
||||
))}
|
||||
</Badges>
|
||||
<InputWrapper>
|
||||
<InputSearch onChange={handleChange} value={term} />
|
||||
</InputWrapper>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<Wrapper width={width} height={height}>
|
||||
<Scrollable topShadow>{data}</Scrollable>
|
||||
</Wrapper>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</Content>
|
||||
</motion.div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
const Path = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 1px 5px 2px;
|
||||
border: 1px solid;
|
||||
background-color: ${({ theme }) => theme.slateLight};
|
||||
color: ${({ theme }) => theme.slateDark};
|
||||
border: 1px solid ${({ theme }) => theme.slateLight};
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Badges = styled.div`
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const InputWrapper = styled.div`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const Results = styled.div`
|
||||
height: calc(100% - 64px);
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const CommandItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
font-family: var(--font-main);
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 8px 16px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
color: var(--gray6);
|
||||
white-space: nowrap;
|
||||
-webkit-transition: color 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
transition: color 0.1s cubic-bezier(0, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
${(props) =>
|
||||
props.selected && {
|
||||
color: `${props.theme.white}`,
|
||||
background: `${props.theme.primary}`,
|
||||
boxShadow: "none",
|
||||
cursor: "pointer",
|
||||
svg: {
|
||||
fill: `${props.theme.white}`,
|
||||
},
|
||||
}}
|
||||
`;
|
||||
|
||||
const CommandList = styled.ul`
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
height: ${(props) => props.height + "px"};
|
||||
width: ${(props) => props.width + "px"};
|
||||
flex: 1 1 auto;
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
background: ${(props) => props.theme.background};
|
||||
width: 40vw;
|
||||
height: 50vh;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin: 20vh auto;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
animation: ${fadeAndSlideUp} 200ms ease-in-out;
|
||||
transform-origin: 25% 0;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) => props.theme.backdrop};
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
`;
|
||||
|
||||
export default observer(QuickMenu);
|
||||
@@ -55,6 +55,7 @@ const Wrapper = styled.div`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { MoonIcon, SunIcon } from "outline-icons";
|
||||
import { KeyboardIcon, MoonIcon, SunIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import { useHistory } from "react-router";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
changelog,
|
||||
@@ -16,6 +17,7 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Guide from "components/Guide";
|
||||
import convertToCommandItem from "../utils/convertToCommandItem";
|
||||
import useBoolean from "hooks/useBoolean";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
@@ -28,15 +30,16 @@ type Props = {|
|
||||
|
||||
function AccountMenu(props: Props) {
|
||||
const [sessions] = useSessions();
|
||||
const history = useHistory();
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [8, 0],
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
});
|
||||
const { auth, ui } = useStores();
|
||||
const { auth, ui, quickMenu } = useStores();
|
||||
const previousTheme = usePrevious(ui.theme);
|
||||
const { theme, resolvedTheme } = ui;
|
||||
const team = useCurrentTeam();
|
||||
const previousTheme = usePrevious(theme);
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
keyboardShortcutsOpen,
|
||||
@@ -63,6 +66,7 @@ function AccountMenu(props: Props) {
|
||||
{
|
||||
title: t("Keyboard shortcuts"),
|
||||
onClick: handleKeyboardShortcutsOpen,
|
||||
icon: <KeyboardIcon />,
|
||||
},
|
||||
{
|
||||
title: t("API documentation"),
|
||||
@@ -136,6 +140,14 @@ function AccountMenu(props: Props) {
|
||||
ui,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
quickMenu.addContext({
|
||||
id: "account",
|
||||
items: convertToCommandItem(items, history),
|
||||
title: t("Account"),
|
||||
});
|
||||
}, [quickMenu, items, t, history]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Guide
|
||||
|
||||
+54
-44
@@ -22,6 +22,7 @@ import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import convertToCommandItem from "../utils/convertToCommandItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
@@ -46,9 +47,8 @@ function CollectionMenu({
|
||||
onClose,
|
||||
}: Props) {
|
||||
const menu = useMenuState({ modal, placement });
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const { ui, documents, policies, quickMenu } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { documents, policies } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -63,7 +63,6 @@ function CollectionMenu({
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
@@ -180,6 +179,20 @@ function CollectionMenu({
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = `collection-${collection.id}`;
|
||||
|
||||
if (ui.activeCollectionId === collection.id) {
|
||||
quickMenu.addContext({
|
||||
id,
|
||||
items: convertToCommandItem(items, history),
|
||||
title: t("Collection"),
|
||||
});
|
||||
}
|
||||
|
||||
return () => quickMenu.removeContext(id);
|
||||
}, [quickMenu, items, collection.id, ui.activeCollectionId, t, history]);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -209,47 +222,44 @@ function CollectionMenu({
|
||||
>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionPermissions(false)}
|
||||
isOpen={showCollectionPermissions}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={showCollectionDelete}
|
||||
onRequestClose={() => setShowCollectionDelete(false)}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={() => setShowCollectionDelete(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionPermissions(false)}
|
||||
isOpen={showCollectionPermissions}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete collection")}
|
||||
isOpen={showCollectionDelete}
|
||||
onRequestClose={() => setShowCollectionDelete(false)}
|
||||
>
|
||||
<CollectionDelete
|
||||
onSubmit={() => setShowCollectionDelete(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+257
-214
@@ -37,6 +37,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Flex from "components/Flex";
|
||||
import Modal from "components/Modal";
|
||||
import convertToCommandItem from "../utils/convertToCommandItem";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -72,7 +73,7 @@ function DocumentMenu({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { policies, collections, documents } = useStores();
|
||||
const { policies, collections, documents, quickMenu, ui } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
@@ -82,7 +83,6 @@ function DocumentMenu({
|
||||
});
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [
|
||||
showPermanentDeleteModal,
|
||||
@@ -93,7 +93,6 @@ function DocumentMenu({
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
@@ -234,6 +233,207 @@ function DocumentMenu({
|
||||
[history, showToast, collection, documents, document.id]
|
||||
);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("Restore 1"),
|
||||
visible: !!collection && (can.restore || can.unarchive),
|
||||
onClick: handleRestore,
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Restore 2"),
|
||||
visible:
|
||||
!collection &&
|
||||
(can.restore || can.unarchive) &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Unpin"),
|
||||
onClick: document.unpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Pin to collection"),
|
||||
onClick: document.pin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Unstar"),
|
||||
onClick: handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
icon: <UnstarredIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Star"),
|
||||
onClick: handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
icon: <StarredIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Edit"),
|
||||
to: editDocumentUrl(document),
|
||||
visible: !!can.update,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
visible: !!can.createChildDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.createChildDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Create template")}…`,
|
||||
onClick: () => setShowTemplateModal(true),
|
||||
visible: !!can.update && !document.isTemplate,
|
||||
icon: <ShapesIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Duplicate"),
|
||||
onClick: handleDuplicate,
|
||||
visible: !!can.update,
|
||||
icon: <DuplicateIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Unpublish"),
|
||||
onClick: handleUnpublish,
|
||||
visible: !!can.unpublish,
|
||||
icon: <UnpublishIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Archive"),
|
||||
onClick: handleArchive,
|
||||
visible: !!can.archive,
|
||||
icon: <ArchiveIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Permanently delete")}…`,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
icon: <CrossIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
visible: !!can.move,
|
||||
icon: <MoveIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("History"),
|
||||
to: isRevision ? documentUrl(document) : documentHistoryUrl(document),
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Print"),
|
||||
onClick: handlePrint,
|
||||
visible: !!showPrint,
|
||||
icon: <PrintIcon />,
|
||||
},
|
||||
],
|
||||
[
|
||||
can.archive,
|
||||
can.createChildDocument,
|
||||
can.delete,
|
||||
can.download,
|
||||
can.move,
|
||||
can.permanentDelete,
|
||||
can.pin,
|
||||
can.restore,
|
||||
can.star,
|
||||
can.unarchive,
|
||||
can.unpin,
|
||||
can.unpublish,
|
||||
can.unstar,
|
||||
can.update,
|
||||
canViewHistory,
|
||||
collection,
|
||||
document,
|
||||
handleArchive,
|
||||
handleDuplicate,
|
||||
handleImportDocument,
|
||||
handlePrint,
|
||||
handleRestore,
|
||||
handleStar,
|
||||
handleUnpublish,
|
||||
handleUnstar,
|
||||
isRevision,
|
||||
restoreItems,
|
||||
showPin,
|
||||
showPrint,
|
||||
showToggleEmbeds,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = `document-${document.id}`;
|
||||
|
||||
if (ui.activeDocumentId === document.id) {
|
||||
quickMenu.addContext({
|
||||
id,
|
||||
items: convertToCommandItem(items, history),
|
||||
title: t("Document"),
|
||||
});
|
||||
}
|
||||
|
||||
return () => quickMenu.removeContext(id);
|
||||
}, [quickMenu, items, document.id, t, ui.activeDocumentId, history]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden>
|
||||
@@ -261,218 +461,61 @@ function DocumentMenu({
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
onClick: handleRestore,
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!collection && !!can.restore && restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Unpin"),
|
||||
onClick: document.unpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Pin to collection"),
|
||||
onClick: document.pin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Unstar"),
|
||||
onClick: handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
icon: <UnstarredIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Star"),
|
||||
onClick: handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
icon: <StarredIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Edit"),
|
||||
to: editDocumentUrl(document),
|
||||
visible: !!can.update,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
visible: !!can.createChildDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.createChildDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Create template")}…`,
|
||||
onClick: () => setShowTemplateModal(true),
|
||||
visible: !!can.update && !document.isTemplate,
|
||||
icon: <ShapesIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Duplicate"),
|
||||
onClick: handleDuplicate,
|
||||
visible: !!can.update,
|
||||
icon: <DuplicateIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Unpublish"),
|
||||
onClick: handleUnpublish,
|
||||
visible: !!can.unpublish,
|
||||
icon: <UnpublishIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Archive"),
|
||||
onClick: handleArchive,
|
||||
visible: !!can.archive,
|
||||
icon: <ArchiveIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Permanently delete")}…`,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
icon: <CrossIcon />,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
visible: !!can.move,
|
||||
icon: <MoveIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Enable embeds"),
|
||||
onClick: document.enableEmbeds,
|
||||
visible: !!showToggleEmbeds && document.embedsDisabled,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Disable embeds"),
|
||||
onClick: document.disableEmbeds,
|
||||
visible: !!showToggleEmbeds && !document.embedsDisabled,
|
||||
icon: <BuildingBlocksIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("History"),
|
||||
to: isRevision
|
||||
? documentUrl(document)
|
||||
: documentHistoryUrl(document),
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Print"),
|
||||
onClick: handlePrint,
|
||||
visible: !!showPrint,
|
||||
icon: <PrintIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.delete && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.permanentDelete && (
|
||||
<Modal
|
||||
title={t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||
isOpen={showPermanentDeleteModal}
|
||||
>
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.delete && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.permanentDelete && (
|
||||
<Modal
|
||||
title={t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||
isOpen={showPermanentDeleteModal}
|
||||
>
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @flow
|
||||
import { observable, computed, action } from "mobx";
|
||||
|
||||
type CommandItem = {|
|
||||
title: React.Node,
|
||||
icon?: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
items?: CommandItem[],
|
||||
|};
|
||||
|
||||
type QuickMenuContext = {|
|
||||
id: string,
|
||||
title: string,
|
||||
items: CommandItem[],
|
||||
priority?: number,
|
||||
|};
|
||||
|
||||
class QuickMenuStore {
|
||||
@observable searchTerm: string = "";
|
||||
@observable contextItems: Map<string, QuickMenuContext> = new Map();
|
||||
@observable states: Map<string, any> = new Map();
|
||||
@observable path: string[] = ["Home"];
|
||||
|
||||
@computed
|
||||
get resolvedMenuItems(): QuickMenuContext[] {
|
||||
const currentPath = this.path[this.path.length - 1];
|
||||
|
||||
if (currentPath === "Home") {
|
||||
let filtered = [];
|
||||
this.contextItems.forEach((context) => {
|
||||
const items = context.items.filter(
|
||||
(item) =>
|
||||
!this.searchTerm ||
|
||||
(typeof item.title === "string" &&
|
||||
item.title.toLowerCase().includes(this.searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
if (items.length) {
|
||||
filtered.push({
|
||||
...context,
|
||||
items,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
} else {
|
||||
let items = this.states.get(currentPath);
|
||||
if (!items) return [];
|
||||
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
!this.searchTerm ||
|
||||
(typeof item.title === "string" &&
|
||||
item.title.toLowerCase().includes(this.searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
return [{ id: currentPath, title: currentPath, items: filtered }];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleNestedItems(item: CommandItem) {
|
||||
this.states.set(item.title, item.items);
|
||||
this.path = [...this.path, item.title];
|
||||
}
|
||||
|
||||
@action
|
||||
setSearchTerm(searchTerm: string): void {
|
||||
this.searchTerm = searchTerm;
|
||||
}
|
||||
|
||||
@action
|
||||
addContext(context: QuickMenuContext): void {
|
||||
this.contextItems.set(context.id, context);
|
||||
}
|
||||
|
||||
@action
|
||||
removeContext(id: string): void {
|
||||
this.contextItems.delete(id);
|
||||
}
|
||||
|
||||
@action
|
||||
reset() {
|
||||
this.setSearchTerm("");
|
||||
this.states.clear();
|
||||
this.path = ["Home"];
|
||||
}
|
||||
|
||||
@action
|
||||
handlePathClick(toPath: string) {
|
||||
if (toPath === "Home") {
|
||||
this.states.clear();
|
||||
this.path = ["Home"];
|
||||
} else {
|
||||
const pathsToRemove = this.path.slice(this.path.indexOf(toPath) + 1);
|
||||
pathsToRemove.forEach((path) => this.states.delete(path));
|
||||
this.path = this.path.slice(0, this.path.indexOf(toPath) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QuickMenuStore;
|
||||
@@ -13,6 +13,7 @@ import IntegrationsStore from "./IntegrationsStore";
|
||||
import MembershipsStore from "./MembershipsStore";
|
||||
import NotificationSettingsStore from "./NotificationSettingsStore";
|
||||
import PoliciesStore from "./PoliciesStore";
|
||||
import QuickMenuStore from "./QuickMenuStore";
|
||||
import RevisionsStore from "./RevisionsStore";
|
||||
import SharesStore from "./SharesStore";
|
||||
import ToastsStore from "./ToastsStore";
|
||||
@@ -34,6 +35,7 @@ export default class RootStore {
|
||||
notificationSettings: NotificationSettingsStore;
|
||||
presence: DocumentPresenceStore;
|
||||
policies: PoliciesStore;
|
||||
quickMenu: QuickMenuStore;
|
||||
revisions: RevisionsStore;
|
||||
shares: SharesStore;
|
||||
ui: UiStore;
|
||||
@@ -57,6 +59,7 @@ export default class RootStore {
|
||||
this.memberships = new MembershipsStore(this);
|
||||
this.notificationSettings = new NotificationSettingsStore(this);
|
||||
this.presence = new DocumentPresenceStore();
|
||||
this.quickMenu = new QuickMenuStore();
|
||||
this.revisions = new RevisionsStore(this);
|
||||
this.shares = new SharesStore(this);
|
||||
this.ui = new UiStore();
|
||||
|
||||
@@ -15,6 +15,9 @@ class UiStore {
|
||||
|
||||
// systemTheme represents the system UI theme (Settings -> General in macOS)
|
||||
@observable systemTheme: "light" | "dark";
|
||||
|
||||
// highlighted items in the sidebar, unfortunately this cannot be accurately
|
||||
// inferred from the route at all times so manually controlled in stoer
|
||||
@observable activeDocumentId: ?string;
|
||||
@observable activeCollectionId: ?string;
|
||||
@observable progressBarVisible: boolean = false;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import History from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { type MenuItem as TMenuItem } from "types";
|
||||
|
||||
type CommandItem = {|
|
||||
title: React.Node,
|
||||
icon?: React.Node,
|
||||
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
|
||||
items?: CommandItem[],
|
||||
|};
|
||||
|
||||
const convertToCommandItem = (data: TMenuItem[], history: History<>) => {
|
||||
return data.reduce((agg: CommandItem[], item) => {
|
||||
if (
|
||||
item.type === "separator" ||
|
||||
item.visible === false ||
|
||||
item.disabled === true ||
|
||||
item.type === "heading"
|
||||
) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const newItem: CommandItem = {
|
||||
title: item.title,
|
||||
icon: item.icon || <MenuIconWrapper />,
|
||||
onClick: () => {},
|
||||
};
|
||||
|
||||
if (item.selected) {
|
||||
newItem.icon = (
|
||||
<MenuIconWrapper>
|
||||
<CheckmarkIcon color="currentColor" />
|
||||
</MenuIconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.to) {
|
||||
newItem.onClick = () => {
|
||||
history.push(item.to);
|
||||
};
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
newItem.onClick = () => {
|
||||
window.location.href = item.href;
|
||||
};
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
newItem.onClick = item.onClick;
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
newItem.onClick = () => {};
|
||||
newItem.items = convertToCommandItem(item.items);
|
||||
}
|
||||
|
||||
return [...agg, newItem];
|
||||
}, []);
|
||||
};
|
||||
|
||||
const MenuIconWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
export default convertToCommandItem;
|
||||
@@ -139,6 +139,7 @@
|
||||
"Dismiss": "Dismiss",
|
||||
"Keyboard shortcuts": "Keyboard shortcuts",
|
||||
"Back": "Back",
|
||||
"Quick menu": "Quick menu",
|
||||
"Document archived": "Document archived",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"New collection": "New collection",
|
||||
@@ -205,8 +206,8 @@
|
||||
"Document duplicated": "Document duplicated",
|
||||
"Document restored": "Document restored",
|
||||
"Document unpublished": "Document unpublished",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Restore 1": "Restore 1",
|
||||
"Restore 2": "Restore 2",
|
||||
"Choose a collection": "Choose a collection",
|
||||
"Unpin": "Unpin",
|
||||
"Pin to collection": "Pin to collection",
|
||||
@@ -220,6 +221,8 @@
|
||||
"Disable embeds": "Disable embeds",
|
||||
"Download": "Download",
|
||||
"Print": "Print",
|
||||
"Document": "Document",
|
||||
"Document options": "Document options",
|
||||
"Move {{ documentName }}": "Move {{ documentName }}",
|
||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||
"Edit group": "Edit group",
|
||||
|
||||
Reference in New Issue
Block a user