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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
You’re 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)));
|
||||
@@ -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} {
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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> {
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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 don’t 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
|
||||
);
|
||||
@@ -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]
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
74
app/scenes/Document/components/References.tsx
Normal file
74
app/scenes/Document/components/References.tsx
Normal 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);
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
Reference in New Issue
Block a user