Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Moor 38fa3ed903 lint 2022-10-18 20:53:15 -04:00
Tom Moor c269d9f1a3 Add document context to allow accessing editor in header, modals, and elsewhere 2022-10-18 20:53:15 -04:00
4 changed files with 254 additions and 180 deletions
+33
View File
@@ -23,6 +23,7 @@ import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings"; import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks"; import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark"; import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node"; import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode"; import ReactNode from "@shared/editor/nodes/ReactNode";
@@ -571,6 +572,9 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: false }); this.setState({ blockMenuOpen: false });
}; };
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => { public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc); const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection); const transaction = this.view.state.tr.setSelection(selection);
@@ -578,6 +582,9 @@ export class Editor extends React.PureComponent<
this.view.focus(); this.view.focus();
}; };
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => { public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc); const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection); const transaction = this.view.state.tr.setSelection(selection);
@@ -585,14 +592,40 @@ export class Editor extends React.PureComponent<
this.view.focus(); this.view.focus();
}; };
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => { public getHeadings = () => {
return getHeadings(this.view.state.doc); return getHeadings(this.view.state.doc);
}; };
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => { public getTasks = () => {
return getTasks(this.view.state.doc); return getTasks(this.view.state.doc);
}; };
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
public render() { public render() {
const { const {
dir, dir,
+196 -179
View File
@@ -45,6 +45,8 @@ import {
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import Container from "./Container"; import Container from "./Container";
import Contents from "./Contents"; import Contents from "./Contents";
import DocumentContext from "./DocumentContext";
import type { DocumentContextValue } from "./DocumentContext";
import Editor from "./Editor"; import Editor from "./Editor";
import Header from "./Header"; import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton"; import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
@@ -110,6 +112,14 @@ class DocumentScene extends React.Component<Props> {
@observable @observable
headings: Heading[] = []; headings: Heading[] = [];
@observable
documentContext: DocumentContextValue = {
editor: null,
setEditor: action((editor: TEditor) => {
this.documentContext.editor = editor;
}),
};
getEditorText: () => string = () => this.props.document.text; getEditorText: () => string = () => this.props.document.text;
componentDidMount() { componentDidMount() {
@@ -452,196 +462,203 @@ class DocumentScene extends React.Component<Props> {
return ( return (
<ErrorBoundary> <ErrorBoundary>
{this.props.location.pathname !== canonicalUrl && ( <DocumentContext.Provider value={this.documentContext}>
<Redirect {this.props.location.pathname !== canonicalUrl && (
to={{ <Redirect
pathname: canonicalUrl, to={{
state: this.props.location.state, pathname: canonicalUrl,
hash: this.props.location.hash, state: this.props.location.state,
hash: this.props.location.hash,
}}
/>
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
<RegisterKeyDown trigger="h" handler={this.goToHistory} />
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
}} }}
/> />
)} <RegisterKeyDown
<RegisterKeyDown trigger="m" handler={this.goToMove} /> trigger="h"
<RegisterKeyDown trigger="e" handler={this.goToEdit} /> handler={(event) => {
<RegisterKeyDown trigger="Escape" handler={this.goBack} /> if (event.ctrlKey && event.altKey) {
<RegisterKeyDown trigger="h" handler={this.goToHistory} /> this.onToggleTableOfContents(event);
<RegisterKeyDown
trigger="p"
handler={(event) => {
if (isModKey(event) && event.shiftKey) {
this.onPublish(event);
}
}}
/>
<RegisterKeyDown
trigger="h"
handler={(event) => {
if (event.ctrlKey && event.altKey) {
this.onToggleTableOfContents(event);
}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Route
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<DocumentMove
document={document}
onRequestClose={this.goBack}
/>
</Modal>
)}
/>
<PageTitle
title={document.titleWithDefault.replace(document.emoji || "", "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{!readOnly && (
<>
<Prompt
when={
this.isEditorDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={(location, action) => {
if (
// a URL replace matching the current document indicates a title change
// no guard is needed for this transition
action === "REPLACE" &&
location.pathname === editDocumentUrl(document)
) {
return true;
}
return t(
`You have unsaved changes.\nAre you sure you want to discard them?`
) as string;
}}
/>
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
} }
savingIsDisabled={document.isSaving || this.isEmpty} }}
sharedTree={this.props.sharedTree} />
onSelectTemplate={this.replaceDocument} <Background key={revision ? revision.id : document.id} column auto>
onSave={this.onSave} <Route
headings={this.headings} path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
onRequestClose={this.goBack}
isOpen
>
<DocumentMove
document={document}
onRequestClose={this.goBack}
/>
</Modal>
)}
/> />
<MaxWidth <PageTitle
archived={document.isArchived} title={document.titleWithDefault.replace(
showContents={showContents} document.emoji || "",
isEditing={!readOnly} ""
isFullWidth={document.fullWidth} )}
column favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
auto />
> {(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Notices document={document} readOnly={readOnly} /> <Container justify="center" column auto>
<React.Suspense fallback={<PlaceholderDocument />}> {!readOnly && (
<Flex auto={!readOnly}> <>
{revision ? ( <Prompt
<RevisionViewer when={
isDraft={document.isDraft} this.isEditorDirty &&
document={document} !this.isUploading &&
revision={revision} !team?.collaborativeEditing
id={revision.id} }
/> message={(location, action) => {
) : ( if (
<> // a URL replace matching the current document indicates a title change
{showContents && ( // no guard is needed for this transition
<Contents action === "REPLACE" &&
headings={this.headings} location.pathname === editDocumentUrl(document)
isFullWidth={document.fullWidth} ) {
/> return true;
)} }
<Editor
id={document.id} return t(
key={embedsDisabled ? "disabled" : "enabled"} `You have unsaved changes.\nAre you sure you want to discard them?`
ref={this.editor} ) as string;
multiplayer={collaborativeEditing} }}
shareId={shareId} />
<Prompt
when={this.isUploading && !this.isEditorDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
/>
</>
)}
<Header
document={document}
documentHasHeadings={hasHeadings}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={
document.isSaving || this.isPublishing || this.isEmpty
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
headings={this.headings}
/>
<MaxWidth
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
{revision ? (
<RevisionViewer
isDraft={document.isDraft} isDraft={document.isDraft}
template={document.isTemplate}
document={document} document={document}
value={readOnly ? document.text : undefined} revision={revision}
defaultValue={document.text} id={revision.id}
embedsDisabled={embedsDisabled} />
onSynced={this.onSynced} ) : (
onFileUploadStart={this.onFileUploadStart} <>
onFileUploadStop={this.onFileUploadStop} {showContents && (
onSearchLink={this.props.onSearchLink} <Contents
onCreateLink={this.props.onCreateLink} headings={this.headings}
onChangeTitle={this.onChangeTitle} isFullWidth={document.fullWidth}
onChange={this.onChange} />
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)} )}
{!isShare && !revision && ( <Editor
<> id={document.id}
<MarkAsViewed document={document} /> key={embedsDisabled ? "disabled" : "enabled"}
ref={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.text : undefined}
defaultValue={document.text}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}
onFileUploadStop={this.onFileUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onHeadingsChange={this.onHeadingsChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
>
{shareId && (
<ReferencesWrapper <ReferencesWrapper
isOnlyTitle={document.isOnlyTitle} isOnlyTitle={document.isOnlyTitle}
> >
<References document={document} /> <PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper> </ReferencesWrapper>
</> )}
)} {!isShare && !revision && (
</Editor> <>
</> <MarkAsViewed document={document} />
)} <ReferencesWrapper
</Flex> isOnlyTitle={document.isOnlyTitle}
</React.Suspense> >
</MaxWidth> <References document={document} />
{isShare && </ReferencesWrapper>
!parseDomain(window.location.origin).custom && </>
!auth.user && ( )}
<Branding href="//www.getoutline.com?ref=sharelink" /> </Editor>
)} </>
</Container> )}
</Background> </Flex>
{!isShare && ( </React.Suspense>
<> </MaxWidth>
<KeyboardShortcutsButton /> {isShare &&
<ConnectionStatus /> !parseDomain(window.location.origin).custom &&
</> !auth.user && (
)} <Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</DocumentContext.Provider>
</ErrorBoundary> </ErrorBoundary>
); );
} }
@@ -0,0 +1,19 @@
import * as React from "react";
import { Editor } from "~/editor";
export type DocumentContextValue = {
/** The current editor instance for this document. */
editor: Editor | null;
/** Set the current editor instance for this document. */
setEditor: (editor: Editor) => void;
};
const DocumentContext = React.createContext<DocumentContextValue>({
editor: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEditor() {},
});
export const useDocumentContext = () => React.useContext(DocumentContext);
export default DocumentContext;
+6 -1
View File
@@ -1,6 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useRouteMatch } from "react-router-dom"; import { useRouteMatch } from "react-router-dom";
import fullPackage from "@shared/editor/packages/full"; import fullPackage from "@shared/editor/packages/full";
import Document from "~/models/Document"; import Document from "~/models/Document";
@@ -14,6 +15,7 @@ import {
matchDocumentHistory, matchDocumentHistory,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import MultiplayerEditor from "./AsyncMultiplayerEditor"; import MultiplayerEditor from "./AsyncMultiplayerEditor";
import { useDocumentContext } from "./DocumentContext";
import EditableTitle from "./EditableTitle"; import EditableTitle from "./EditableTitle";
type Props = Omit<EditorProps, "extensions"> & { type Props = Omit<EditorProps, "extensions"> & {
@@ -74,6 +76,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[focusAtStart, ref] [focusAtStart, ref]
); );
const { setEditor } = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor; const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
return ( return (
@@ -103,7 +108,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
/> />
)} )}
<EditorComponent <EditorComponent
ref={ref} ref={mergeRefs([ref, handleRefChanged])}
autoFocus={!!document.title && !props.defaultValue} autoFocus={!!document.title && !props.defaultValue}
placeholder={t("Type '/' to insert, or start writing…")} placeholder={t("Type '/' to insert, or start writing…")}
scrollTo={decodeURIComponent(window.location.hash)} scrollTo={decodeURIComponent(window.location.hash)}