fix: Allow selection of embeds (#1562)

* feat: Support importing .docx or .html files as new documents (#1551)

* Support importing .docx as new documents

* Add html file support, build types and interface for easily adding file types to importer

* fix: Upload embedded images in docx to storage

* refactor: Bulk of logic to command

* refactor: Do all importing on server, so we're not splitting logic for import into two places

* test: Add documentImporter tests


Co-authored-by: Lance Whatley <whatl3y@gmail.com>

* fix: Accessibility audit

* fix: Quick fix, non editable title
closes #1560

* fix: Embed selection

Co-authored-by: Lance Whatley <whatl3y@gmail.com>
This commit is contained in:
Tom Moor
2020-09-20 22:27:11 -07:00
committed by GitHub
parent e67d319e2b
commit 4ffc04bc5d
53 changed files with 735 additions and 218 deletions

View File

@@ -8,7 +8,6 @@ import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import { createGlobalStyle } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import LoadingIndicator from "components/LoadingIndicator";
import importFile from "utils/importFile";
const EMPTY_OBJECT = {};
let importingLock = false;
@@ -61,12 +60,12 @@ class DropToImport extends React.Component<Props> {
}
for (const file of files) {
const doc = await importFile({
documents: this.props.documents,
const doc = await this.props.documents.import(
file,
documentId,
collectionId,
});
{ publish: true }
);
if (redirect) {
this.props.history.push(doc.url);
@@ -95,7 +94,7 @@ class DropToImport extends React.Component<Props> {
return (
<Dropzone
accept="text/markdown, text/plain"
accept={documents.importFileTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
disableClick

View File

@@ -177,6 +177,7 @@ class DropdownMenu extends React.Component<Props> {
{label || (
<NudeButton
id={`${this.id}button`}
aria-label="More options"
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}

View File

@@ -21,7 +21,7 @@ function HeaderBlock({
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} />
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}

View File

@@ -2,11 +2,12 @@
import styled from "styled-components";
const TeamLogo = styled.img`
width: auto;
height: 38px;
width: ${(props) => props.size || "auto"};
height: ${(props) => props.size || "38px"};
border-radius: 4px;
background: ${(props) => props.theme.background};
outline: 1px solid ${(props) => props.theme.divider};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
`;
export default TeamLogo;

View File

@@ -21,6 +21,7 @@ export default class Abstract extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://app.goabstract.com/embed/${shareId}`}
title={`Abstract (${shareId})`}
/>

View File

@@ -20,6 +20,7 @@ export default class Airtable extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border

View File

@@ -17,6 +17,12 @@ export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="ClickUp Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="ClickUp Embed"
/>
);
}
}

View File

@@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
return <Frame src={normalizedUrl} title="Codepen Embed" />;
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
}
}

View File

@@ -19,6 +19,7 @@ export default class Figma extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
title="Figma Embed"
border

View File

@@ -15,6 +15,13 @@ export default class Framer extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Framer Embed" border />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Framer Embed"
border
/>
);
}
}

View File

@@ -6,6 +6,7 @@ const URL_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -48,6 +49,7 @@ class Gist extends React.Component<Props> {
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.updateIframeContent}
type="text/html"
frameBorder="0"

View File

@@ -17,6 +17,7 @@ export default class GoogleDocs extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img

View File

@@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img

View File

@@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href
.replace("/edit", "/preview")
.replace("/pub", "/embed")}

View File

@@ -12,6 +12,7 @@ const IMAGE_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -25,6 +26,7 @@ export default class InVision extends React.Component<Props> {
if (IMAGE_REGEX.test(this.props.attrs.href)) {
return (
<ImageZoom
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: this.props.attrs.href,
alt: "InVision Embed",
@@ -37,6 +39,12 @@ export default class InVision extends React.Component<Props> {
/>
);
}
return <Frame src={this.props.attrs.href} title="InVision Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="InVision Embed"
/>
);
}
}

View File

@@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace("share", "embed");
return <Frame src={normalizedUrl} title="Loom Embed" />;
return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />;
}
}

View File

@@ -20,6 +20,7 @@ export default class Lucidchart extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>

View File

