chore: Move to Typescript (#2783)

This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
This commit is contained in:
Tom Moor
2021-11-29 06:40:55 -08:00
committed by GitHub
parent 25ccfb5d04
commit 15b1069bcc
1017 changed files with 17410 additions and 54942 deletions

View File

@@ -1,6 +1,5 @@
// @flow
import styled from "styled-components";
import Flex from "components/Flex";
import Flex from "~/components/Flex";
const Container = styled(Flex)`
position: relative;

View File

@@ -1,19 +1,24 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import HelpText from "components/HelpText";
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
import HelpText from "~/components/HelpText";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
type Props = {
headings: { title: string, level: number, id: string }[],
headings: {
title: string;
level: number;
id: string;
}[];
};
export default function Contents({ headings }: Props) {
const [activeSlug, setActiveSlug] = React.useState();
const position = useWindowScrollPosition({ throttle: 100 });
const [activeSlug, setActiveSlug] = React.useState<string>();
const position = useWindowScrollPosition({
throttle: 100,
});
React.useEffect(() => {
for (let key = 0; key < headings.length; key++) {
@@ -24,6 +29,7 @@ export default function Contents({ headings }: Props) {
if (element) {
const bounding = element.getBoundingClientRect();
if (bounding.top > HEADING_OFFSET) {
const last = headings[Math.max(0, key - 1)];
setActiveSlug(last.id);
@@ -66,7 +72,7 @@ export default function Contents({ headings }: Props) {
);
}
const Wrapper = styled("div")`
const Wrapper = styled.div`
display: none;
position: sticky;
top: 80px;
@@ -87,7 +93,7 @@ const Wrapper = styled("div")`
`};
`;
const Heading = styled("h3")`
const Heading = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
@@ -103,7 +109,7 @@ const Empty = styled(HelpText)`
font-size: 14px;
`;
const ListItem = styled("li")`
const ListItem = styled.li<{ level: number; active?: boolean }>`
margin-left: ${(props) => (props.level - 1) * 10}px;
margin-bottom: 8px;
padding-right: 2em;
@@ -117,7 +123,7 @@ const ListItem = styled("li")`
}
`;
const Link = styled("a")`
const Link = styled.a`
color: ${(props) => props.theme.text};
font-size: 14px;
@@ -126,7 +132,7 @@ const Link = styled("a")`
}
`;
const List = styled("ol")`
const List = styled.ol`
min-width: 14em;
width: 14em;
padding: 0;

View File

@@ -1,53 +1,58 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import invariant from "invariant";
import { deburr, sortBy } from "lodash";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import RevisionsStore from "stores/RevisionsStore";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import { RouteComponentProps, StaticContext } from "react-router";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import withStores from "~/components/withStores";
import { NavigationNode } from "~/types";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit, updateDocumentUrl } from "~/utils/routeHelpers";
import { isInternalUrl } from "~/utils/urls";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
auth: AuthStore,
location: LocationWithState,
shares: SharesStore,
documents: DocumentsStore,
policies: PoliciesStore,
revisions: RevisionsStore,
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
type Props = RootStore &
RouteComponentProps<
{
documentSlug: string;
revisionId?: string;
shareId?: string;
title?: string;
},
StaticContext,
{
title?: string;
}
> & {
children: (arg0: any) => React.ReactNode;
};
const sharedTreeCache = {};
@observer
class DataLoader extends React.Component<Props> {
sharedTree: ?NavigationNode;
@observable document: ?Document;
@observable revision: ?Revision;
@observable shapshot: ?Blob;
@observable error: ?Error;
sharedTree: NavigationNode | null | undefined;
@observable
document: Document | null | undefined;
@observable
revision: Revision | null | undefined;
@observable
shapshot: Blob | null | undefined;
@observable
error: Error | null | undefined;
componentDidMount() {
const { documents, match } = this.props;
@@ -78,6 +83,7 @@ class DataLoader extends React.Component<Props> {
// Also need to load the revision if it changes
const { revisionId } = this.props.match.params;
if (
prevProps.match.params.revisionId !== revisionId &&
revisionId &&
@@ -98,14 +104,16 @@ class DataLoader extends React.Component<Props> {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
try {
const {
document,
}: { document: Document } = await this.props.documents.fetch(slug);
if (!slug) {
return;
}
try {
const document = await this.props.documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return [
{
title: document.title,
@@ -125,10 +133,11 @@ class DataLoader extends React.Component<Props> {
const results = await this.props.documents.searchTitles(term);
return sortBy(
results.map((document) => {
results.map((document: Document) => {
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return {
title: document.title,
subtitle: `Updated ${time}`,
@@ -160,7 +169,10 @@ class DataLoader extends React.Component<Props> {
loadRevision = async () => {
const { revisionId } = this.props.match.params;
this.revision = await this.props.revisions.fetch(revisionId);
if (revisionId) {
this.revision = await this.props.revisions.fetch(revisionId);
}
};
loadDocument = async () => {
@@ -172,10 +184,12 @@ class DataLoader extends React.Component<Props> {
}
try {
const response = await this.props.documents.fetch(documentSlug, {
shareId,
});
const response = await this.props.documents.fetchWithSharedTree(
documentSlug,
{
shareId,
}
);
this.sharedTree = response.sharedTree;
this.document = response.document;
sharedTreeCache[this.document.id] = response.sharedTree;
@@ -194,7 +208,6 @@ class DataLoader extends React.Component<Props> {
if (document) {
const can = this.props.policies.abilities(document.id);
// sets the document as active in the sidebar, ideally in the future this
// will be route driven.
this.props.ui.setActiveDocument(document);
@@ -202,7 +215,7 @@ class DataLoader extends React.Component<Props> {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && this.isEditing) {
this.props.history.push(document.url);
history.push(document.url);
return;
}
@@ -218,10 +231,12 @@ class DataLoader extends React.Component<Props> {
const isMove = this.props.location.pathname.match(/move$/);
const canRedirect = !revisionId && !isMove && !shareId;
if (canRedirect) {
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
if (this.props.location.pathname !== canonicalUrl) {
this.props.history.replace(canonicalUrl);
history.replace(canonicalUrl);
}
}
}
@@ -255,7 +270,6 @@ class DataLoader extends React.Component<Props> {
}
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
@@ -284,13 +298,4 @@ class DataLoader extends React.Component<Props> {
}
}
export default withRouter(
inject(
"ui",
"auth",
"documents",
"revisions",
"policies",
"shares"
)(DataLoader)
);
export default withStores(DataLoader);

View File

@@ -1,33 +1,47 @@
// @flow
import { debounce } from "lodash";
import { action, observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { InputIcon } from "outline-icons";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import { WithTranslation, Trans, withTranslation } from "react-i18next";
import {
Prompt,
Route,
RouteComponentProps,
StaticContext,
withRouter,
} from "react-router";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import getTasks from "shared/utils/getTasks";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Revision from "models/Revision";
import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ConnectionStatus from "components/ConnectionStatus";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
import Modal from "components/Modal";
import Notice from "components/Notice";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import RegisterKeyDown from "components/RegisterKeyDown";
import Time from "components/Time";
import getTasks from "@shared/utils/getTasks";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentMove from "~/scenes/DocumentMove";
import Branding from "~/components/Branding";
import ConnectionStatus from "~/components/ConnectionStatus";
import ErrorBoundary from "~/components/ErrorBoundary";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import Notice from "~/components/Notice";
import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Time from "~/components/Time";
import withStores from "~/components/withStores";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import { isCustomDomain } from "~/utils/domains";
import { emojiToUrl } from "~/utils/emoji";
import { isModKey } from "~/utils/keyboard";
import {
documentMoveUrl,
documentHistoryUrl,
editDocumentUrl,
documentUrl,
} from "~/utils/routeHelpers";
import Container from "./Container";
import Contents from "./Contents";
import Editor from "./Editor";
@@ -36,55 +50,60 @@ import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed";
import PublicReferences from "./PublicReferences";
import References from "./References";
import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { client } from "utils/ApiClient";
import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { isModKey } from "utils/keyboard";
import {
documentMoveUrl,
documentHistoryUrl,
editDocumentUrl,
documentUrl,
} from "utils/routeHelpers";
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
type Props = {
match: Match,
history: RouterHistory,
location: LocationWithState,
sharedTree: ?NavigationNode,
abilities: Object,
document: Document,
revision: Revision,
readOnly: boolean,
onCreateLink: (title: string) => Promise<string>,
onSearchLink: (term: string) => any,
theme: Theme,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
t: TFunction,
};
type Props = WithTranslation &
RootStore &
RouteComponentProps<
Record<string, string>,
StaticContext,
{ restore?: boolean; revisionId?: string }
> & {
sharedTree?: NavigationNode;
abilities: Record<string, any>;
document: Document;
revision?: Revision;
readOnly: boolean;
shareId?: string;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => any;
};
@observer
class DocumentScene extends React.Component<Props> {
@observable editor = React.createRef();
@observable isUploading: boolean = false;
@observable isSaving: boolean = false;
@observable isPublishing: boolean = false;
@observable isDirty: boolean = false;
@observable isEmpty: boolean = true;
@observable lastRevision: number = this.props.document.revision;
@observable title: string = this.props.document.title;
@observable
editor = React.createRef();
@observable
isUploading = false;
@observable
isSaving = false;
@observable
isPublishing = false;
@observable
isDirty = false;
@observable
isEmpty = true;
@observable
lastRevision: number = this.props.document.revision;
@observable
title: string = this.props.document.title;
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
this.updateIsDirty();
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
const { auth, document, t } = this.props;
if (prevProps.readOnly && !this.props.readOnly) {
@@ -129,12 +148,13 @@ class DocumentScene extends React.Component<Props> {
replaceDocument = (template: Document | Revision) => {
this.title = template.title;
this.isDirty = true;
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
// @ts-expect-error ts-migrate(2339) FIXME: Property 'view' does not exist on type 'unknown'.
const { view, parser } = editorRef;
view.dispatch(
view.state.tr
@@ -145,9 +165,9 @@ class DocumentScene extends React.Component<Props> {
if (template instanceof Document) {
this.props.document.templateId = template.id;
}
this.props.document.title = template.title;
this.props.document.text = template.text;
this.updateIsDirty();
};
@@ -155,8 +175,8 @@ class DocumentScene extends React.Component<Props> {
const { toasts, history, location, t } = this.props;
const restore = location.state?.restore;
const revisionId = location.state?.revisionId;
const editorRef = this.editor.current;
if (!editorRef || !restore) {
return;
}
@@ -172,9 +192,9 @@ class DocumentScene extends React.Component<Props> {
}
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
goToMove = (ev) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { document, abilities } = this.props;
@@ -183,9 +203,9 @@ class DocumentScene extends React.Component<Props> {
}
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
goToEdit = (ev) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { document, abilities } = this.props;
@@ -194,17 +214,10 @@ class DocumentScene extends React.Component<Props> {
}
};
goBack = (ev) => {
if (this.props.readOnly) return;
ev.preventDefault();
this.props.history.goBack();
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
goToHistory = (ev) => {
if (!this.props.readOnly) return;
if (ev.ctrlKey) return;
ev.preventDefault();
const { document, location } = this.props;
@@ -215,16 +228,20 @@ class DocumentScene extends React.Component<Props> {
}
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
onPublish = (ev) => {
ev.preventDefault();
const { document } = this.props;
if (document.publishedAt) return;
this.onSave({ publish: true, done: true });
this.onSave({
publish: true,
done: true,
});
};
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
onToggleTableOfContents = (ev) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { ui } = this.props;
@@ -237,13 +254,12 @@ class DocumentScene extends React.Component<Props> {
onSave = async (
options: {
done?: boolean,
publish?: boolean,
autosave?: boolean,
done?: boolean;
publish?: boolean;
autosave?: boolean;
} = {}
) => {
const { document, auth } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -252,6 +268,7 @@ class DocumentScene extends React.Component<Props> {
const title = this.title;
// prevent save before anything has been written (single hash is empty doc)
// @ts-expect-error ts-migrate(2367) FIXME: This condition will always return 'false' since th... Remove this comment to see the full error message
if (text.trim() === "" && title.trim === "") return;
// prevent autosave if nothing has changed
@@ -259,19 +276,20 @@ class DocumentScene extends React.Component<Props> {
options.autosave &&
document.text.trim() === text.trim() &&
document.title.trim() === title.trim()
)
) {
return;
}
document.title = title;
document.text = text;
document.tasks = getTasks(document.text);
let isNew = !document.id;
const isNew = !document.id;
this.isSaving = true;
this.isPublishing = !!options.publish;
try {
let savedDocument = document;
if (auth.team?.collaborativeEditing) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
@@ -298,7 +316,9 @@ class DocumentScene extends React.Component<Props> {
this.props.ui.setActiveDocument(savedDocument);
}
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.toasts.showToast(err.message, {
type: "error",
});
} finally {
this.isSaving = false;
this.isPublishing = false;
@@ -306,7 +326,10 @@ class DocumentScene extends React.Component<Props> {
};
autosave = debounce(() => {
this.onSave({ done: false, autosave: true });
this.onSave({
done: false,
autosave: true,
});
}, AUTOSAVE_DELAY);
updateIsDirty = () => {
@@ -330,9 +353,8 @@ class DocumentScene extends React.Component<Props> {
this.isUploading = false;
};
onChange = (getEditorText) => {
onChange = (getEditorText: () => string) => {
const { document, auth } = this.props;
this.getEditorText = getEditorText;
// If the multiplayer editor is enabled then we still want to keep the local
@@ -342,7 +364,6 @@ class DocumentScene extends React.Component<Props> {
document.text = this.getEditorText();
document.tasks = getTasks(document.text);
})();
return;
}
@@ -350,14 +371,17 @@ class DocumentScene extends React.Component<Props> {
// in that case we don't delay in saving for a better user experience.
if (this.props.readOnly) {
this.updateIsDirty();
this.onSave({ done: false, autosave: true });
this.onSave({
done: false,
autosave: true,
});
} else {
this.updateIsDirtyDebounced();
this.autosave();
}
};
onChangeTitle = (value) => {
onChangeTitle = (value: string) => {
this.title = value;
this.updateIsDirtyDebounced();
this.autosave();
@@ -375,23 +399,20 @@ class DocumentScene extends React.Component<Props> {
abilities,
auth,
ui,
match,
shareId,
t,
} = this.props;
const team = auth.team;
const { shareId } = match.params;
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const disableEmbeds =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const headings = this.editor.current
? this.editor.current.getHeadings()
? // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
this.editor.current.getHeadings()
: [];
const showContents =
ui.tocVisible && (readOnly || team?.collaborativeEditing);
const collaborativeEditing =
team?.collaborativeEditing &&
!document.isArchived &&
@@ -421,12 +442,7 @@ class DocumentScene extends React.Component<Props> {
}
}}
/>
<Background
key={revision ? revision.id : document.id}
isShare={isShare}
column
auto
>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
@@ -443,11 +459,10 @@ class DocumentScene extends React.Component<Props> {
)}
/>
<PageTitle
title={document.titleWithDefault.replace(document.emoji, "")}
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
@@ -482,6 +497,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ document: Document; shareId: any; isRevisi... Remove this comment to see the full error message
goBack={this.goBack}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
@@ -495,7 +511,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
{document.isTemplate && !readOnly && (
<Notice muted>
<Notice>
<Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
@@ -505,7 +521,7 @@ class DocumentScene extends React.Component<Props> {
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice muted>
<Notice>
{t("Archived by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
@@ -513,7 +529,7 @@ class DocumentScene extends React.Component<Props> {
</Notice>
)}
{document.deletedAt && (
<Notice muted>
<Notice>
<strong>
{t("Deleted by {{userName}}", {
userName: document.updatedBy.name,
@@ -617,7 +633,7 @@ const Background = styled(Container)`
transition: ${(props) => props.theme.backgroundTransition};
`;
const ReferencesWrapper = styled("div")`
const ReferencesWrapper = styled.div<{ isOnlyTitle?: boolean }>`
margin-top: ${(props) => (props.isOnlyTitle ? -45 : 16)}px;
@media print {
@@ -625,7 +641,11 @@ const ReferencesWrapper = styled("div")`
}
`;
const MaxWidth = styled(Flex)`
const MaxWidth = styled(Flex)<{
isEditing?: boolean;
archived?: boolean;
showContents?: boolean;
}>`
${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
@@ -639,7 +659,7 @@ const MaxWidth = styled(Flex)`
${breakpoint("tablet")`
padding: 0 24px;
margin: 4px auto 12px;
max-width: calc(48px + ${(props) =>
max-width: calc(48px + ${(props: any) =>
props.showContents ? "64em" : "46em"});
`};
@@ -648,8 +668,4 @@ const MaxWidth = styled(Flex)`
`};
`;
export default withRouter(
withTranslation()<DocumentScene>(
inject("ui", "auth", "toasts")(DocumentScene)
)
);
export default withTranslation()(withStores(withRouter(DocumentScene)));

View File

@@ -1,25 +1,24 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/theme";
import parseTitle from "shared/utils/parseTitle";
import Document from "models/Document";
import ContentEditable from "components/ContentEditable";
import Star, { AnimatedStar } from "components/Star";
import useStores from "hooks/useStores";
import { isModKey } from "utils/keyboard";
import { MAX_TITLE_LENGTH } from "@shared/constants";
import { light } from "@shared/theme";
import parseTitle from "@shared/utils/parseTitle";
import Document from "~/models/Document";
import ContentEditable from "~/components/ContentEditable";
import Star, { AnimatedStar } from "~/components/Star";
import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
value: string,
document: Document,
readOnly: boolean,
onChange: (text: string) => void,
onGoToNextInput: (insertParagraph?: boolean) => void,
onSave: (options: { publish?: boolean, done?: boolean }) => void,
value: string;
document: Document;
readOnly?: boolean;
onChange: (text: string) => void;
onGoToNextInput: (insertParagraph?: boolean) => void;
onSave?: (options: { publish?: boolean; done?: boolean }) => void;
};
function EditableTitle({
@@ -30,7 +29,6 @@ function EditableTitle({
onSave,
onGoToNextInput,
}: Props) {
const ref = React.useRef();
const { policies } = useStores();
const { t } = useTranslation();
const can = policies.abilities(document.id);
@@ -40,30 +38,39 @@ function EditableTitle({
!value && readOnly ? document.titleWithDefault : value;
const handleKeyDown = React.useCallback(
(event: SyntheticKeyboardEvent<>) => {
(event: React.KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
if (isModKey(event)) {
onSave({ done: true });
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 });
onSave?.({
publish: true,
done: true,
});
return;
}
if (event.key === "s" && isModKey(event)) {
event.preventDefault();
onSave({});
onSave?.({});
return;
}
},
@@ -72,7 +79,6 @@ function EditableTitle({
return (
<Title
ref={ref}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={
@@ -99,7 +105,10 @@ const StarButton = styled(Star)`
left: 4px;
`;
const Title = styled(ContentEditable)`
const Title = styled(ContentEditable)<{
$startsWithEmojiAndSpace: boolean;
$isStarred: boolean;
}>`
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
@@ -124,7 +133,8 @@ const Title = styled(ContentEditable)`
}
${breakpoint("tablet")`
margin-left: ${(props) => (props.$startsWithEmojiAndSpace ? "-1.2em" : 0)};
margin-left: ${(props: any) =>
props.$startsWithEmojiAndSpace ? "-1.2em" : 0};
`};
${AnimatedStar} {

View File

@@ -1,37 +1,41 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { type TFunction, withTranslation } from "react-i18next";
import PoliciesStore from "stores/PoliciesStore";
import Document from "models/Document";
import ClickablePadding from "components/ClickablePadding";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor, { type Props as EditorProps } from "components/Editor";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import { WithTranslation, withTranslation } from "react-i18next";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import HoverPreview from "~/components/HoverPreview";
import { documentHistoryUrl } from "~/utils/routeHelpers";
import EditableTitle from "./EditableTitle";
import MultiplayerEditor from "./MultiplayerEditor";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
...EditorProps,
onChangeTitle: (text: string) => void,
title: string,
document: Document,
isDraft: boolean,
shareId: ?string,
multiplayer?: boolean,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
policies: PoliciesStore,
t: TFunction,
|};
type Props = EditorProps &
WithTranslation & {
onChangeTitle: (text: string) => void;
title: string;
document: Document;
isDraft: boolean;
shareId: string | null | undefined;
multiplayer?: boolean;
onSave: (arg0: {
done?: boolean;
autosave?: boolean;
publish?: boolean;
}) => any;
innerRef: {
current: any;
};
children: React.ReactNode;
};
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
@observable
activeLinkEvent: MouseEvent | null | undefined;
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
focusAtStart = () => {
@@ -66,6 +70,7 @@ class DocumentEditor extends React.Component<Props> {
if (insertParagraph) {
this.insertParagraph();
}
this.focusAtStart();
};
@@ -79,12 +84,10 @@ class DocumentEditor extends React.Component<Props> {
readOnly,
innerRef,
children,
policies,
multiplayer,
t,
...rest
} = this.props;
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
return (
@@ -122,7 +125,7 @@ class DocumentEditor extends React.Component<Props> {
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
node={this.activeLinkEvent.target as HTMLAnchorElement}
event={this.activeLinkEvent}
onClose={this.handleLinkInactive}
/>
@@ -133,6 +136,4 @@ class DocumentEditor extends React.Component<Props> {
}
}
export default withTranslation()<DocumentEditor>(
inject("policies")(DocumentEditor)
);
export default withTranslation()(DocumentEditor);

View File

@@ -1,4 +1,3 @@
// @flow
import { observer } from "mobx-react";
import {
TableOfContentsIcon,
@@ -12,46 +11,51 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import { Action, Separator } from "components/Actions";
import Badge from "components/Badge";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import { Theme } from "~/stores/UiStore";
import Document from "~/models/Document";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Header from "~/components/Header";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import TemplatesMenu from "~/menus/TemplatesMenu";
import { NavigationNode } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
import { newDocumentPath, editDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
sharedTree: ?NavigationNode,
shareId: ?string,
isDraft: boolean,
isEditing: boolean,
isRevision: boolean,
isSaving: boolean,
isPublishing: boolean,
publishingIsDisabled: boolean,
savingIsDisabled: boolean,
onSelectTemplate: (template: Document) => void,
onDiscard: () => void,
onSave: ({
done?: boolean,
publish?: boolean,
autosave?: boolean,
}) => void,
headings: { title: string, level: number, id: string }[],
|};
type Props = {
document: Document;
sharedTree: NavigationNode | undefined;
shareId: string | null | undefined;
isDraft: boolean;
isEditing: boolean;
isRevision: boolean;
isSaving: boolean;
isPublishing: boolean;
publishingIsDisabled: boolean;
savingIsDisabled: boolean;
onSelectTemplate: (template: Document) => void;
onDiscard: () => void;
onSave: (options: {
done?: boolean;
publish?: boolean;
autosave?: boolean;
}) => void;
headings: {
title: string;
level: number;
id: string;
}[];
};
function DocumentHeader({
document,
@@ -75,11 +79,16 @@ function DocumentHeader({
const isMobile = useMobile();
const handleSave = React.useCallback(() => {
onSave({ done: true });
onSave({
done: true,
});
}, [onSave]);
const handlePublish = React.useCallback(() => {
onSave({ done: true, publish: true });
onSave({
done: true,
publish: true,
});
}, [onSave]);
const isNew = document.isNewDocument;
@@ -87,7 +96,6 @@ function DocumentHeader({
const can = policies.abilities(document.id);
const canToggleEmbeds = team?.documentEmbeds;
const canEdit = can.update && !isEditing;
const toc = (
<Tooltip
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
@@ -106,11 +114,12 @@ function DocumentHeader({
/>
</Tooltip>
);
const editAction = (
<Action>
<Tooltip
tooltip={t("Edit {{noun}}", { noun: document.noun })}
tooltip={t("Edit {{noun}}", {
noun: document.noun,
})}
shortcut="e"
delay={500}
placement="bottom"
@@ -126,7 +135,6 @@ function DocumentHeader({
</Tooltip>
</Action>
);
const appearanceAction = (
<Action>
<Tooltip
@@ -139,7 +147,7 @@ function DocumentHeader({
<Button
icon={resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? "dark" : "light")
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
}
neutral
borderOnHover
@@ -192,7 +200,7 @@ function DocumentHeader({
<TableOfContentsMenu headings={headings} />
</TocWrapper>
)}
{!isPublishing && isSaving && !team.collaborativeEditing && (
{!isPublishing && isSaving && !team?.collaborativeEditing && (
<Status>{t("Saving")}</Status>
)}
<Collaborators document={document} />
@@ -229,7 +237,7 @@ function DocumentHeader({
</Action>
</>
)}
{canEdit && !team.collaborativeEditing && editAction}
{canEdit && !team?.collaborativeEditing && editAction}
{canEdit && can.createChildDocument && !isMobile && (
<Action>
<NewChildDocumentMenu

View File

@@ -1,10 +1,9 @@
// @flow
import * as React from "react";
import UiStore from "stores/UiStore";
import UiStore from "~/stores/UiStore";
type Props = {
ui: UiStore,
children?: React.Node,
ui: UiStore;
children?: React.ReactNode;
};
class HideSidebar extends React.Component<Props> {

View File

@@ -1,14 +1,13 @@
// @flow
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 KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import Guide from "~/components/Guide";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
@@ -17,7 +16,6 @@ function KeyboardShortcutsButton() {
handleOpenKeyboardShortcuts,
handleCloseKeyboardShortcuts,
] = useBoolean();
return (
<>
<Guide

View File

@@ -1,19 +1,17 @@
// @flow
import { Location } from "history";
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import PlaceholderDocument from "~/components/PlaceholderDocument";
import Container from "./Container";
import type { LocationWithState } from "types";
type Props = {|
location: LocationWithState,
|};
type Props = {
location: Location<{ title?: string }>;
};
export default function Loading({ location }: Props) {
const { t } = useTranslation();
return (
<Container column auto>
<PageTitle

View File

@@ -1,20 +1,17 @@
// @flow
import * as React from "react";
import Document from "models/Document";
import Document from "~/models/Document";
const MARK_AS_VIEWED_AFTER = 3 * 1000;
type Props = {|
document: Document,
children?: React.Node,
|};
type Props = {
document: Document;
children?: React.ReactNode;
};
class MarkAsViewed extends React.Component<Props> {
viewTimeout: TimeoutID;
viewTimeout: ReturnType<typeof setTimeout>;
componentDidMount() {
const { document } = this.props;
this.viewTimeout = setTimeout(async () => {
const view = await document.view();

View File

@@ -1,27 +1,37 @@
// @flow
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
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 Editor, { type Props as EditorProps } from "components/Editor";
import env from "env";
import useCurrentToken from "hooks/useCurrentToken";
import useCurrentUser from "hooks/useCurrentUser";
import useIdle from "hooks/useIdle";
import useIsMounted from "hooks/useIsMounted";
import usePageVisibility from "hooks/usePageVisibility";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homePath } from "utils/routeHelpers";
import Editor, { Props as EditorProps } from "~/components/Editor";
import env from "~/env";
import useCurrentToken from "~/hooks/useCurrentToken";
import useCurrentUser from "~/hooks/useCurrentUser";
import useIdle from "~/hooks/useIdle";
import useIsMounted from "~/hooks/useIsMounted";
import usePageVisibility from "~/hooks/usePageVisibility";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import MultiplayerExtension from "~/multiplayer/MultiplayerExtension";
import { homePath } from "~/utils/routeHelpers";
type Props = {|
...EditorProps,
id: string,
onSynced?: () => void,
|};
type Props = EditorProps & {
id: string;
onSynced?: () => Promise<void>;
};
export type ConnectionStatus =
| "connecting"
| "connected"
| "disconnected"
| void;
type AwarenessChangeEvent = { states: { user: { id: string }; cursor: any }[] };
type ConnectionStatusEvent = { status: ConnectionStatus };
type MessageEvent = { message: string };
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const documentId = props.id;
@@ -31,7 +41,10 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const { presence, ui } = useStores();
const token = useCurrentToken();
const [showCursorNames, setShowCursorNames] = React.useState(false);
const [remoteProvider, setRemoteProvider] = React.useState();
const [
remoteProvider,
setRemoteProvider,
] = React.useState<HocuspocusProvider | null>(null);
const [isLocalSynced, setLocalSynced] = React.useState(false);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
@@ -47,15 +60,12 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
React.useLayoutEffect(() => {
const debug = env.ENVIRONMENT === "development";
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
url: `${env.COLLABORATION_URL}/collaboration`,
debug,
name,
document: ydoc,
token,
maxReconnectTimeout: 10000,
});
provider.on("authenticationFailed", () => {
@@ -64,11 +74,10 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
"Sorry, it looks like you dont have permission to access the document"
)
);
history.replace(homePath());
});
provider.on("awarenessChange", ({ states }) => {
provider.on("awarenessChange", ({ states }: AwarenessChangeEvent) => {
states.forEach(({ user, cursor }) => {
if (user) {
// could know if the user is editing here using `state.cursor` but it
@@ -90,7 +99,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
};
provider.on("awarenessChange", showCursorNames);
localProvider.on("synced", () =>
// only set local storage to "synced" if it's loaded a non-empty doc
setLocalSynced(!!ydoc.get("default")._start)
@@ -101,24 +109,28 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
if (debug) {
provider.on("status", (ev) => console.log("status", ev.status));
provider.on("message", (ev) => console.log("incoming", ev.message));
provider.on("outgoingMessage", (ev) =>
provider.on("status", (ev: ConnectionStatusEvent) =>
console.log("status", ev.status)
);
provider.on("message", (ev: MessageEvent) =>
console.log("incoming", ev.message)
);
provider.on("outgoingMessage", (ev: MessageEvent) =>
console.log("outgoing", ev.message)
);
localProvider.on("synced", (ev) => console.log("local synced"));
localProvider.on("synced", () => console.log("local synced"));
}
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
provider.on("status", (ev: ConnectionStatusEvent) =>
ui.setMultiplayerStatus(ev.status)
);
setRemoteProvider(provider);
return () => {
provider?.destroy();
localProvider?.destroy();
setRemoteProvider(null);
ui.setMultiplayerStatus(undefined);
};
}, [
@@ -131,6 +143,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
token,
ydoc,
currentUser.id,
isMounted,
]);
const user = React.useMemo(() => {
@@ -167,6 +180,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
if (!remoteProvider) {
return;
}
if (
isIdle &&
!isVisible &&
@@ -174,6 +188,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
) {
remoteProvider.disconnect();
}
if (
(!isIdle || isVisible) &&
remoteProvider.status === WebSocketStatus.Disconnected
@@ -189,7 +204,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
// while the collaborative document is loading, we render a version of the
// document from the last text cache in read-only mode if we have it.
const showCache = !isLocalSynced && !isRemoteSynced;
return (
<>
{showCache && (
@@ -201,13 +215,19 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
defaultValue={undefined}
extensions={extensions}
ref={showCache ? undefined : ref}
style={showCache ? { display: "none" } : undefined}
style={
showCache
? {
display: "none",
}
: undefined
}
className={showCursorNames ? "show-cursor-names" : undefined}
/>
</>
);
}
export default React.forwardRef<any, typeof MultiplayerEditor>(
export default React.forwardRef<typeof MultiplayerEditor, any>(
MultiplayerEditor
);

View File

@@ -1,24 +1,32 @@
// @flow
import * as React from "react";
import Breadcrumb from "components/Breadcrumb";
import type { NavigationNode } from "types";
import Breadcrumb from "~/components/Breadcrumb";
import { NavigationNode } from "~/types";
type Props = {|
documentId: string,
shareId: string,
sharedTree: ?NavigationNode,
children?: React.Node,
|};
type Props = {
documentId: string;
shareId: string;
sharedTree: NavigationNode | undefined;
children?: React.ReactNode;
};
function pathToDocument(sharedTree, documentId) {
let path = [];
const traveler = (nodes, previousPath) => {
function pathToDocument(
sharedTree: NavigationNode | undefined,
documentId: string
) {
let path: NavigationNode[] = [];
const traveler = (
nodes: NavigationNode[],
previousPath: NavigationNode[]
) => {
nodes.forEach((childNode) => {
const newPath = [...previousPath, childNode];
if (childNode.id === documentId) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
@@ -26,6 +34,7 @@ function pathToDocument(sharedTree, documentId) {
if (sharedTree) {
traveler([sharedTree], []);
}
return path;
}
@@ -40,10 +49,7 @@ const PublicBreadcrumb = ({
pathToDocument(sharedTree, documentId)
.slice(0, -1)
.map((item) => {
return {
...item,
to: `/share/${shareId}${item.url}`,
};
return { ...item, to: `/share/${shareId}${item.url}` };
}),
[sharedTree, shareId, documentId]
);

View File

@@ -1,16 +1,15 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Subheading from "components/Subheading";
import Subheading from "~/components/Subheading";
import { NavigationNode } from "~/types";
import ReferenceListItem from "./ReferenceListItem";
import { type NavigationNode } from "types";
type Props = {|
shareId: string,
documentId: string,
sharedTree: NavigationNode,
|};
type Props = {
shareId: string;
documentId: string;
sharedTree?: NavigationNode;
};
function PublicReferences(props: Props) {
const { t } = useTranslation();
@@ -20,10 +19,11 @@ function PublicReferences(props: Props) {
// we must filter down the tree to only the part with the document we're
// currently viewing
const children = React.useMemo(() => {
let result;
let result: NavigationNode[];
function findChildren(node) {
function findChildren(node?: NavigationNode) {
if (!node) return;
if (node.id === documentId) {
result = node.children;
} else {
@@ -31,9 +31,11 @@ function PublicReferences(props: Props) {
if (result) {
return;
}
findChildren(node);
});
}
return result;
}

View File

@@ -1,19 +1,18 @@
// @flow
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import type { NavigationNode } from "types";
import Document from "~/models/Document";
import DocumentMeta from "~/components/DocumentMeta";
import { NavigationNode } from "~/types";
type Props = {|
shareId?: string,
document: Document | NavigationNode,
anchor?: string,
showCollection?: boolean,
|};
type Props = {
shareId?: string;
document: Document | NavigationNode;
anchor?: string;
showCollection?: boolean;
};
const DocumentLink = styled(Link)`
display: block;
@@ -73,21 +72,23 @@ function ReferenceListItem({
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
state: {
title: document.title,
},
}}
{...rest}
>
<Title dir="auto">
{document.emoji ? (
{document instanceof Document && document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
<StyledDocumentIcon color="currentColor" />
)}{" "}
{document.emoji
{document instanceof Document && document.emoji
? document.title.replace(new RegExp(`^${document.emoji}`), "")
: document.title}
</Title>
{document.updatedBy && (
{document instanceof Document && document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>

View File

@@ -1,82 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import { useLocation } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
import Fade from "components/Fade";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import ReferenceListItem from "./ReferenceListItem";
import useStores from "hooks/useStores";
type Props = {
document: Document,
documents: DocumentsStore,
collections: CollectionsStore,
};
function References({ document }: Props) {
const { collections, documents } = useStores();
const location = useLocation();
React.useEffect(() => {
documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = documents.getBacklinedDocuments(document.id);
const collection = collections.get(document.collectionId);
const children = collection
? collection.getDocumentChildren(document.id)
: [];
const showBacklinks = !!backlinks.length;
const showNestedDocuments = !!children.length;
const isBacklinksTab = location.hash === "#backlinks" || !showNestedDocuments;
return (
(showBacklinks || showNestedDocuments) && (
<Fade>
<Tabs>
{showNestedDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
<Trans>Nested documents</Trans>
</Tab>
)}
{showBacklinks && (
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
<Trans>Referenced by</Trans>
</Tab>
)}
</Tabs>
{isBacklinksTab
? backlinks.map((backlinkedDocument) => (
<ReferenceListItem
anchor={document.urlId}
key={backlinkedDocument.id}
document={backlinkedDocument}
showCollection={
backlinkedDocument.collectionId !== document.collectionId
}
/>
))
: children.map((node) => {
// If we have the document in the store already then use it to get the extra
// contextual info, otherwise the collection node will do (only has title and id)
const document = documents.get(node.id);
return (
<ReferenceListItem
key={node.id}
document={document || node}
showCollection={false}
/>
);
})}
</Fade>
)
);
}
export default observer(References);

View File

@@ -0,0 +1,74 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import { useLocation } from "react-router-dom";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useStores from "~/hooks/useStores";
import ReferenceListItem from "./ReferenceListItem";
type Props = {
document: Document;
};
function References({ document }: Props) {
const { collections, documents } = useStores();
const location = useLocation();
React.useEffect(() => {
documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = documents.getBacklinedDocuments(document.id);
const collection = collections.get(document.collectionId);
const children = collection
? collection.getDocumentChildren(document.id)
: [];
const showBacklinks = !!backlinks.length;
const showNestedDocuments = !!children.length;
const isBacklinksTab = location.hash === "#backlinks" || !showNestedDocuments;
return showBacklinks || showNestedDocuments ? (
<Fade>
<Tabs>
{showNestedDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}>
<Trans>Nested documents</Trans>
</Tab>
)}
{showBacklinks && (
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
<Trans>Referenced by</Trans>
</Tab>
)}
</Tabs>
{isBacklinksTab
? backlinks.map((backlinkedDocument) => (
<ReferenceListItem
anchor={document.urlId}
key={backlinkedDocument.id}
document={backlinkedDocument}
showCollection={
backlinkedDocument.collectionId !== document.collectionId
}
/>
))
: children.map((node) => {
// If we have the document in the store already then use it to get the extra
// contextual info, otherwise the collection node will do (only has title and id)
const document = documents.get(node.id);
return (
<ReferenceListItem
key={node.id}
document={document || node}
showCollection={false}
/>
);
})}
</Fade>
) : null;
}
export default observer(References);

View File

@@ -1,19 +1,18 @@
// @flow
import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "models/Document";
import Button from "components/Button";
import Popover from "components/Popover";
import Tooltip from "components/Tooltip";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import SharePopover from "./SharePopover";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
|};
type Props = {
document: Document;
};
function ShareButton({ document }: Props) {
const { t } = useTranslation();
@@ -22,6 +21,7 @@ function ShareButton({ document }: Props) {
const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
(share && share.published) || (sharedParent && sharedParent.published);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
@@ -55,6 +55,7 @@ function ShareButton({ document }: Props) {
</Tooltip>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")}>
<SharePopover
document={document}

View File

@@ -1,4 +1,3 @@
// @flow
import { formatDistanceToNow } from "date-fns";
import invariant from "invariant";
import { observer } from "mobx-react";
@@ -6,26 +5,26 @@ import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Share from "models/Share";
import Button from "components/Button";
import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useKeyDown from "hooks/useKeyDown";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Button from "~/components/Button";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import HelpText from "~/components/HelpText";
import Input from "~/components/Input";
import Notice from "~/components/Notice";
import Switch from "~/components/Switch";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {|
document: Document,
share: Share,
sharedParent: ?Share,
onRequestClose: () => void,
visible: boolean,
|};
type Props = {
document: Document;
share: Share | null | undefined;
sharedParent: Share | null | undefined;
onRequestClose: () => void;
visible: boolean;
};
function SharePopover({
document,
@@ -38,8 +37,8 @@ function SharePopover({
const { policies, shares, auth } = useStores();
const { showToast } = useToasts();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const buttonRef = React.useRef<?HTMLButtonElement>();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const can = policies.abilities(share ? share.id : "");
const documentAbilities = policies.abilities(document.id);
const canPublish =
@@ -56,7 +55,8 @@ function SharePopover({
document.share();
buttonRef.current?.focus();
}
return () => clearTimeout(timeout.current);
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
}, [document, visible]);
const handlePublishedChange = React.useCallback(
@@ -65,9 +65,13 @@ function SharePopover({
invariant(share, "Share must exist");
try {
await share.save({ published: event.currentTarget.checked });
await share.save({
published: event.currentTarget.checked,
});
} catch (err) {
showToast(err.message, { type: "error" });
showToast(err.message, {
type: "error",
});
}
},
[document.id, shares, showToast]
@@ -83,7 +87,9 @@ function SharePopover({
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
showToast(err.message, { type: "error" });
showToast(err.message, {
type: "error",
});
}
},
[document.id, shares, showToast]
@@ -91,12 +97,12 @@ function SharePopover({
const handleCopied = React.useCallback(() => {
setIsCopied(true);
timeout.current = setTimeout(() => {
setIsCopied(false);
onRequestClose();
showToast(t("Share link copied"), { type: "info" });
showToast(t("Share link copied"), {
type: "info",
});
}, 250);
}, [t, onRequestClose, showToast]);
@@ -115,8 +121,12 @@ function SharePopover({
<Notice>
<Trans
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
values={{ documentTitle: sharedParent.documentTitle }}
components={{ em: <strong /> }}
values={{
documentTitle: sharedParent.documentTitle,
}}
components={{
em: <strong />,
}}
/>
</Notice>
)}
@@ -132,15 +142,15 @@ function SharePopover({
/>
<SwitchLabel>
<SwitchText>
{share.published
{share?.published
? t("Anyone with the link can view this document")
: t("Only team members with permission can view")}
{share.lastAccessedAt && (
{share?.lastAccessedAt && (
<>
.{" "}
{t("The shared link was last accessed {{ timeAgo }}.", {
timeAgo: formatDistanceToNow(
Date.parse(share.lastAccessedAt),
Date.parse(share?.lastAccessedAt),
{
addSuffix: true,
}
@@ -214,8 +224,6 @@ const InputLink = styled(Input)`
`;
const SwitchLabel = styled(Flex)`
flex-align: center;
svg {
flex-shrink: 0;
}

View File

@@ -1,18 +1,19 @@
// @flow
import * as React from "react";
import { USER_PRESENCE_INTERVAL } from "shared/constants";
import { SocketContext } from "components/SocketProvider";
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
import { SocketContext } from "~/components/SocketProvider";
type Props = {
children?: React.Node,
documentId: string,
isEditing: boolean,
children?: React.ReactNode;
documentId: string;
isEditing: boolean;
};
export default class SocketPresence extends React.Component<Props> {
static contextType = SocketContext;
previousContext: any;
editingInterval: IntervalID;
previousContext: typeof SocketContext;
editingInterval: ReturnType<typeof setInterval>;
componentDidMount() {
this.editingInterval = setInterval(() => {
@@ -33,7 +34,9 @@ export default class SocketPresence extends React.Component<Props> {
componentWillUnmount() {
if (this.context) {
this.context.emit("leave", { documentId: this.props.documentId });
this.context.emit("leave", {
documentId: this.props.documentId,
});
this.context.off("authenticated", this.emitJoin);
}
@@ -47,6 +50,7 @@ export default class SocketPresence extends React.Component<Props> {
if (this.context.authenticated) {
this.emitJoin();
}
this.context.on("authenticated", () => {
this.emitJoin();
});
@@ -55,7 +59,6 @@ export default class SocketPresence extends React.Component<Props> {
emitJoin = () => {
if (!this.context) return;
this.context.emit("join", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,
@@ -64,7 +67,6 @@ export default class SocketPresence extends React.Component<Props> {
emitPresence = () => {
if (!this.context) return;
this.context.emit("presence", {
documentId: this.props.documentId,
isEditing: this.props.isEditing,