mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c5e8a8876 | |||
| d7c331532d | |||
| 0261e0712c | |||
| f7111991dc | |||
| 10a190cd80 | |||
| 3721ea2333 | |||
| 1048ea8771 | |||
| a3cfef09f3 | |||
| ef71a54120 | |||
| 1c7bb65c7a | |||
| 093ee74a90 | |||
| 0054b7152e | |||
| 8b4b2ca741 | |||
| 911bb1f492 | |||
| c9f0c86719 | |||
| d0fe6ad93f | |||
| 4e53029377 | |||
| 7abb4f9ad6 | |||
| dec03b9d84 | |||
| d591158c4d | |||
| fa03f9c08d | |||
| b7055ef853 | |||
| 864ddbd438 | |||
| 30a4303a8e | |||
| 7725f29dc7 | |||
| 08825c7d97 | |||
| 448258746c | |||
| b002d51ace | |||
| 3e6a22e369 | |||
| 412f3ed9a4 | |||
| 78ad1b867a | |||
| c643f62d96 | |||
| 79ff9309fd | |||
| 9256c59e60 | |||
| 1d90f98a29 | |||
| 10ec8a59b4 | |||
| dfbd89ad53 | |||
| da9a8af543 | |||
| aada5c20cd | |||
| 8f86eadc5d | |||
| 53c6c5599a | |||
| e3ba87dcb0 | |||
| 3c5753621c | |||
| 3366fb46cd | |||
| 89bf5373aa | |||
| e6b0e434ea | |||
| 225f0dbf11 | |||
| 418d3305b2 | |||
| 5c07694f6b | |||
| 74722b80f2 | |||
| 4354e1055e | |||
| c3a8858c6b | |||
| 546022e5d6 | |||
| 33e532847e | |||
| c9d62420c8 | |||
| cc2a1865c5 | |||
| 1ec87da8a9 | |||
| d820b2a617 | |||
| 5e7ea165b4 | |||
| c68d55f49b | |||
| 7e349c9db1 | |||
| 13b067fb3f | |||
| 41c346d105 | |||
| 4788ab3bd6 | |||
| 5f00b4f744 | |||
| fd600ced09 | |||
| 0047384d70 | |||
| 8bff566c30 | |||
| fce90df3aa | |||
| 28ae1af2a3 | |||
| 9f0534d544 | |||
| 4edfab20fe | |||
| c38e045df2 | |||
| b7bfc4bb1a | |||
| a71ad43c31 | |||
| 199fa5844e | |||
| b466f1c8bb | |||
| 503e4e1f71 | |||
| 2bc52be2cf | |||
| 3ba730943c | |||
| 6828718cf0 | |||
| 9749a53558 | |||
| f4e4992508 | |||
| cf2f0b1b5c | |||
| 4a4ea0e531 | |||
| 8830773acb | |||
| f5d2c7890a | |||
| 434812dbe3 | |||
| ed5671209a | |||
| c32cec7bff |
+2
-2
@@ -183,5 +183,5 @@ RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
# Iframely API config
|
||||
IFRAMELY_URL=
|
||||
IFRAMELY_API_KEY=
|
||||
# IFRAMELY_URL=
|
||||
# IFRAMELY_API_KEY=
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"import"
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
@@ -55,6 +55,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.64.0
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2026-05-23
|
||||
Change Date: 2027-08-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@ test:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test
|
||||
|
||||
watch:
|
||||
docker-compose up -d redis postgres s3
|
||||
yarn sequelize db:drop --env=test
|
||||
yarn sequelize db:create --env=test
|
||||
yarn sequelize db:migrate --env=test
|
||||
NODE_ENV=test yarn sequelize db:migrate --env=test
|
||||
yarn test:watch
|
||||
|
||||
destroy:
|
||||
|
||||
@@ -96,6 +96,10 @@ Or to run migrations on test database:
|
||||
yarn sequelize db:migrate --env test
|
||||
```
|
||||
|
||||
## License
|
||||
# Activity
|
||||
|
||||

|
||||
|
||||
# License
|
||||
|
||||
Outline is [BSL 1.1 licensed](LICENSE).
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BrowserIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import {
|
||||
developersUrl,
|
||||
changelogUrl,
|
||||
@@ -26,7 +27,6 @@ import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { flattenDeep } from "lodash";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import * as React from "react";
|
||||
import { Optional } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import { escape } from "lodash";
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "~/env";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { sortBy, filter, uniq, isEqual } from "lodash";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -7,6 +7,7 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -17,16 +18,20 @@ type Props = {
|
||||
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { ui } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
showToast(t("Collection deleted"), { type: "success" });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,15 +14,48 @@ function ConnectionStatus() {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
ui.multiplayerStatus === "disconnected" ? (
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
message ? (
|
||||
<Centered>
|
||||
<strong>{message.title}</strong>
|
||||
<br />
|
||||
{message.body}
|
||||
</Centered>
|
||||
) : (
|
||||
<Centered>
|
||||
<strong>{t("Server connection lost")}</strong>
|
||||
<br />
|
||||
{t("Edits you make will sync once you’re online")}
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
|
||||
readOnly?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onChange?: (text: string) => void;
|
||||
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
|
||||
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
|
||||
@@ -35,6 +36,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
disabled,
|
||||
onChange,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
value,
|
||||
@@ -143,11 +145,13 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} dir={dir} onClick={onClick}>
|
||||
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
|
||||
{children}
|
||||
<Content
|
||||
ref={contentRef}
|
||||
contentEditable={!disabled && !readOnly}
|
||||
onInput={wrappedEvent(onInput)}
|
||||
onFocus={wrappedEvent(onFocus)}
|
||||
onBlur={wrappedEvent(onBlur)}
|
||||
onKeyDown={wrappedEvent(onKeyDown)}
|
||||
onPaste={handlePaste}
|
||||
@@ -158,7 +162,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
|
||||
>
|
||||
{innerValue}
|
||||
</Content>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
ref={backgroundRef}
|
||||
hiddenScrollbars
|
||||
style={
|
||||
topAnchor
|
||||
topAnchor && !isMobile
|
||||
? {
|
||||
maxHeight,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
templatesPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -105,7 +106,13 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
path.forEach((node: NavigationNode) => {
|
||||
output.push({
|
||||
type: "route",
|
||||
title: node.title,
|
||||
title: node.emoji ? (
|
||||
<>
|
||||
<EmojiIcon emoji={node.emoji} /> {node.title}
|
||||
</>
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ function DocumentCard(props: Props) {
|
||||
|
||||
{document.emoji ? (
|
||||
<Squircle color={theme.slateLight}>
|
||||
<EmojiIcon emoji={document.emoji} size={26} />
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
</Squircle>
|
||||
) : (
|
||||
<Squircle color={collection?.color}>
|
||||
@@ -279,8 +279,8 @@ const Heading = styled.h3`
|
||||
overflow: hidden;
|
||||
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCard);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import { includes, difference, concat, filter, map, fill } from "lodash";
|
||||
import concat from "lodash/concat";
|
||||
import difference from "lodash/difference";
|
||||
import fill from "lodash/fill";
|
||||
import filter from "lodash/filter";
|
||||
import includes from "lodash/includes";
|
||||
import map from "lodash/map";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -10,7 +15,6 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -200,84 +204,86 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const ListItem = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title, path;
|
||||
const ListItem = observer(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: NavigationNode[];
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const node = data[index];
|
||||
const isCollection = node.type === "collection";
|
||||
let icon, title: string, emoji: string | undefined, path;
|
||||
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
const doc = documents.get(node.id);
|
||||
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||
title = strippedTitle;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
if (isCollection) {
|
||||
const col = collections.get(node.collectionId as string);
|
||||
icon = col && (
|
||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||
);
|
||||
title = node.title;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
const doc = documents.get(node.id);
|
||||
emoji = doc?.emoji ?? node.emoji;
|
||||
title = doc?.title ?? node.title;
|
||||
|
||||
if (emoji) {
|
||||
icon = <EmojiIcon emoji={emoji} />;
|
||||
} else if (doc?.isStarred) {
|
||||
icon = <StarredIcon color={theme.yellow} />;
|
||||
} else {
|
||||
icon = <DocumentIcon color={theme.textSecondary} />;
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => a.title)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
path = ancestors(node)
|
||||
.map((a) => parseTitle(a.title).strippedTitle)
|
||||
.join(" / ");
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return searchTerm ? (
|
||||
<DocumentExplorerSearchResult
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
path={path}
|
||||
/>
|
||||
) : (
|
||||
<DocumentExplorerNode
|
||||
style={{
|
||||
...style,
|
||||
top: (style.top as number) + VERTICAL_PADDING,
|
||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||
}}
|
||||
onPointerMove={() => setActiveNode(index)}
|
||||
onClick={() => toggleSelect(index)}
|
||||
onDisclosureClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
toggleCollapse(index);
|
||||
}}
|
||||
selected={isSelected(index)}
|
||||
active={activeNode === index}
|
||||
expanded={isExpanded(index)}
|
||||
icon={icon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
const focusSearchInput = () => {
|
||||
inputSearchRef.current?.focus();
|
||||
|
||||
@@ -24,6 +24,7 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import EmojiIcon from "./Icons/EmojiIcon";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -92,6 +93,12 @@ function DocumentListItem(
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.emoji && (
|
||||
<>
|
||||
<EmojiIcon emoji={document.emoji} size={24} />
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
@@ -262,8 +269,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
|
||||
margin-bottom: 0.25em;
|
||||
white-space: nowrap;
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const StarPositioner = styled(Flex)`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sortBy } from "lodash";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -46,6 +48,7 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
previewsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
@@ -60,6 +63,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
previewsDisabled,
|
||||
} = props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
@@ -337,7 +341,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onHoverLink={previewsDisabled ? undefined : handleLinkActive}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import { hover } from "~/styles";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const EmojiButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&: ${hover},
|
||||
&:active,
|
||||
&[aria-expanded= "true"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Emoji = styled(Flex)<{ size?: number }>`
|
||||
line-height: 1.6;
|
||||
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
|
||||
`;
|
||||
@@ -0,0 +1,269 @@
|
||||
import data from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { toRGB } from "@shared/utils/color";
|
||||
import Button from "~/components/Button";
|
||||
import Popover from "~/components/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { Emoji, EmojiButton } from "./components";
|
||||
|
||||
/* Locales supported by emoji-mart */
|
||||
const supportedLocales = [
|
||||
"en",
|
||||
"ar",
|
||||
"be",
|
||||
"cs",
|
||||
"de",
|
||||
"es",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
"hi",
|
||||
"it",
|
||||
"ja",
|
||||
"kr",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sa",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh",
|
||||
];
|
||||
|
||||
/**
|
||||
* React hook to derive emoji picker's theme from UI theme
|
||||
*
|
||||
* @returns {string} Theme to use for emoji picker
|
||||
*/
|
||||
function usePickerTheme(): string {
|
||||
const { ui } = useStores();
|
||||
const { theme } = ui;
|
||||
|
||||
if (theme === "system") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The selected emoji, if any */
|
||||
value?: string | null;
|
||||
/** Callback when an emoji is selected */
|
||||
onChange: (emoji: string | null) => void | Promise<void>;
|
||||
/** Callback when the picker is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when the picker is closed */
|
||||
onClose?: () => void;
|
||||
/** Callback when the picker is clicked outside of */
|
||||
onClickOutside: () => void;
|
||||
/** Whether to auto focus the search input on open */
|
||||
autoFocus?: boolean;
|
||||
/** Class name to apply to the trigger button */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function EmojiPicker({
|
||||
value,
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
onClickOutside,
|
||||
autoFocus,
|
||||
className,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const pickerTheme = usePickerTheme();
|
||||
const theme = useTheme();
|
||||
const locale = useUserLocale(true) ?? "en";
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: "bottom-start",
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
|
||||
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
|
||||
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && pickerRef.current) {
|
||||
// 28 is picker's observed width when perLine is set to 0
|
||||
// and 36 is the default emojiButtonSize
|
||||
// Ref: https://github.com/missive/emoji-mart#options--props
|
||||
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
|
||||
}
|
||||
}, [popover.visible]);
|
||||
|
||||
const handleEmojiChange = React.useCallback(
|
||||
async (emoji) => {
|
||||
popover.hide();
|
||||
await onChange(emoji ? emoji.native : null);
|
||||
},
|
||||
[popover, onChange]
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
} else {
|
||||
popover.show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
);
|
||||
|
||||
const handleClickOutside = React.useCallback(() => {
|
||||
// It was observed that onClickOutside got triggered
|
||||
// even when the picker wasn't open or opened at all.
|
||||
// Hence, this guard here...
|
||||
if (popover.visible) {
|
||||
onClickOutside();
|
||||
}
|
||||
}, [popover.visible, onClickOutside]);
|
||||
|
||||
// Auto focus search input when picker is opened
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocus && popover.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
const searchInput = pickerRef.current
|
||||
?.querySelector("em-emoji-picker")
|
||||
?.shadowRoot?.querySelector(
|
||||
"input[type=search]"
|
||||
) as HTMLInputElement | null;
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
}, [autoFocus, popover.visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<EmojiButton
|
||||
{...props}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
icon={
|
||||
value ? (
|
||||
<Emoji size={32} align="center" justify="center">
|
||||
{value}
|
||||
</Emoji>
|
||||
) : (
|
||||
<StyledSmileyIcon size={32} color={theme.textTertiary} />
|
||||
)
|
||||
}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<PickerPopover
|
||||
{...popover}
|
||||
tabIndex={0}
|
||||
// This prevents picker from closing when any of its
|
||||
// children are focused, e.g, clicking on search bar or
|
||||
// a click on skin tone button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
width={352}
|
||||
aria-label={t("Emoji Picker")}
|
||||
>
|
||||
{popover.visible && (
|
||||
<>
|
||||
{value && (
|
||||
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
|
||||
{t("Remove")}
|
||||
</RemoveButton>
|
||||
)}
|
||||
<PickerStyles ref={pickerRef}>
|
||||
<Picker
|
||||
// https://github.com/missive/emoji-mart/issues/800
|
||||
locale={
|
||||
locale === "ko"
|
||||
? "kr"
|
||||
: supportedLocales.includes(locale)
|
||||
? locale
|
||||
: "en"
|
||||
}
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiChange}
|
||||
theme={pickerTheme}
|
||||
previewPosition="none"
|
||||
perLine={emojisPerLine}
|
||||
onClickOutside={handleClickOutside}
|
||||
/>
|
||||
</PickerStyles>
|
||||
</>
|
||||
)}
|
||||
</PickerPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSmileyIcon = styled(SmileyIcon)`
|
||||
flex-shrink: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(Button)`
|
||||
margin-left: -12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
|
||||
> :first-child {
|
||||
min-height: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerPopover = styled(Popover)`
|
||||
z-index: ${depths.popover};
|
||||
> :first-child {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0;
|
||||
max-height: 488px;
|
||||
overflow: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const PickerStyles = styled.div`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
em-emoji-picker {
|
||||
--shadow: none;
|
||||
--font-family: ${s("fontFamily")};
|
||||
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
|
||||
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
|
||||
--border-radius: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
min-height: 443px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default EmojiPicker;
|
||||
@@ -80,7 +80,7 @@ const Note = styled(Text)`
|
||||
margin-bottom: 0;
|
||||
line-height: 1.2em;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -14,7 +15,6 @@ import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import * as React from "react";
|
||||
import replace from "string-replace-to-array";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -4,7 +4,7 @@ import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
export const CARD_MARGIN = 16;
|
||||
export const CARD_MARGIN = 10;
|
||||
|
||||
const NUMBER_OF_LINES = 10;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths } from "@shared/styles";
|
||||
import { UnfurlType } from "@shared/types";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -27,6 +27,14 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
enum Direction {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
const POINTER_HEIGHT = 22;
|
||||
const POINTER_WIDTH = 22;
|
||||
|
||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const url = element.href || element.dataset.url;
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
@@ -36,31 +44,46 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
const stores = useStores();
|
||||
const [cardLeft, setCardLeft] = React.useState(0);
|
||||
const [cardTop, setCardTop] = React.useState(0);
|
||||
const [pointerOffset, setPointerOffset] = React.useState(0);
|
||||
const [pointerLeft, setPointerLeft] = React.useState(0);
|
||||
const [pointerTop, setPointerTop] = React.useState(0);
|
||||
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isVisible && cardRef.current) {
|
||||
const elem = element.getBoundingClientRect();
|
||||
const card = cardRef.current.getBoundingClientRect();
|
||||
|
||||
const top = elem.bottom + window.scrollY;
|
||||
setCardTop(top);
|
||||
let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
|
||||
let pTop = -POINTER_HEIGHT;
|
||||
let pDir = Direction.UP;
|
||||
if (cTop + card.height > window.innerHeight + window.scrollY) {
|
||||
// shift card upwards if it goes out of screen
|
||||
const bottom = elem.top + window.scrollY;
|
||||
cTop = bottom - card.height;
|
||||
// shift a little further to leave some margin between card and element boundary
|
||||
cTop -= CARD_MARGIN;
|
||||
// pointer should be shifted downwards to align with card's bottom
|
||||
pTop = card.height;
|
||||
pDir = Direction.DOWN;
|
||||
}
|
||||
setCardTop(cTop);
|
||||
setPointerTop(pTop);
|
||||
setPointerDir(pDir);
|
||||
|
||||
let left = elem.left;
|
||||
let pointerOffset = elem.width / 2;
|
||||
if (left + card.width > window.innerWidth) {
|
||||
let cLeft = elem.left;
|
||||
let pLeft = elem.width / 2;
|
||||
if (cLeft + card.width > window.innerWidth) {
|
||||
// shift card leftwards by the amount it went out of screen
|
||||
let shiftBy = left + card.width - window.innerWidth;
|
||||
// shift a littler further to leave some margin between card and window boundary
|
||||
let shiftBy = cLeft + card.width - window.innerWidth;
|
||||
// shift a little further to leave some margin between card and window boundary
|
||||
shiftBy += CARD_MARGIN;
|
||||
left -= shiftBy;
|
||||
cLeft -= shiftBy;
|
||||
|
||||
// shift pointer rightwards by same amount so as to position it back correctly
|
||||
pointerOffset += shiftBy;
|
||||
pLeft += shiftBy;
|
||||
}
|
||||
setCardLeft(left);
|
||||
|
||||
setPointerOffset(pointerOffset);
|
||||
setCardLeft(cLeft);
|
||||
setPointerLeft(pLeft);
|
||||
}
|
||||
}, [isVisible, element]);
|
||||
|
||||
@@ -193,7 +216,11 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer offset={pointerOffset} />
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
@@ -217,7 +244,6 @@ const Animate = styled(m.div)`
|
||||
`;
|
||||
|
||||
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
z-index: ${depths.hoverPreview};
|
||||
display: flex;
|
||||
@@ -227,11 +253,11 @@ const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div<{ offset: number }>`
|
||||
top: -22px;
|
||||
left: ${(props) => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
|
||||
top: ${(props) => props.top}px;
|
||||
left: ${(props) => props.left}px;
|
||||
width: ${POINTER_WIDTH}px;
|
||||
height: ${POINTER_HEIGHT}px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
@@ -241,20 +267,26 @@ const Pointer = styled.div<{ offset: number }>`
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
${({ direction }) => (direction === Direction.UP ? "bottom: 0" : "top: 0")};
|
||||
${({ direction }) => (direction === Direction.UP ? "right: 0" : "left: 0")};
|
||||
}
|
||||
|
||||
&:before {
|
||||
border: 8px solid transparent;
|
||||
border-bottom-color: ${(props) =>
|
||||
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
|
||||
right: -1px;
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
|
||||
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
|
||||
${({ direction }) =>
|
||||
direction === Direction.UP ? "right: -1px" : "left: -1px"};
|
||||
}
|
||||
|
||||
&:after {
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: ${s("menuBackground")};
|
||||
${({ direction, theme }) =>
|
||||
direction === Direction.UP
|
||||
? `border-bottom-color: ${theme.menuBackground}`
|
||||
: `border-top-color: ${theme.menuBackground}`};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
) {
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column>
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
<Card ref={ref}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
<Title>{title}</Title>
|
||||
|
||||
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
|
||||
width: ${(props) => props.$size}px;
|
||||
height: ${(props) => props.$size}px;
|
||||
text-indent: -0.15em;
|
||||
font-size: 14px;
|
||||
font-size: ${(props) => props.$size - 10}px;
|
||||
`;
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function InputSelectPermission(
|
||||
const handleChange = React.useCallback(
|
||||
(value) => {
|
||||
if (value === "no_access") {
|
||||
value = "";
|
||||
value = null;
|
||||
}
|
||||
|
||||
onChange?.(value);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { times } from "lodash";
|
||||
import times from "lodash/times";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Fade from "~/components/Fade";
|
||||
|
||||
@@ -94,11 +94,9 @@ const Modal: React.FC<Props> = ({
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text as="span" size="large">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Text>
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SubscribeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Relative from "../Sidebar/components/Relative";
|
||||
|
||||
const NotificationIcon = () => {
|
||||
const { notifications } = useStores();
|
||||
const theme = useTheme();
|
||||
const count = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<Relative style={{ height: 24 }}>
|
||||
<SubscribeIcon color={theme.textTertiary} />
|
||||
<SubscribeIcon />
|
||||
{count > 0 && <Badge />}
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
|
||||
{notification.comment && (
|
||||
<StyledCommentEditor
|
||||
defaultValue={toJS(notification.comment.data)}
|
||||
previewsDisabled
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, SearchIcon, ShapesIcon, HomeIcon } from "outline-icons";
|
||||
import {
|
||||
EditIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
HomeIcon,
|
||||
SidebarIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
@@ -14,7 +20,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import {
|
||||
homePath,
|
||||
draftsPath,
|
||||
@@ -22,21 +28,23 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarAction from "./components/SidebarAction";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import Starred from "./components/Starred";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import TrashLink from "./components/TrashLink";
|
||||
|
||||
function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { documents, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team);
|
||||
@@ -65,23 +73,31 @@ function AppSidebar() {
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
title={team.name}
|
||||
image={
|
||||
<TeamLogo
|
||||
model={team}
|
||||
size={Desktop.hasInsetTitlebar() ? 24 : 32}
|
||||
size={24}
|
||||
alt={t("Logo")}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
style={
|
||||
// Move the logo over to align with smaller size
|
||||
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
|
||||
}
|
||||
showDisclosure
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Scrollable flex shadow>
|
||||
|
||||
@@ -120,7 +120,7 @@ const Position = styled(Flex)`
|
||||
const Sidebar = styled(m.div)<{
|
||||
$border?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
background: ${s("background")};
|
||||
max-width: 80%;
|
||||
@@ -129,6 +129,7 @@ const Sidebar = styled(m.div)<{
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { groupBy } from "lodash";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon } from "outline-icons";
|
||||
import { BackIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
@@ -8,17 +8,21 @@ import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
import Version from "./components/Version";
|
||||
|
||||
function SettingsSidebar() {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const configs = useSettingsConfig();
|
||||
@@ -31,12 +35,23 @@ function SettingsSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<HistoryNavigation />
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={t("Return to App")}
|
||||
image={<StyledBackIcon />}
|
||||
onClick={returnToApp}
|
||||
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
tooltip={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
|
||||
@@ -11,9 +11,9 @@ import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import HeaderButton from "./components/HeaderButton";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
@@ -28,7 +28,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
return (
|
||||
<Sidebar>
|
||||
{team && (
|
||||
<HeaderButton
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -11,29 +10,29 @@ import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Avatar from "../Avatar";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
{ children }: Props,
|
||||
{ children, className }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const [isCollapsing, setCollapsing] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
@@ -46,6 +45,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
@@ -99,6 +99,22 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(() => {
|
||||
setHovering(true);
|
||||
}, []);
|
||||
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
setHovering(
|
||||
ev.pageX < width &&
|
||||
ev.pageX > 0 &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), ANIMATION_MS);
|
||||
@@ -147,23 +163,19 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const toggleStyle = React.useMemo(
|
||||
() => ({
|
||||
right: "auto",
|
||||
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
|
||||
}),
|
||||
[width, theme.sidebarCollapsedWidth, collapsed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
style={style}
|
||||
$isHovering={isHovering}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible && (
|
||||
@@ -175,26 +187,32 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
{user && (
|
||||
<AccountMenu>
|
||||
{(props: HeaderButtonProps) => (
|
||||
<HeaderButton
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
position="bottom"
|
||||
image={
|
||||
<StyledAvatar
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
model={user}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: HeaderButtonProps) => (
|
||||
<HeaderButton {...rest} image={<NotificationIcon />} />
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
</HeaderButton>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
)}
|
||||
@@ -202,28 +220,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
{ui.sidebarIsClosed && (
|
||||
<Toggle
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={"right"}
|
||||
aria-label={t("Expand")}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Toggle
|
||||
style={toggleStyle}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
direction={ui.sidebarIsClosed ? "right" : "left"}
|
||||
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
@@ -240,16 +241,33 @@ type ContainerProps = {
|
||||
$mobileSidebarVisible: boolean;
|
||||
$isAnimating: boolean;
|
||||
$isSmallerThanMinimum: boolean;
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
transform: none;
|
||||
box-shadow: ${
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"
|
||||
};
|
||||
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<ContainerProps>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
|
||||
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
|
||||
transform 100ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
@@ -259,19 +277,17 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
z-index: ${depths.sidebar};
|
||||
max-width: 80%;
|
||||
min-width: 280px;
|
||||
padding-top: ${Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
${fadeOnDesktopBackgrounded()}
|
||||
|
||||
${Positioner} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
opacity: ${(props) => (props.$collapsed && !props.$isHovering ? "0" : "1")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
@@ -280,28 +296,14 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||
: 0});
|
||||
|
||||
&:hover,
|
||||
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
|
||||
|
||||
&:focus-within {
|
||||
transform: none;
|
||||
box-shadow: ${(props: ContainerProps) =>
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
${hoverStyles}
|
||||
|
||||
${Positioner} {
|
||||
display: block;
|
||||
}
|
||||
|
||||
${ToggleButton} {
|
||||
& > div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props: ContainerProps) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -324,6 +324,7 @@ function InnerDocumentLink(
|
||||
starred: inStarredSection,
|
||||
},
|
||||
}}
|
||||
emoji={document?.emoji || node.emoji}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
|
||||
@@ -3,12 +3,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { isMac } from "~/utils/browser";
|
||||
|
||||
function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,14 +7,14 @@ import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import Disclosure from "./Disclosure";
|
||||
import { descendants } from "~/utils/tree";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
collection?: Collection;
|
||||
activeDocumentId: string | undefined;
|
||||
activeDocument: Document | undefined;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -41,10 +42,19 @@ function DocumentLink(
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
const document = documents.get(node.id);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() => !!hasChildDocuments,
|
||||
[hasChildDocuments]
|
||||
() =>
|
||||
!!(
|
||||
hasChildDocuments &&
|
||||
((activeDocumentId &&
|
||||
includes(
|
||||
descendants(node).map((n) => n.id),
|
||||
activeDocumentId
|
||||
)) ||
|
||||
isActiveDocument ||
|
||||
depth <= 1)
|
||||
),
|
||||
[hasChildDocuments, activeDocumentId, isActiveDocument, depth, node]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
@@ -55,12 +65,6 @@ function DocumentLink(
|
||||
}
|
||||
}, [showChildren]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isActiveDocument]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -105,14 +109,10 @@ function DocumentLink(
|
||||
title: node.title,
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<>
|
||||
{hasChildDocuments && depth !== 0 && (
|
||||
<Disclosure expanded={expanded} onClick={handleDisclosureClick} />
|
||||
)}
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
emoji={node.emoji}
|
||||
label={title}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||
|
||||
+31
-22
@@ -1,58 +1,66 @@
|
||||
import { ExpandedIcon, MoreIcon } from "outline-icons";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import Text from "~/components/Text";
|
||||
import { draggableOnDesktop, undraggableOnDesktop } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
minHeight?: number;
|
||||
rounded?: boolean;
|
||||
showDisclosure?: boolean;
|
||||
showMoreMenu?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
function _HeaderButton(
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function _SidebarButton(
|
||||
{
|
||||
showDisclosure,
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
minHeight = 0,
|
||||
children,
|
||||
...rest
|
||||
}: HeaderButtonProps,
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" shrink={false}>
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
minHeight={minHeight}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Title gap={8} align="center">
|
||||
{image}
|
||||
{title}
|
||||
{title && <Text as="span">{title}</Text>}
|
||||
</Title>
|
||||
{showDisclosure && <ExpandedIcon />}
|
||||
{showMoreMenu && <MoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Flex>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
|
||||
padding-top: ${(props) =>
|
||||
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
color: ${s("text")};
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
text-overflow: ellipsis;
|
||||
@@ -60,19 +68,20 @@ const Title = styled(Flex)`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Button = styled(Flex)<{ minHeight: number }>`
|
||||
const Button = styled(Flex)<{
|
||||
$position: "top" | "bottom";
|
||||
}>`
|
||||
flex: 1;
|
||||
color: ${s("textTertiary")};
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
padding: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
margin: ${(props) => (props.$position === "top" ? 16 : 8)}px 0;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
min-height: ${(props) => props.minHeight}px;
|
||||
|
||||
-webkit-appearance: none;
|
||||
text-decoration: none;
|
||||
@@ -99,4 +108,4 @@ const Button = styled(Flex)<{ minHeight: number }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default HeaderButton;
|
||||
export default SidebarButton;
|
||||
@@ -5,6 +5,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import EventBoundary from "~/components/EventBoundary";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
@@ -25,6 +26,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
onClickIntent?: () => void;
|
||||
onDisclosureClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon?: React.ReactNode;
|
||||
emoji?: string | null;
|
||||
label?: React.ReactNode;
|
||||
menu?: React.ReactNode;
|
||||
showActions?: boolean;
|
||||
@@ -48,6 +50,7 @@ function SidebarLink(
|
||||
onClick,
|
||||
onClickIntent,
|
||||
to,
|
||||
emoji,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
@@ -136,6 +139,7 @@ function SidebarLink(
|
||||
/>
|
||||
)}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{emoji && <EmojiIcon emoji={emoji} />}
|
||||
<Label>{label}</Label>
|
||||
</Content>
|
||||
</Link>
|
||||
@@ -152,6 +156,7 @@ const Content = styled.span`
|
||||
|
||||
${Disclosure} {
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -292,7 +297,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
line-height: 24px;
|
||||
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
@@ -42,14 +41,10 @@ function useLabelAndIcon({ documentId, collectionId }: Star) {
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (document) {
|
||||
const { emoji } = parseTitle(document?.title);
|
||||
|
||||
return {
|
||||
label: emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault,
|
||||
icon: emoji ? (
|
||||
<EmojiIcon emoji={emoji} />
|
||||
label: document.titleWithDefault,
|
||||
icon: document.emoji ? (
|
||||
<EmojiIcon emoji={document.emoji} />
|
||||
) : (
|
||||
<StarredIcon color={theme.yellow} />
|
||||
),
|
||||
@@ -148,6 +143,10 @@ function StarredLink({ star }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { emoji } = document;
|
||||
const label = emoji
|
||||
? document.title.replace(emoji, "")
|
||||
: document.titleWithDefault;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Arrow from "~/components/Arrow";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
|
||||
type Props = {
|
||||
direction: "left" | "right";
|
||||
style?: React.CSSProperties;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
|
||||
{ direction = "left", onClick, style }: Props,
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
const positionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Not using CSS hover here so that we can disable pointer events on this
|
||||
// div and allow click through to the editor elements behind.
|
||||
useEventListener("mousemove", (event: MouseEvent) => {
|
||||
if (!positionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = positionRef.current.getBoundingClientRect();
|
||||
const withinBounds =
|
||||
event.clientX >= bound.left && event.clientX <= bound.right;
|
||||
if (withinBounds !== hovering) {
|
||||
setHovering(withinBounds);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Positioner style={style} ref={positionRef} $hovering={hovering}>
|
||||
<ToggleButton
|
||||
ref={ref}
|
||||
$direction={direction}
|
||||
onClick={onClick}
|
||||
aria-label={t("Toggle sidebar")}
|
||||
>
|
||||
<Arrow />
|
||||
</ToggleButton>
|
||||
</Positioner>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
|
||||
opacity: 0;
|
||||
background: none;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%)
|
||||
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
|
||||
position: fixed;
|
||||
top: 50vh;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
color: ${s("divider")};
|
||||
|
||||
&:active {
|
||||
color: ${s("sidebarText")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: var(--pointer);
|
||||
`}
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Positioner = styled.div<{ $hovering: boolean }>`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -30px;
|
||||
width: 30px;
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$hovering &&
|
||||
css`
|
||||
${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "~/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:${hover},
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ToggleButton;
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -14,12 +15,18 @@ import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
/** Target collection */
|
||||
collection?: Collection;
|
||||
/** Target document */
|
||||
document?: Document;
|
||||
/** Size of the star */
|
||||
size?: number;
|
||||
/** Color override for the star */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function Star({ size, document, collection, ...rest }: Props) {
|
||||
function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
@@ -36,6 +43,10 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
<NudeButton
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
tooltip={{
|
||||
tooltip: target.isStarred ? t("Unstar document") : t("Star document"),
|
||||
delay: 500,
|
||||
}}
|
||||
action={
|
||||
collection
|
||||
? collection.isStarred
|
||||
@@ -55,7 +66,7 @@ function Star({ size, document, collection, ...rest }: Props) {
|
||||
) : (
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={theme.textTertiary}
|
||||
color={color ?? theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -4,7 +4,8 @@ import Avatar from "./Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${s("divider")};
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
+13
-8
@@ -1,4 +1,4 @@
|
||||
import styled from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "initial")};
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
@@ -31,12 +31,17 @@ const Text = styled.p<Props>`
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
font-weight: ${(props) =>
|
||||
props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? "normal"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
@@ -15,7 +16,7 @@ export function UserChangeToViewerDialog({ user, onSubmit }: Props) {
|
||||
const { users } = useStores();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await users.demote(user, "viewer");
|
||||
await users.demote(user, UserRole.Viewer);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
@@ -41,7 +42,7 @@ export function UserChangeToMemberDialog({ user, onSubmit }: Props) {
|
||||
const { users } = useStores();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await users.demote(user, "member");
|
||||
await users.demote(user, UserRole.Member);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import data, { type Emoji as TEmoji, EmojiMartData } from "@emoji-mart/data";
|
||||
import FuzzySearch from "fuzzy-search";
|
||||
import gemojies from "gemoji";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import snakeCase from "lodash/snakeCase";
|
||||
import React from "react";
|
||||
import EmojiMenuItem from "./EmojiMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
@@ -14,14 +16,14 @@ type Emoji = {
|
||||
attrs: { markup: string; "data-name": string };
|
||||
};
|
||||
|
||||
const searcher = new FuzzySearch<{
|
||||
names: string[];
|
||||
description: string;
|
||||
emoji: string;
|
||||
}>(gemojies, ["names"], {
|
||||
caseSensitive: true,
|
||||
sort: true,
|
||||
});
|
||||
const searcher = new FuzzySearch<TEmoji>(
|
||||
Object.values((data as EmojiMartData).emojis),
|
||||
["keywords"],
|
||||
{
|
||||
caseSensitive: true,
|
||||
sort: true,
|
||||
}
|
||||
);
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
@@ -34,14 +36,17 @@ const EmojiMenu = (props: Props) => {
|
||||
const items = React.useMemo(() => {
|
||||
const n = search.toLowerCase();
|
||||
const result = searcher.search(n).map((item) => {
|
||||
const description = item.description;
|
||||
const name = item.names[0];
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
const shortcode = snakeCase(item.id);
|
||||
const emoji = item.skins[0].native;
|
||||
|
||||
return {
|
||||
...item,
|
||||
name: "emoji",
|
||||
title: name,
|
||||
description,
|
||||
attrs: { markup: name, "data-name": name },
|
||||
title: emoji,
|
||||
description: capitalize(item.name.toLowerCase()),
|
||||
emoji,
|
||||
attrs: { markup: shortcode, "data-name": shortcode },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useViewportHeight from "~/hooks/useViewportHeight";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { useEditor } from "./EditorContext";
|
||||
@@ -40,6 +41,7 @@ function usePosition({
|
||||
const { selection } = view.state;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
const viewportHeight = useViewportHeight();
|
||||
const isMobile = useMobile();
|
||||
const isTouchDevice = useMediaQuery("(hover: none) and (pointer: coarse)");
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
@@ -48,7 +50,7 @@ function usePosition({
|
||||
|
||||
// If we're on a mobile device then stick the floating toolbar to the bottom
|
||||
// of the screen above the virtual keyboard.
|
||||
if (isTouchDevice && viewportHeight) {
|
||||
if (isTouchDevice && isMobile && viewportHeight) {
|
||||
return {
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { some } from "lodash";
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
@@ -231,7 +231,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (rowIndex !== undefined) {
|
||||
items = getTableRowMenuItems(state, rowIndex, dictionary);
|
||||
} else if (isImageSelection) {
|
||||
items = getImageMenuItems(state, dictionary);
|
||||
items = readOnly ? [] : getImageMenuItems(state, dictionary);
|
||||
} else if (isDividerSelection) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else if (readOnly) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import commandScore from "command-score";
|
||||
import { capitalize } from "lodash";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
|
||||
@@ -8,4 +8,10 @@ declare global {
|
||||
|
||||
const env = window.env;
|
||||
|
||||
if (!env) {
|
||||
throw new Error(
|
||||
"Config could not be be parsed. \nSee: https://docs.getoutline.com/s/hosting/doc/troubleshooting-HXckrzCqDJ#h-config-could-not-be-parsed"
|
||||
);
|
||||
}
|
||||
|
||||
export default env;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRegisterActions } from "kbar";
|
||||
import { flattenDeep } from "lodash";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { actionToKBar } from "~/actions";
|
||||
import { Action } from "~/types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import * as React from "react";
|
||||
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Options = {
|
||||
fontSize?: string;
|
||||
lineHeight?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Measures the width of an emoji character
|
||||
*
|
||||
* @param emoji The emoji to measure
|
||||
* @param options Options to pass to the measurement element
|
||||
* @returns The width of the emoji in pixels
|
||||
*/
|
||||
export default function useEmojiWidth(
|
||||
emoji: string | undefined,
|
||||
{ fontSize = "2.25em", lineHeight = "1.25" }: Options
|
||||
) {
|
||||
return React.useMemo(() => {
|
||||
const element = window.document.createElement("span");
|
||||
if (!emoji) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
element.innerText = `${emoji}\u00A0`;
|
||||
element.style.visibility = "hidden";
|
||||
element.style.position = "absolute";
|
||||
element.style.left = "-9999px";
|
||||
element.style.lineHeight = lineHeight;
|
||||
element.style.fontSize = fontSize;
|
||||
element.style.width = "max-content";
|
||||
window.document.body?.appendChild(element);
|
||||
const width = window.getComputedStyle(element).width;
|
||||
window.document.body?.removeChild(element);
|
||||
return parseInt(width, 10);
|
||||
}, [emoji, fontSize, lineHeight]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import * as React from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { noop } from "lodash";
|
||||
import noop from "lodash/noop";
|
||||
import React from "react";
|
||||
|
||||
type MenuContextType = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import * as React from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
import useIsMounted from "./useIsMounted";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { QueryNotices } from "@shared/types";
|
||||
import useQuery from "./useQuery";
|
||||
import useToasts from "./useToasts";
|
||||
|
||||
/**
|
||||
* Display a toast message based on a notice in the query string. This is usually
|
||||
* used when redirecting from an external source to the client, such as OAuth,
|
||||
* or emails.
|
||||
*/
|
||||
export default function useQueryNotices() {
|
||||
const query = useQuery();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const notice = query.get("notice") as QueryNotices;
|
||||
|
||||
React.useEffect(() => {
|
||||
switch (notice) {
|
||||
case QueryNotices.UnsubscribeDocument: {
|
||||
showToast(
|
||||
t("Unsubscribed from document", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, showToast, notice]);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useUserLocale() {
|
||||
/**
|
||||
* Returns the user's locale, or undefined if the user is not logged in.
|
||||
*
|
||||
* @param languageCode Whether to only return the language code
|
||||
* @returns The user's locale, or undefined if the user is not logged in
|
||||
*/
|
||||
export default function useUserLocale(languageCode?: boolean) {
|
||||
const { auth } = useStores();
|
||||
|
||||
if (!auth.user || !auth.user.language) {
|
||||
if (!auth.user?.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language;
|
||||
const { language } = auth.user;
|
||||
return languageCode ? language.split("_")[0] : language;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Based on https://github.com/rehooks/window-scroll-position which is no longer
|
||||
// maintained.
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import { useState, useEffect } from "react";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
|
||||
const getPosition = () => ({
|
||||
x: window.pageXOffset,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pick } from "lodash";
|
||||
import pick from "lodash/pick";
|
||||
import { set, observable } from "mobx";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { getFieldsForModel } from "./decorators/Field";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { trim } from "lodash";
|
||||
import trim from "lodash/trim";
|
||||
import { action, computed, observable, reaction, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import floor from "lodash/floor";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
@@ -68,6 +67,13 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* An emoji to use as the document icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
emoji: string | undefined | null;
|
||||
|
||||
/**
|
||||
* Whether this is a template.
|
||||
*/
|
||||
@@ -127,12 +133,6 @@ export default class Document extends ParanoidModel {
|
||||
|
||||
revision: number;
|
||||
|
||||
@computed
|
||||
get emoji() {
|
||||
const { emoji } = parseTitle(this.title);
|
||||
return emoji;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direction of the document text, either "rtl" or "ltr"
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,9 @@ class Revision extends BaseModel {
|
||||
/** Markdown string of the content when revision was created */
|
||||
text: string;
|
||||
|
||||
/** The emoji of the document when the revision was created */
|
||||
emoji: string | null;
|
||||
|
||||
/** HTML string representing the revision as a diff from the previous version */
|
||||
html: string;
|
||||
|
||||
|
||||
+6
-5
@@ -7,8 +7,9 @@ import {
|
||||
NotificationEventType,
|
||||
UserPreference,
|
||||
UserPreferences,
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import type { Role, NotificationSettings } from "@shared/types";
|
||||
import type { NotificationSettings } from "@shared/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
@@ -74,13 +75,13 @@ class User extends ParanoidModel {
|
||||
}
|
||||
|
||||
@computed
|
||||
get role(): Role {
|
||||
get role(): UserRole {
|
||||
if (this.isAdmin) {
|
||||
return "admin";
|
||||
return UserRole.Admin;
|
||||
} else if (this.isViewer) {
|
||||
return "viewer";
|
||||
return UserRole.Viewer;
|
||||
} else {
|
||||
return "member";
|
||||
return UserRole.Member;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -14,6 +15,8 @@ const Login = lazyWithRetry(() => import("~/scenes/Login"));
|
||||
const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
|
||||
@@ -26,7 +26,6 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Star, { AnimatedStar } from "~/components/Star";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -157,7 +156,7 @@ function CollectionScene() {
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<>
|
||||
<HeadingWithIcon $isStarred={collection.isStarred}>
|
||||
<HeadingWithIcon>
|
||||
<HeadingIcon collection={collection} size={40} expanded />
|
||||
{collection.name}
|
||||
{collection.isPrivate && (
|
||||
@@ -170,7 +169,6 @@ function CollectionScene() {
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<StarButton collection={collection} size={32} />
|
||||
</HeadingWithIcon>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
@@ -285,42 +283,15 @@ function CollectionScene() {
|
||||
);
|
||||
}
|
||||
|
||||
const StarButton = styled(Star)`
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
overflow: hidden;
|
||||
width: 24px;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
left: -4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Documents = styled.div`
|
||||
position: relative;
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>`
|
||||
const HeadingWithIcon = styled(Heading)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: -40px;
|
||||
`};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sortBy } from "lodash";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { intersection } from "lodash";
|
||||
import intersection from "lodash/intersection";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -56,6 +56,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon: this.icon,
|
||||
color: this.color,
|
||||
permission: this.permission,
|
||||
documents: [],
|
||||
},
|
||||
this.props.collections
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { AllSelection } from "prosemirror-state";
|
||||
@@ -354,7 +354,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isUploading = false;
|
||||
};
|
||||
|
||||
onChange = (getEditorText: () => string) => {
|
||||
handleChange = (getEditorText: () => string) => {
|
||||
const { document } = this.props;
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
@@ -369,13 +369,19 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.headings = headings;
|
||||
};
|
||||
|
||||
onChangeTitle = action((value: string) => {
|
||||
handleChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
this.props.document.title = value;
|
||||
this.updateIsDirty();
|
||||
void this.autosave();
|
||||
});
|
||||
|
||||
handleChangeEmoji = action((value: string) => {
|
||||
this.props.document.emoji = value;
|
||||
this.updateIsDirty();
|
||||
void this.autosave();
|
||||
});
|
||||
|
||||
goBack = () => {
|
||||
if (!this.props.readOnly) {
|
||||
this.props.history.push(this.props.document.url);
|
||||
@@ -482,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Flex auto={!readOnly} reverse>
|
||||
{revision ? (
|
||||
<RevisionViewer
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
revision={revision}
|
||||
id={revision.id}
|
||||
@@ -506,8 +511,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
onFileUploadStop={this.onFileUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onChangeTitle={this.handleChangeTitle}
|
||||
onChangeEmoji={this.handleChangeEmoji}
|
||||
onChange={this.handleChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { __parseFromClipboard } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { extraArea, s } from "@shared/styles";
|
||||
import { light } from "@shared/styles/theme";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { Emoji, EmojiButton } from "~/components/EmojiPicker/components";
|
||||
import Flex from "~/components/Flex";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker"));
|
||||
|
||||
type Props = {
|
||||
/** ID of the associated document */
|
||||
documentId: string;
|
||||
/** Document to display */
|
||||
title: string;
|
||||
/** Emoji to display */
|
||||
emoji?: string | null;
|
||||
/** Placeholder to display when the document has no title */
|
||||
placeholder?: string;
|
||||
/** Should the title be editable, policies will also be considered separately */
|
||||
readOnly?: boolean;
|
||||
/** Callback called on any edits to text */
|
||||
onChangeTitle?: (text: string) => void;
|
||||
/** Callback called when the user selects an emoji */
|
||||
onChangeEmoji?: (emoji: string | null) => void;
|
||||
/** Callback called when the user expects to move to the "next" input */
|
||||
onGoToNextInput?: (insertParagraph?: boolean) => void;
|
||||
/** Callback called when the user expects to save (CMD+S) */
|
||||
onSave?: (options: { publish?: boolean; done?: boolean }) => void;
|
||||
/** Callback called when focus leaves the input */
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement>;
|
||||
};
|
||||
|
||||
const lineHeight = "1.25";
|
||||
const fontSize = "2.25em";
|
||||
|
||||
const DocumentTitle = React.forwardRef(function _DocumentTitle(
|
||||
{
|
||||
documentId,
|
||||
title,
|
||||
emoji,
|
||||
readOnly,
|
||||
onChangeTitle,
|
||||
onChangeEmoji,
|
||||
onSave,
|
||||
onGoToNextInput,
|
||||
onBlur,
|
||||
placeholder,
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) {
|
||||
const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean();
|
||||
const { editor } = useDocumentContext();
|
||||
|
||||
const can = usePolicy(documentId);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
ref.current?.focus();
|
||||
}, [ref]);
|
||||
|
||||
const restoreFocus = React.useCallback(() => {
|
||||
ref.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(ev: React.FocusEvent<HTMLSpanElement>) => {
|
||||
// Do nothing and simply return if the related target is the parent
|
||||
// or a sibling of the current target element(the <span>
|
||||
// containing document title)
|
||||
if (
|
||||
ev.currentTarget.parentElement === ev.relatedTarget ||
|
||||
(ev.relatedTarget &&
|
||||
ev.currentTarget.parentElement === ev.relatedTarget.parentElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (onBlur) {
|
||||
onBlur(ev);
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
if (isModKey(event)) {
|
||||
onSave?.({
|
||||
done: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onGoToNextInput?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onGoToNextInput?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "p" && isModKey(event) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSave?.({
|
||||
publish: true,
|
||||
done: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "s" && isModKey(event)) {
|
||||
event.preventDefault();
|
||||
onSave?.({});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onGoToNextInput, onSave]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(value: string) => {
|
||||
let title = value;
|
||||
|
||||
if (/\/date\s$/.test(value)) {
|
||||
title = getCurrentDateAsString();
|
||||
ref.current?.focusAtEnd();
|
||||
} else if (/\/time$/.test(value)) {
|
||||
title = getCurrentTimeAsString();
|
||||
ref.current?.focusAtEnd();
|
||||
} else if (/\/datetime$/.test(value)) {
|
||||
title = getCurrentDateTimeAsString();
|
||||
ref.current?.focusAtEnd();
|
||||
}
|
||||
|
||||
onChangeTitle?.(title);
|
||||
},
|
||||
[ref, onChangeTitle]
|
||||
);
|
||||
|
||||
// Custom paste handling so that if a multiple lines are pasted we
|
||||
// only take the first line and insert the rest directly into the editor.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const [firstLine, ...rest] = text.split(`\n`);
|
||||
const content = rest.join(`\n`).trim();
|
||||
|
||||
window.document.execCommand(
|
||||
"insertText",
|
||||
false,
|
||||
firstLine.replace(/^#+\s?/, "")
|
||||
);
|
||||
|
||||
if (editor && content) {
|
||||
const { view, pasteParser } = editor;
|
||||
let slice;
|
||||
|
||||
if (isMarkdown(text)) {
|
||||
const paste = pasteParser.parse(normalizePastedMarkdown(content));
|
||||
if (paste) {
|
||||
slice = paste.slice(0);
|
||||
}
|
||||
} else {
|
||||
const defaultSlice = __parseFromClipboard(
|
||||
view,
|
||||
text,
|
||||
html,
|
||||
false,
|
||||
view.state.selection.$from
|
||||
);
|
||||
|
||||
// remove first node from slice
|
||||
slice = defaultSlice.content.firstChild
|
||||
? new Slice(
|
||||
defaultSlice.content.cut(
|
||||
defaultSlice.content.firstChild.nodeSize
|
||||
),
|
||||
defaultSlice.openStart,
|
||||
defaultSlice.openEnd
|
||||
)
|
||||
: defaultSlice;
|
||||
}
|
||||
|
||||
if (slice) {
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(Selection.atStart(view.state.doc))
|
||||
.replaceSelection(slice)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleEmojiChange = React.useCallback(
|
||||
async (value: string | null) => {
|
||||
// Restore focus on title
|
||||
restoreFocus();
|
||||
if (emoji !== value) {
|
||||
onChangeEmoji?.(value);
|
||||
}
|
||||
},
|
||||
[emoji, onChangeEmoji, restoreFocus]
|
||||
);
|
||||
|
||||
const emojiIcon = <Emoji size={32}>{emoji}</Emoji>;
|
||||
|
||||
return (
|
||||
<Title
|
||||
onClick={handleClick}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
value={title}
|
||||
$emojiPickerIsOpen={emojiPickerIsOpen}
|
||||
$containsEmoji={!!emoji}
|
||||
autoFocus={!document.title}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
dir="auto"
|
||||
ref={ref}
|
||||
>
|
||||
{can.update && !readOnly ? (
|
||||
<EmojiWrapper align="center" justify="center">
|
||||
<React.Suspense fallback={emojiIcon}>
|
||||
<StyledEmojiPicker
|
||||
value={emoji}
|
||||
onChange={handleEmojiChange}
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
onClickOutside={restoreFocus}
|
||||
autoFocus
|
||||
/>
|
||||
</React.Suspense>
|
||||
</EmojiWrapper>
|
||||
) : emoji ? (
|
||||
<EmojiWrapper align="center" justify="center">
|
||||
{emojiIcon}
|
||||
</EmojiWrapper>
|
||||
) : null}
|
||||
</Title>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledEmojiPicker = styled(EmojiPicker)`
|
||||
${extraArea(8)}
|
||||
`;
|
||||
|
||||
const EmojiWrapper = styled(Flex)`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -40px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
type TitleProps = {
|
||||
$containsEmoji: boolean;
|
||||
$emojiPickerIsOpen: boolean;
|
||||
};
|
||||
|
||||
const Title = styled(ContentEditable)<TitleProps>`
|
||||
position: relative;
|
||||
line-height: ${lineHeight};
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: ${(props) =>
|
||||
props.$containsEmoji || props.$emojiPickerIsOpen ? "40px" : "0px"};
|
||||
font-size: ${fontSize};
|
||||
font-weight: 500;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: ${(props) => (props.readOnly ? "default" : "text")};
|
||||
|
||||
> span {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
-webkit-text-fill-color: ${s("placeholder")};
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
margin-left: 40px;
|
||||
|
||||
${EmojiButton} {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
${EmojiButton} {
|
||||
opacity: ${(props: TitleProps) =>
|
||||
props.$containsEmoji ? "1 !important" : 0};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: 0;
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${EmojiButton} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}`};
|
||||
|
||||
@media print {
|
||||
color: ${light.text};
|
||||
-webkit-text-fill-color: ${light.text};
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentTitle);
|
||||
@@ -1,278 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { __parseFromClipboard } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { s } from "@shared/styles";
|
||||
import { light } from "@shared/styles/theme";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Star, { AnimatedStar } from "~/components/Star";
|
||||
import useEmojiWidth from "~/hooks/useEmojiWidth";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
/** Placeholder to display when the document has no title */
|
||||
placeholder: string;
|
||||
/** Should the title be editable, policies will also be considered separately */
|
||||
readOnly?: boolean;
|
||||
/** Whether the title show the option to star, policies will also be considered separately (defaults to true) */
|
||||
starrable?: boolean;
|
||||
/** Callback called on any edits to text */
|
||||
onChange: (text: string) => void;
|
||||
/** Callback called when the user expects to move to the "next" input */
|
||||
onGoToNextInput: (insertParagraph?: boolean) => void;
|
||||
/** Callback called when the user expects to save (CMD+S) */
|
||||
onSave?: (options: { publish?: boolean; done?: boolean }) => void;
|
||||
/** Callback called when focus leaves the input */
|
||||
onBlur?: React.FocusEventHandler<HTMLSpanElement>;
|
||||
};
|
||||
|
||||
const lineHeight = "1.25";
|
||||
const fontSize = "2.25em";
|
||||
|
||||
const EditableTitle = React.forwardRef(
|
||||
(
|
||||
{
|
||||
document,
|
||||
readOnly,
|
||||
onChange,
|
||||
onSave,
|
||||
onGoToNextInput,
|
||||
onBlur,
|
||||
starrable,
|
||||
placeholder,
|
||||
}: Props,
|
||||
ref: React.RefObject<RefHandle>
|
||||
) => {
|
||||
const { editor } = useDocumentContext();
|
||||
const handleClick = React.useCallback(() => {
|
||||
ref.current?.focus();
|
||||
}, [ref]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
if (isModKey(event)) {
|
||||
onSave?.({
|
||||
done: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onGoToNextInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onGoToNextInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "p" && isModKey(event) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSave?.({
|
||||
publish: true,
|
||||
done: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "s" && isModKey(event)) {
|
||||
event.preventDefault();
|
||||
onSave?.({});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onGoToNextInput, onSave]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(text: string) => {
|
||||
if (/\/date\s$/.test(text)) {
|
||||
onChange(getCurrentDateAsString());
|
||||
ref.current?.focusAtEnd();
|
||||
} else if (/\/time$/.test(text)) {
|
||||
onChange(getCurrentTimeAsString());
|
||||
ref.current?.focusAtEnd();
|
||||
} else if (/\/datetime$/.test(text)) {
|
||||
onChange(getCurrentDateTimeAsString());
|
||||
ref.current?.focusAtEnd();
|
||||
} else {
|
||||
onChange(text);
|
||||
}
|
||||
},
|
||||
[ref, onChange]
|
||||
);
|
||||
|
||||
// Custom paste handling so that if a multiple lines are pasted we
|
||||
// only take the first line and insert the rest directly into the editor.
|
||||
const handlePaste = React.useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const [firstLine, ...rest] = text.split(`\n`);
|
||||
const content = rest.join(`\n`).trim();
|
||||
|
||||
window.document.execCommand(
|
||||
"insertText",
|
||||
false,
|
||||
firstLine.replace(/^#+\s?/, "")
|
||||
);
|
||||
|
||||
if (editor && content) {
|
||||
const { view, pasteParser } = editor;
|
||||
let slice;
|
||||
|
||||
if (isMarkdown(text)) {
|
||||
const paste = pasteParser.parse(normalizePastedMarkdown(content));
|
||||
if (paste) {
|
||||
slice = paste.slice(0);
|
||||
}
|
||||
} else {
|
||||
const defaultSlice = __parseFromClipboard(
|
||||
view,
|
||||
text,
|
||||
html,
|
||||
false,
|
||||
view.state.selection.$from
|
||||
);
|
||||
|
||||
// remove first node from slice
|
||||
slice = defaultSlice.content.firstChild
|
||||
? new Slice(
|
||||
defaultSlice.content.cut(
|
||||
defaultSlice.content.firstChild.nodeSize
|
||||
),
|
||||
defaultSlice.openStart,
|
||||
defaultSlice.openEnd
|
||||
)
|
||||
: defaultSlice;
|
||||
}
|
||||
|
||||
if (slice) {
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(Selection.atStart(view.state.doc))
|
||||
.replaceSelection(slice)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const emojiWidth = useEmojiWidth(document.emoji, {
|
||||
fontSize,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
const value =
|
||||
!document.title && readOnly ? document.titleWithDefault : document.title;
|
||||
|
||||
return (
|
||||
<Title
|
||||
onClick={handleClick}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
$emojiWidth={emojiWidth}
|
||||
$isStarred={document.isStarred}
|
||||
autoFocus={!document.title}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
dir="auto"
|
||||
ref={ref}
|
||||
>
|
||||
{starrable !== false && <StarButton document={document} size={32} />}
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const StarButton = styled(Star)`
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: 10px;
|
||||
overflow: hidden;
|
||||
width: 24px;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
left: -4px;
|
||||
}
|
||||
`;
|
||||
|
||||
type TitleProps = {
|
||||
$isStarred: boolean;
|
||||
$emojiWidth: number;
|
||||
};
|
||||
|
||||
const Title = styled(ContentEditable)<TitleProps>`
|
||||
line-height: ${lineHeight};
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: ${fontSize};
|
||||
font-weight: 500;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: ${(props) => (props.readOnly ? "default" : "text")};
|
||||
|
||||
> span {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
-webkit-text-fill-color: ${s("placeholder")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props: TitleProps) => -props.$emojiWidth}px;
|
||||
`};
|
||||
|
||||
${AnimatedStar} {
|
||||
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${AnimatedStar} {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
color: ${light.text};
|
||||
-webkit-text-fill-color: ${light.text};
|
||||
background: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(EditableTitle);
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
|
||||
const extensions = withComments(richExtensions);
|
||||
|
||||
type Props = Omit<EditorProps, "extensions" | "editorStyle"> & {
|
||||
onChangeTitle: (text: string) => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
onChangeEmoji: (emoji: string | null) => void;
|
||||
id: string;
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
@@ -56,6 +57,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const {
|
||||
document,
|
||||
onChangeTitle,
|
||||
onChangeEmoji,
|
||||
isDraft,
|
||||
shareId,
|
||||
readOnly,
|
||||
@@ -151,14 +153,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<EditableTitle
|
||||
<DocumentTitle
|
||||
ref={titleRef}
|
||||
readOnly={readOnly}
|
||||
document={document}
|
||||
documentId={document.id}
|
||||
title={
|
||||
!document.title && readOnly
|
||||
? document.titleWithDefault
|
||||
: document.title
|
||||
}
|
||||
emoji={document.emoji}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeEmoji={onChangeEmoji}
|
||||
onGoToNextInput={handleGoToNextInput}
|
||||
onChange={onChangeTitle}
|
||||
onBlur={handleBlur}
|
||||
starrable={!shareId}
|
||||
placeholder={t("Untitled")}
|
||||
/>
|
||||
{!shareId && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -21,6 +21,8 @@ import Button from "~/components/Button";
|
||||
import Collaborators from "~/components/Collaborators";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Header from "~/components/Header";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import Star from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { publishDocument } from "~/actions/definitions/documents";
|
||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
@@ -81,6 +83,7 @@ function DocumentHeader({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, auth } = useStores();
|
||||
const theme = useTheme();
|
||||
const { resolvedTheme } = ui;
|
||||
const { team } = auth;
|
||||
const isMobile = useMobile();
|
||||
@@ -104,7 +107,6 @@ function DocumentHeader({
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const can = usePolicy(document?.id);
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
const toc = (
|
||||
<Tooltip
|
||||
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
|
||||
@@ -138,7 +140,7 @@ function DocumentHeader({
|
||||
to={documentEditPath(document)}
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
{isMobile ? null : t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
@@ -185,7 +187,7 @@ function DocumentHeader({
|
||||
actions={
|
||||
<>
|
||||
{appearanceAction}
|
||||
{canEdit ? editAction : <div />}
|
||||
{can.update && !isEditing ? editAction : <div />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -200,11 +202,18 @@ function DocumentHeader({
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>{toc}</DocumentBreadcrumb>
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
</DocumentBreadcrumb>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<>
|
||||
{document.emoji && (
|
||||
<>
|
||||
<EmojiIcon size={24} emoji={document.emoji} />{" "}
|
||||
</>
|
||||
)}
|
||||
{document.title}{" "}
|
||||
{document.isArchived && (
|
||||
<ArchivedBadge>{t("Archived")}</ArchivedBadge>
|
||||
@@ -250,45 +259,56 @@ function DocumentHeader({
|
||||
disabled={savingIsDisabled}
|
||||
neutral={isDraft}
|
||||
>
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
{isDraft ? t("Save draft") : t("Done editing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
{canEdit && !team?.seamlessEditing && !isRevision && editAction}
|
||||
{canEdit && can.createChildDocument && !isRevision && !isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
label={(props) => (
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && isTemplate && !isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update &&
|
||||
!isEditing &&
|
||||
!team?.seamlessEditing &&
|
||||
!isRevision &&
|
||||
editAction}
|
||||
{can.update &&
|
||||
can.createChildDocument &&
|
||||
!isRevision &&
|
||||
!isMobile && (
|
||||
<Action>
|
||||
<NewChildDocumentMenu
|
||||
document={document}
|
||||
label={(props) => (
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<PlusIcon />} {...props} neutral>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{can.update &&
|
||||
!isEditing &&
|
||||
isTemplate &&
|
||||
!isDraft &&
|
||||
!isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
as={Link}
|
||||
to={newDocumentPath(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})}
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{revision && revision.createdAt !== document.updatedAt && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
@@ -318,27 +338,23 @@ function DocumentHeader({
|
||||
{document.collectionId ? t("Publish") : `${t("Publish")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
{!isEditing && (
|
||||
<>
|
||||
{!isDeleted && <Separator />}
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showDisplayOptions
|
||||
{!isDeleted && <Separator />}
|
||||
<Action>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showDisplayOptions
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { KeyboardIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import Guide from "~/components/Guide";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function KeyboardShortcutsButton() {
|
||||
const { t } = useTranslation();
|
||||
const [
|
||||
keyboardShortcutsOpen,
|
||||
handleOpenKeyboardShortcuts,
|
||||
handleCloseKeyboardShortcuts,
|
||||
] = useBoolean();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleOpenKeyboardShortcuts = () => {
|
||||
dialogs.openGuide({
|
||||
title: t("Keyboard shortcuts"),
|
||||
content: <KeyboardShortcuts />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Guide
|
||||
isOpen={keyboardShortcutsOpen}
|
||||
onRequestClose={handleCloseKeyboardShortcuts}
|
||||
title={t("Keyboard shortcuts")}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
</Guide>
|
||||
<Tooltip tooltip={t("Keyboard shortcuts")} shortcut="?" delay={500}>
|
||||
<Button onClick={handleOpenKeyboardShortcuts}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
<Tooltip tooltip={t("Keyboard shortcuts")} shortcut="?" delay={500}>
|
||||
<Button onClick={handleOpenKeyboardShortcuts}>
|
||||
<KeyboardIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,4 +44,4 @@ const Button = styled(NudeButton)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default KeyboardShortcutsButton;
|
||||
export default observer(KeyboardShortcutsButton);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { throttle } from "lodash";
|
||||
import throttle from "lodash/throttle";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import MultiplayerExtension from "@shared/editor/extensions/Multiplayer";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -16,7 +17,6 @@ import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { AwarenessChangeEvent } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = EditorProps & {
|
||||
@@ -135,13 +135,10 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
});
|
||||
|
||||
provider.on("close", (ev: MessageEvent) => {
|
||||
if ("code" in ev.event && ev.event.code === 1009) {
|
||||
provider.shouldConnect = false;
|
||||
showToast(
|
||||
t(
|
||||
"Sorry, this document is too large - edits will no longer be persisted."
|
||||
)
|
||||
);
|
||||
if ("code" in ev.event) {
|
||||
provider.shouldConnect =
|
||||
ev.event.code !== 1009 && ev.event.code !== 4401;
|
||||
ui.setMultiplayerStatus("disconnected", ev.event.code);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,9 +161,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
);
|
||||
}
|
||||
|
||||
provider.on("status", (ev: ConnectionStatusEvent) =>
|
||||
ui.setMultiplayerStatus(ev.status)
|
||||
);
|
||||
provider.on("status", (ev: ConnectionStatusEvent) => {
|
||||
if (ui.multiplayerStatus !== ev.status) {
|
||||
ui.setMultiplayerStatus(ev.status, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
setRemoteProvider(provider);
|
||||
|
||||
@@ -177,7 +176,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
provider?.destroy();
|
||||
void localProvider?.destroy();
|
||||
setRemoteProvider(null);
|
||||
ui.setMultiplayerStatus(undefined);
|
||||
ui.setMultiplayerStatus(undefined, undefined);
|
||||
};
|
||||
}, [
|
||||
history,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -52,6 +53,13 @@ const PublicBreadcrumb: React.FC<Props> = ({
|
||||
.slice(0, -1)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
title: item.emoji ? (
|
||||
<>
|
||||
<EmojiIcon emoji={item.emoji} /> {item.title}
|
||||
</>
|
||||
) : (
|
||||
item.title
|
||||
),
|
||||
type: "route",
|
||||
to: sharedDocumentPath(shareId, item.url),
|
||||
})),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||
@@ -49,8 +48,7 @@ const Title = styled.div`
|
||||
line-height: 1.25;
|
||||
padding-top: 3px;
|
||||
color: ${s("text")};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: ${s("fontFamily")};
|
||||
`;
|
||||
|
||||
function ReferenceListItem({
|
||||
@@ -60,7 +58,7 @@ function ReferenceListItem({
|
||||
shareId,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { emoji } = parseTitle(document.title);
|
||||
const { emoji } = document;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { Meta as DocumentMeta } from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
|
||||
type Props = Omit<EditorProps, "extensions"> & {
|
||||
/** The ID of the revision */
|
||||
id: string;
|
||||
/** The current document */
|
||||
document: Document;
|
||||
/** The revision to display */
|
||||
revision: Revision;
|
||||
isDraft: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -24,7 +27,12 @@ function RevisionViewer(props: Props) {
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
||||
<DocumentTitle
|
||||
documentId={revision.documentId}
|
||||
title={revision.title}
|
||||
emoji={revision.emoji}
|
||||
readOnly
|
||||
/>
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
revision={revision}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { debounce, isEmpty } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { flatten } from "lodash";
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { flatten } from "lodash";
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ function Home() {
|
||||
void pins.fetchPage();
|
||||
}, [pins]);
|
||||
|
||||
const canManageTeam = usePolicy(team).manage;
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -49,7 +49,7 @@ function Home() {
|
||||
>
|
||||
{!ui.languagePromptDismissed && <LanguagePrompt />}
|
||||
<Heading>{t("Home")}</Heading>
|
||||
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
|
||||
<PinnedDocuments pins={pins.home} canUpdate={can.update} />
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to="/home" exact>
|
||||
|
||||
+18
-15
@@ -5,7 +5,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Role } from "@shared/types";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -28,7 +28,7 @@ type Props = {
|
||||
type InviteRequest = {
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
function Invite({ onSubmit }: Props) {
|
||||
@@ -38,17 +38,17 @@ function Invite({ onSubmit }: Props) {
|
||||
{
|
||||
email: "",
|
||||
name: "",
|
||||
role: "member",
|
||||
role: UserRole.Member,
|
||||
},
|
||||
{
|
||||
email: "",
|
||||
name: "",
|
||||
role: "member",
|
||||
role: UserRole.Member,
|
||||
},
|
||||
{
|
||||
email: "",
|
||||
name: "",
|
||||
role: "member",
|
||||
role: UserRole.Member,
|
||||
},
|
||||
]);
|
||||
const { users } = useStores();
|
||||
@@ -65,7 +65,7 @@ function Invite({ onSubmit }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const data = await users.invite(invites);
|
||||
const data = await users.invite(invites.filter((i) => i.email));
|
||||
onSubmit();
|
||||
|
||||
if (data.sent.length > 0) {
|
||||
@@ -113,7 +113,7 @@ function Invite({ onSubmit }: Props) {
|
||||
newInvites.push({
|
||||
email: "",
|
||||
name: "",
|
||||
role: "member",
|
||||
role: UserRole.Member,
|
||||
});
|
||||
return newInvites;
|
||||
});
|
||||
@@ -138,13 +138,16 @@ function Invite({ onSubmit }: Props) {
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleRoleChange = React.useCallback((role: Role, index: number) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index]["role"] = role;
|
||||
return newInvites;
|
||||
});
|
||||
}, []);
|
||||
const handleRoleChange = React.useCallback(
|
||||
(role: UserRole, index: number) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index]["role"] = role;
|
||||
return newInvites;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -224,7 +227,7 @@ function Invite({ onSubmit }: Props) {
|
||||
flex
|
||||
/>
|
||||
<InputSelectRole
|
||||
onChange={(role: Role) => handleRoleChange(role, index)}
|
||||
onChange={(role: UserRole) => handleRoleChange(role, index)}
|
||||
value={invite.role}
|
||||
labelHidden={index !== 0}
|
||||
short
|
||||
|
||||
@@ -2,10 +2,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Key from "~/components/Key";
|
||||
import { isMac } from "~/utils/browser";
|
||||
import { metaDisplay, altDisplay } from "~/utils/keyboard";
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
@@ -442,6 +442,7 @@ const List = styled.dl`
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const Keys = styled.dt`
|
||||
|
||||
@@ -37,7 +37,13 @@ export default function Notices() {
|
||||
Please use a Google Workspaces account instead.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "maximum-teams" && (
|
||||
{notice === "pending-deletion" && (
|
||||
<Trans>
|
||||
The workspace associated with your user is scheduled for deletion and
|
||||
cannot at accessed at this time.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "maximum-reached" && (
|
||||
<Trans>
|
||||
The workspace you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find } from "lodash";
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon, EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from "lodash";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import queryString from "query-string";
|
||||
@@ -464,7 +464,7 @@ const SearchTitlesFilter = styled(Switch)`
|
||||
margin-left: 8px;
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export default withTranslation()(withStores(withRouter(Search)));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user