@@ -15,6 +15,13 @@ export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Marvel Embed" border />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Marvel Embed"
border
/>
);
}
}

View File

@@ -21,6 +21,7 @@ export default class Mindmeister extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
title="Mindmeister Embed"
border

View File

@@ -20,6 +20,7 @@ export default class RealtimeBoard extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>

View File

@@ -21,7 +21,11 @@ export default class ModeAnalytics extends React.Component<Props> {
const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
<Frame
{...this.props}
src={`${normalizedUrl}/embed`}
title="Mode Analytics Embed"
/>
);
}
}

View File

@@ -17,6 +17,8 @@ export default class Prezi extends React.Component<Props> {
render() {
const url = this.props.attrs.href.replace(/\/embed$/, "");
return <Frame src={`${url}/embed`} title="Prezi Embed" border />;
return (
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
);
}
}

View File

@@ -27,6 +27,7 @@ export default class Spotify extends React.Component<Props> {
return (
<Frame
{...this.props}
width="300px"
height="380px"
src={`https://open.spotify.com/embed${normalizedPath}`}

View File

@@ -31,6 +31,7 @@ export default class Trello extends React.Component<Props> {
return (
<Frame
{...this.props}
width="248px"
height="185px"
src={`https://trello.com/embed/board?id=${objectId}`}

View File

@@ -17,6 +17,12 @@ export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Typeform Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Typeform Embed"
/>
);
}
}

View File

@@ -20,6 +20,7 @@ export default class Vimeo extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>

View File

@@ -5,6 +5,7 @@ import Frame from "./components/Frame";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -20,6 +21,7 @@ export default class YouTube extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>

View File

@@ -12,6 +12,7 @@ type Props = {
title?: string,
icon?: React.Node,
canonicalUrl?: string,
isSelected?: boolean,
width?: string,
height?: string,
};
@@ -48,13 +49,19 @@ class Frame extends React.Component<PropsWithRef> {
icon,
title,
canonicalUrl,
...rest
isSelected,
src,
} = this.props;
const Component = border ? StyledIframe : "iframe";
const withBar = !!(icon || canonicalUrl);
return (
<Rounded width={width} height={height} withBar={withBar}>
<Rounded
width={width}
height={height}
withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
@@ -66,8 +73,8 @@ class Frame extends React.Component<PropsWithRef> {
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
{...rest}
/>
)}
{withBar && (

View File

@@ -15,7 +15,6 @@ import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
import getDataTransferFiles from "utils/getDataTransferFiles";
import importFile from "utils/importFile";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -55,11 +54,13 @@ class CollectionMenu extends React.Component<Props> {
const files = getDataTransferFiles(ev);
try {
const document = await importFile({
file: files[0],
documents: this.props.documents,
collectionId: this.props.collection.id,
});
const file = files[0];
const document = await this.props.documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
this.props.history.push(document.url);
} catch (err) {
this.props.ui.showToast(err.message);
@@ -103,7 +104,14 @@ class CollectionMenu extends React.Component<Props> {
};
render() {
const { policies, collection, position, onOpen, onClose } = this.props;
const {
policies,
documents,
collection,
position,
onOpen,
onClose,
} = this.props;
const can = policies.abilities(collection.id);
return (
@@ -114,7 +122,7 @@ class CollectionMenu extends React.Component<Props> {
ref={(ref) => (this.file = ref)}
onChange={this.onFilePicked}
onClick={(ev) => ev.stopPropagation()}
accept="text/markdown, text/plain"
accept={documents.importFileTypes.join(", ")}
/>
</VisuallyHidden>

View File

@@ -89,6 +89,10 @@ class DocumentScene extends React.Component<Props> {
if (this.props.readOnly) {
this.lastRevision = document.revision;
if (document.title !== this.title) {
this.title = document.title;
}
} else if (prevProps.document.revision !== this.lastRevision) {
if (auth.user && document.updatedBy.id !== auth.user.id) {
this.props.ui.showToast(
@@ -106,12 +110,9 @@ class DocumentScene extends React.Component<Props> {
}
}
if (!this.isDirty && document.title !== this.title) {
this.title = document.title;
}
if (document.injectTemplate) {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
}

View File

@@ -18,12 +18,23 @@ import Document from "models/Document";
import type { FetchOptions, PaginationParams, SearchResult } from "types";
import { client } from "utils/ApiClient";
type ImportOptions = {
publish?: boolean,
};
export default class DocumentsStore extends BaseStore<Document> {
@observable recentlyViewedIds: string[] = [];
@observable searchCache: Map<string, SearchResult[]> = new Map();
@observable starredIds: Map<string, boolean> = new Map();
@observable backlinks: Map<string, string[]> = new Map();
importFileTypes: string[] = [
"text/markdown",
"text/plain",
"text/html",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
];
constructor(rootStore: RootStore) {
super(rootStore, Document);
}
@@ -455,6 +466,41 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.add(res.data);
};
@action
import = async (
file: File,
parentDocumentId: string,
collectionId: string,
options: ImportOptions
) => {
const title = file.name.replace(/\.[^/.]+$/, "");
const formData = new FormData();
[
{ key: "parentDocumentId", value: parentDocumentId },
{ key: "collectionId", value: collectionId },
{ key: "title", value: title },
{ key: "publish", value: options.publish },
{ key: "file", value: file },
].map((info) => {
if (typeof info.value === "string" && info.value) {
formData.append(info.key, info.value);
}
if (typeof info.value === "boolean") {
formData.append(info.key, info.value.toString());
}
if (info.value instanceof File) {
formData.append(info.key, info.value);
}
});
const res = await client.post("/documents.import", formData);
invariant(res && res.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(res.data);
};
_add = this.add;
@action

View File

@@ -28,12 +28,13 @@ class ApiClient {
fetch = async (
path: string,
method: string,
data: ?Object,
data: ?Object | FormData | void,
options: Object = {}
) => {
let body;
let modifiedPath;
let urlToFetch;
let isJson;
if (method === "GET") {
if (data) {
@@ -42,7 +43,18 @@ class ApiClient {
modifiedPath = path;
}
} else if (method === "POST" || method === "PUT") {
body = data ? JSON.stringify(data) : undefined;
body = data || undefined;
// Only stringify data if its a normal object and
// not if it's [object FormData], in addition to
// toggling Content-Type to application/json
if (
typeof data === "object" &&
(data || "").toString() === "[object Object]"
) {
isJson = true;
body = JSON.stringify(data);
}
}
if (path.match(/^http/)) {
@@ -51,14 +63,20 @@ class ApiClient {
urlToFetch = this.baseUrl + (modifiedPath || path);
}
// Construct headers
const headers = new Headers({
let headerOptions: any = {
Accept: "application/json",
"Content-Type": "application/json",
"cache-control": "no-cache",
"x-editor-version": EDITOR_VERSION,
pragma: "no-cache",
});
};
// for multipart forms or other non JSON requests fetch
// populates the Content-Type without needing to explicitly
// set it.
if (isJson) {
headerOptions["Content-Type"] = "application/json";
}
const headers = new Headers(headerOptions);
if (stores.auth.authenticated) {
invariant(stores.auth.token, "JWT token not set properly");
headers.set("Authorization", `Bearer ${stores.auth.token}`);

View File

@@ -1,58 +0,0 @@
// @flow
import parseTitle from "shared/utils/parseTitle";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
type Options = {
file: File,
documents: DocumentsStore,
collectionId: string,
documentId?: string,
};
const importFile = async ({
documents,
file,
documentId,
collectionId,
}: Options): Promise<Document> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (ev) => {
let text = ev.target.result;
let title;
// If the first line of the imported file looks like a markdown heading
// then we can use this as the document title
if (text.trim().startsWith("# ")) {
const result = parseTitle(text);
title = result.title;
text = text.replace(`# ${title}\n`, "");
// otherwise, just use the filename without the extension as our best guess
} else {
title = file.name.replace(/\.[^/.]+$/, "");
}
let document = new Document(
{
parentDocumentId: documentId,
collectionId,
text,
title,
},
documents
);
try {
document = await document.save({ publish: true });
resolve(document);
} catch (err) {
reject(err);
}
};
reader.readAsText(file);
});
};
export default importFile;