mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
PDF embed (#10198)
* simple PDF embded * send pdf url * pdf resize * resize pdf accordingly * pdf alignment * minor fixes * use attachment node for PDF preview * remove unnecessary comments * fix pdf class * minor fixes * adjust upload pdf logo * revert SelectionToolbar * pass embed URL directly * pass embed URL directly * remove embedded pdf alignment * improve resize UX * improve resize UX * fix: X-Frame-Options with local hosting fix: Resize not persisted * Add dimensions to attachment toolbar * fix: Styling * fix: Non-interactable in read-only editor * Revert unneccessary changes * Avoid setting width/height on all attachment nodes * fix: Disable embeds should also disable PDF embeds --------- Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -26,7 +26,7 @@ export function MediaDimension() {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
// This component will be rendered only when the selection is image or video (NodeSelection types).
|
||||
// This component will be rendered for specific media nodes like image, video or pdfs (NodeSelection types).
|
||||
const node = (selection as NodeSelection).node;
|
||||
const nodeType = node.type.name,
|
||||
width = node.attrs.width as number,
|
||||
@@ -157,6 +157,11 @@ export function MediaDimension() {
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
});
|
||||
} else if (nodeType === "attachment") {
|
||||
commands["resizeAttachment"]({
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
});
|
||||
}
|
||||
}, [commands, width, height, localDimension, nodeType, error, reset]);
|
||||
|
||||
|
||||
@@ -268,12 +268,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
AttachmentValidation.imageContentTypes.join(", "),
|
||||
item.attrs
|
||||
);
|
||||
case "video":
|
||||
return triggerFilePick("video/*");
|
||||
return triggerFilePick("video/*", item.attrs);
|
||||
case "attachment":
|
||||
return triggerFilePick("*");
|
||||
return triggerFilePick(item.attrs?.accept ?? "*", item.attrs);
|
||||
case "embed":
|
||||
return triggerLinkInput(item);
|
||||
default:
|
||||
@@ -353,11 +354,14 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFilePick = (accept: string) => {
|
||||
const triggerFilePick = (accept: string, attrs?: Record<string, any>) => {
|
||||
if (inputRef.current) {
|
||||
if (accept) {
|
||||
inputRef.current.accept = accept;
|
||||
}
|
||||
if (attrs) {
|
||||
inputRef.current.dataset.attrs = attrs ? JSON.stringify(attrs) : "";
|
||||
}
|
||||
inputRef.current.click();
|
||||
}
|
||||
};
|
||||
@@ -375,6 +379,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { uploadFile, onFileUploadStart, onFileUploadStop } = props;
|
||||
const files = getEventFiles(event);
|
||||
const parent = findParentNode((node) => !!node)(view.state.selection);
|
||||
const attrs = event.currentTarget.dataset.attrs
|
||||
? JSON.parse(event.currentTarget.dataset.attrs)
|
||||
: undefined;
|
||||
|
||||
handleClearSearch();
|
||||
|
||||
@@ -389,6 +396,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onFileUploadStop,
|
||||
dictionary,
|
||||
isAttachment: inputRef.current?.accept === "*",
|
||||
attrs,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
|
||||
export default function attachmentMenuItems(
|
||||
state: EditorState,
|
||||
@@ -11,6 +12,12 @@ export default function attachmentMenuItems(
|
||||
if (readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { schema } = state;
|
||||
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
|
||||
preview: true,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
name: "replaceAttachment",
|
||||
@@ -25,6 +32,15 @@ export default function attachmentMenuItems(
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "dimensions",
|
||||
tooltip: dictionary.dimensions,
|
||||
visible: isAttachmentWithPreview(state),
|
||||
skipIcon: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "downloadAttachment",
|
||||
label: dictionary.download,
|
||||
|
||||
@@ -28,6 +28,8 @@ import Image from "@shared/editor/components/Img";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFilePdf } from "@fortawesome/free-solid-svg-icons";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
const Img = styled(Image)`
|
||||
@@ -115,6 +117,23 @@ export default function blockMenuItems(
|
||||
icon: <EmbedIcon />,
|
||||
keywords: "mov avi upload player",
|
||||
},
|
||||
{
|
||||
name: "attachment",
|
||||
title: dictionary.pdf,
|
||||
icon: (
|
||||
<FontAwesomeIcon
|
||||
icon={faFilePdf}
|
||||
size="lg"
|
||||
style={{ marginLeft: "2px" }}
|
||||
/>
|
||||
),
|
||||
keywords: "pdf upload attach",
|
||||
attrs: {
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "attachment",
|
||||
title: dictionary.file,
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function useDictionary() {
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
file: t("File attachment"),
|
||||
pdf: t("Embed PDF"),
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
|
||||
@@ -94,6 +94,10 @@ router.get(
|
||||
(fileName ? mime.lookup(fileName) : undefined) ||
|
||||
"application/octet-stream";
|
||||
|
||||
if (contentType === "application/pdf") {
|
||||
ctx.remove("X-Frame-Options");
|
||||
}
|
||||
|
||||
ctx.set("Accept-Ranges", "bytes");
|
||||
ctx.set("Access-Control-Allow-Origin", "*");
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
|
||||
@@ -4,6 +4,35 @@ import { contentSecurityPolicy } from "koa-helmet";
|
||||
import uniq from "lodash/uniq";
|
||||
import env from "@server/env";
|
||||
|
||||
const getBucketOrigin = () => {
|
||||
if (env.AWS_S3_ACCELERATE_URL) {
|
||||
return new URL(env.AWS_S3_ACCELERATE_URL).origin;
|
||||
}
|
||||
|
||||
const url = env.AWS_S3_UPLOAD_BUCKET_URL || "";
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (
|
||||
env.AWS_S3_UPLOAD_BUCKET_NAME &&
|
||||
parsedUrl.hostname.startsWith(`${env.AWS_S3_UPLOAD_BUCKET_NAME}.`)
|
||||
) {
|
||||
const hostnameWithoutBucket = parsedUrl.hostname.substring(
|
||||
env.AWS_S3_UPLOAD_BUCKET_NAME.length + 1 // +1 for the dot
|
||||
);
|
||||
return `${parsedUrl.protocol}//${hostnameWithoutBucket}`;
|
||||
}
|
||||
|
||||
return parsedUrl.origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Content Security Policy middleware for the application.
|
||||
*/
|
||||
@@ -12,6 +41,7 @@ export default function createCSPMiddleware() {
|
||||
const defaultSrc: string[] = ["'self'"];
|
||||
const scriptSrc: string[] = [];
|
||||
const styleSrc: string[] = ["'self'", "'unsafe-inline'"];
|
||||
const objectSrc: string[] = [env.URL, "'self'"];
|
||||
|
||||
if (env.isCloudHosted) {
|
||||
scriptSrc.push("www.googletagmanager.com");
|
||||
@@ -38,6 +68,11 @@ export default function createCSPMiddleware() {
|
||||
defaultSrc.push(env.CDN_URL);
|
||||
}
|
||||
|
||||
const bucketOrigin = getBucketOrigin();
|
||||
if (bucketOrigin) {
|
||||
objectSrc.push(bucketOrigin);
|
||||
}
|
||||
|
||||
return function cspMiddleware(ctx: Context, next: Next) {
|
||||
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
@@ -55,6 +90,7 @@ export default function createCSPMiddleware() {
|
||||
mediaSrc: ["*", "data:", "blob:"],
|
||||
imgSrc: ["*", "data:", "blob:"],
|
||||
frameSrc: ["*", "data:"],
|
||||
objectSrc,
|
||||
// Do not use connect-src: because self + websockets does not work in
|
||||
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
|
||||
connectSrc: ["*"],
|
||||
|
||||
@@ -65,6 +65,7 @@ const insertFiles = async function (
|
||||
FileHelper.isVideo(file.type) &&
|
||||
!options.isAttachment &&
|
||||
!!schema.nodes.video;
|
||||
const isPdf = FileHelper.isPdf(file.type) && !options.isAttachment;
|
||||
const getDimensions = isImage
|
||||
? FileHelper.getImageDimensions
|
||||
: isVideo
|
||||
@@ -77,6 +78,7 @@ const insertFiles = async function (
|
||||
source: await FileHelper.getImageSourceAttr(file),
|
||||
isImage,
|
||||
isVideo,
|
||||
isPdf,
|
||||
file,
|
||||
};
|
||||
})
|
||||
@@ -99,6 +101,7 @@ const insertFiles = async function (
|
||||
// to allow all placeholders to be entered at once with the uploads
|
||||
// happening in the background in parallel.
|
||||
uploadFile?.(upload.file)
|
||||
// then this should be able to get the full URL as well
|
||||
.then(async (src) => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
@@ -180,6 +183,9 @@ const insertFiles = async function (
|
||||
href: src,
|
||||
title: upload.file.name ?? dictionary.untitled,
|
||||
size: upload.file.size,
|
||||
contentType: upload.file.type,
|
||||
preview: upload.isPdf,
|
||||
...options.attrs,
|
||||
})
|
||||
)
|
||||
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { OpenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import { EmbedProps as Props } from "../embeds";
|
||||
import Widget from "./Widget";
|
||||
|
||||
export default function DisabledEmbed(
|
||||
props: Omit<Props, "matches" | "attrs"> &
|
||||
ThemeProps<DefaultTheme> & {
|
||||
href: string;
|
||||
}
|
||||
props: Omit<Props, "matches" | "attrs"> & {
|
||||
href: string;
|
||||
}
|
||||
) {
|
||||
return (
|
||||
<Widget
|
||||
@@ -17,7 +14,6 @@ export default function DisabledEmbed(
|
||||
icon={props.embed.icon}
|
||||
context={props.href.replace(/^https?:\/\//, "")}
|
||||
isSelected={props.isSelected}
|
||||
theme={props.theme}
|
||||
>
|
||||
<OpenIcon size={20} />
|
||||
</Widget>
|
||||
|
||||
@@ -67,7 +67,7 @@ const Embed = (props: Props) => {
|
||||
|
||||
const InnerEmbed = React.forwardRef<HTMLIFrameElement, Props>(
|
||||
function InnerEmbed_(
|
||||
{ isEditable, isSelected, theme, node, embeds, embedsDisabled, style },
|
||||
{ isEditable, isSelected, node, embeds, embedsDisabled, style },
|
||||
ref
|
||||
) {
|
||||
const cache = React.useMemo(
|
||||
@@ -88,7 +88,6 @@ const InnerEmbed = React.forwardRef<HTMLIFrameElement, Props>(
|
||||
embed={embed}
|
||||
isEditable={isEditable}
|
||||
isSelected={isSelected}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +119,6 @@ const InnerEmbed = React.forwardRef<HTMLIFrameElement, Props>(
|
||||
isEditable={isEditable}
|
||||
isSelected={isSelected}
|
||||
embed={embed}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import styled from "styled-components";
|
||||
import useDragResize from "./hooks/useDragResize";
|
||||
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
|
||||
import { ComponentProps } from "../types";
|
||||
import { isFirefox } from "../../utils/browser";
|
||||
import Flex from "../../components/Flex";
|
||||
import { s } from "../../styles";
|
||||
import { Preview, Subtitle, Title } from "./Widget";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
type Props = ComponentProps & {
|
||||
/** Icon to display on the left side of the widget */
|
||||
icon: React.ReactNode;
|
||||
/** Title of the widget */
|
||||
title: React.ReactNode;
|
||||
/** Context, displayed to right of title */
|
||||
context?: React.ReactNode;
|
||||
/** Callback triggered when the pdf is resized */
|
||||
onChangeSize?: (props: { width: number; height?: number }) => void;
|
||||
};
|
||||
|
||||
export default function PdfViewer(props: Props) {
|
||||
const { node, isEditable, onChangeSize, isSelected } = props;
|
||||
const { href, name } = node.attrs;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const embedRef = useRef<HTMLEmbedElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||
{
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
naturalWidth: 300,
|
||||
naturalHeight: 424,
|
||||
gridSnap: 5,
|
||||
onChangeSize,
|
||||
ref,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.attrs.width && node.attrs.width !== width) {
|
||||
setSize({
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [node.attrs.width]);
|
||||
|
||||
// force embed to reload, so the content fits the new size.
|
||||
useEffect(() => {
|
||||
// firefox handles resizing on its own
|
||||
// and forced reload causes the parent to collapse while resizing
|
||||
if (isFirefox() || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (embedRef.current) {
|
||||
embedRef.current.src = "";
|
||||
requestAnimationFrame(() => {
|
||||
if (embedRef.current) {
|
||||
embedRef.current.src = href;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [dragging, href]);
|
||||
|
||||
return (
|
||||
<PDFWrapper
|
||||
contentEditable={false}
|
||||
ref={ref}
|
||||
className={
|
||||
isSelected || dragging
|
||||
? "pdf-wrapper ProseMirror-selectednode"
|
||||
: "pdf-wrapper"
|
||||
}
|
||||
style={{ width: width ?? "auto" }}
|
||||
$dragging={dragging}
|
||||
>
|
||||
<Flex gap={6} align="center">
|
||||
{props.icon}
|
||||
<Preview>
|
||||
<Title>{props.title}</Title>
|
||||
<Subtitle>{props.context}</Subtitle>
|
||||
</Preview>
|
||||
</Flex>
|
||||
<embed
|
||||
title={name}
|
||||
src={href}
|
||||
ref={embedRef}
|
||||
type="application/pdf"
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
pointerEvents:
|
||||
!isEditable || (isSelected && !dragging) ? "initial" : "none",
|
||||
marginTop: 6,
|
||||
}}
|
||||
/>
|
||||
{isEditable && !!props.onChangeSize && (
|
||||
<>
|
||||
<ResizeLeft
|
||||
onPointerDown={handlePointerDown("left")}
|
||||
$dragging={isSelected || dragging}
|
||||
/>
|
||||
<ResizeRight
|
||||
onPointerDown={handlePointerDown("right")}
|
||||
$dragging={isSelected || dragging}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PDFWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const PDFWrapper = styled.div<{ $dragging: boolean }>`
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
transition-property: width, height;
|
||||
transition-duration: 120ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
overflow: hidden;
|
||||
will-change: ${(props) => (props.$dragging ? "width, height" : "auto")};
|
||||
box-shadow: 0 0 0 1px ${s("divider")};
|
||||
border-radius: ${EditorStyleHelper.blockRadius};
|
||||
padding: ${EditorStyleHelper.blockRadius};
|
||||
|
||||
embed {
|
||||
transition-property: width, height;
|
||||
transition-duration: 120ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
will-change: ${(props) => (props.$dragging ? "width, height" : "auto")};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
${ResizeLeft}, ${ResizeRight} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -662,6 +662,31 @@ iframe.embed {
|
||||
}
|
||||
}
|
||||
|
||||
.pdf {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
max-width: 100%;
|
||||
clear: both;
|
||||
z-index: 1;
|
||||
transition-property: width, height;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
embed {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
contain: strict,
|
||||
content-visibility: auto,
|
||||
backface-visibility: hidden,
|
||||
transition-property: width, height;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.image-replacement-uploading {
|
||||
img {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from "react";
|
||||
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "../../styles";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import Flex from "../../components/Flex";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
type Props = {
|
||||
/** Icon to display on the left side of the widget */
|
||||
@@ -24,7 +26,7 @@ type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
export default function Widget(props: Props & ThemeProps<DefaultTheme>) {
|
||||
export default function Widget(props: Props) {
|
||||
const className = props.isSelected
|
||||
? "ProseMirror-selectednode widget"
|
||||
: "widget";
|
||||
@@ -59,25 +61,34 @@ const Children = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.strong`
|
||||
export const Title = styled.strong`
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
const Preview = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
export const Preview = styled(Flex).attrs({
|
||||
gap: 8,
|
||||
align: "center",
|
||||
})`
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const Subtitle = styled.span`
|
||||
export const Subtitle = styled.span`
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
color: ${s("textTertiary")} !important;
|
||||
line-height: 0;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.a`
|
||||
@@ -88,10 +99,10 @@ const Wrapper = styled.a`
|
||||
color: ${s("text")} !important;
|
||||
box-shadow: 0 0 0 1px ${s("divider")};
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: ${EditorStyleHelper.blockRadius};
|
||||
padding: ${EditorStyleHelper.blockRadius};
|
||||
max-width: 840px;
|
||||
cursor: default;
|
||||
cursor: var(--pointer);
|
||||
|
||||
user-select: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -36,6 +36,16 @@ export default class FileHelper {
|
||||
return /^audio\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is a pdf.
|
||||
*
|
||||
* @param contentType The content type of the file
|
||||
* @returns True if the file is a pdf
|
||||
*/
|
||||
static isPdf(contentType: string) {
|
||||
return /^application\/pdf$/i.test(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a URL and return it as a File object.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Token } from "markdown-it";
|
||||
import { DownloadIcon } from "outline-icons";
|
||||
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { Command, NodeSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Primitive } from "utility-types";
|
||||
import { bytesToHumanReadable, getEventFiles } from "../../utils/files";
|
||||
@@ -15,6 +14,7 @@ import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import attachmentsRule from "../rules/links";
|
||||
import { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
import PdfViewer from "../components/PDF";
|
||||
|
||||
export default class Attachment extends Node {
|
||||
get name() {
|
||||
@@ -38,6 +38,19 @@ export default class Attachment extends Node {
|
||||
size: {
|
||||
default: 0,
|
||||
},
|
||||
preview: {
|
||||
default: false,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
contentType: {
|
||||
default: null,
|
||||
validate: "string|null",
|
||||
},
|
||||
},
|
||||
group: "block",
|
||||
defining: true,
|
||||
@@ -78,9 +91,48 @@ export default class Attachment extends Node {
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleChangeSize =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
({ width, height }: { width: number; height?: number }) => {
|
||||
if (!node.attrs.preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view, commands } = this.editor;
|
||||
const { doc, tr } = view.state;
|
||||
|
||||
const pos = getPos();
|
||||
const $pos = doc.resolve(pos);
|
||||
|
||||
view.dispatch(tr.setSelection(new NodeSelection($pos)));
|
||||
commands["resizeAttachment"]({
|
||||
width,
|
||||
height: height || node.attrs.height,
|
||||
});
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => {
|
||||
const { isSelected, isEditable, theme, node } = props;
|
||||
return (
|
||||
const { embedsDisabled } = this.editor.props;
|
||||
const { isSelected, isEditable, node } = props;
|
||||
const context = node.attrs.href ? (
|
||||
bytesToHumanReadable(node.attrs.size || "0")
|
||||
) : (
|
||||
<>
|
||||
<Trans>Uploading</Trans>…
|
||||
</>
|
||||
);
|
||||
|
||||
return node.attrs.preview &&
|
||||
!embedsDisabled &&
|
||||
node.attrs.contentType === "application/pdf" ? (
|
||||
<PdfViewer
|
||||
icon={<FileExtension title={node.attrs.title} />}
|
||||
title={node.attrs.title}
|
||||
context={context}
|
||||
onChangeSize={this.handleChangeSize(props)}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Widget
|
||||
icon={<FileExtension title={node.attrs.title} />}
|
||||
href={node.attrs.href}
|
||||
@@ -95,17 +147,8 @@ export default class Attachment extends Node {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
context={
|
||||
node.attrs.href ? (
|
||||
bytesToHumanReadable(node.attrs.size || "0")
|
||||
) : (
|
||||
<>
|
||||
<Trans>Uploading</Trans>…
|
||||
</>
|
||||
)
|
||||
}
|
||||
context={context}
|
||||
isSelected={isSelected}
|
||||
theme={theme}
|
||||
>
|
||||
{node.attrs.href && !isEditable && <DownloadIcon size={20} />}
|
||||
</Widget>
|
||||
@@ -133,13 +176,21 @@ export default class Attachment extends Node {
|
||||
throw new Error("uploadFile prop is required to replace attachments");
|
||||
}
|
||||
|
||||
if (node.type.name !== "attachment") {
|
||||
const accept =
|
||||
node.attrs.contentType === "application/pdf"
|
||||
? ".pdf"
|
||||
: node.type.name === "attachment"
|
||||
? "*"
|
||||
: null;
|
||||
|
||||
if (accept === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// create an input element and click to trigger picker
|
||||
const inputElement = document.createElement("input");
|
||||
inputElement.type = "file";
|
||||
inputElement.accept = accept;
|
||||
inputElement.onchange = (event) => {
|
||||
const files = getEventFiles(event);
|
||||
void insertFiles(view, event, state.selection.from, files, {
|
||||
@@ -170,6 +221,31 @@ export default class Attachment extends Node {
|
||||
document.body.removeChild(link);
|
||||
return true;
|
||||
},
|
||||
resizeAttachment:
|
||||
({ width, height }: { width: number; height?: number }): Command =>
|
||||
(state, dispatch) => {
|
||||
if (
|
||||
!(state.selection instanceof NodeSelection) ||
|
||||
!state.selection.node.attrs.preview
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
const { attrs } = state.selection.node;
|
||||
|
||||
const transaction = tr
|
||||
.setNodeMarkup(state.selection.from, undefined, {
|
||||
...attrs,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
.setMeta("addToHistory", true);
|
||||
const $pos = transaction.doc.resolve(state.selection.from);
|
||||
dispatch?.(transaction.setSelection(new NodeSelection($pos)));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -555,6 +555,7 @@
|
||||
"Italic": "Italic",
|
||||
"Sorry, that link won’t work for this embed type": "Sorry, that link won’t work for this embed type",
|
||||
"File attachment": "File attachment",
|
||||
"Embed PDF": "Embed PDF",
|
||||
"Enter a link": "Enter a link",
|
||||
"Big heading": "Big heading",
|
||||
"Medium heading": "Medium heading",
|
||||
|
||||
Reference in New Issue
Block a user