* 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:
Salihu
2025-12-20 03:48:25 +01:00
committed by GitHub
parent b45a096aeb
commit 419cf2a583
16 changed files with 420 additions and 42 deletions
+6 -1
View File
@@ -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]);
+12 -4
View File
@@ -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,
});
}
+16
View File
@@ -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,
+19
View File
@@ -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,
+1
View File
@@ -50,6 +50,7 @@ export default function useDictionary() {
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont 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"),
+4
View File
@@ -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);
+36
View File
@@ -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: ["*"],
+6
View File
@@ -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 } })
+3 -7
View File
@@ -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>
+1 -3
View File
@@ -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}
/>
);
}
+166
View File
@@ -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;
}
}
`;
+25
View File
@@ -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;
+24 -13
View File
@@ -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;
+10
View File
@@ -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.
*
+90 -14
View File
@@ -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 wont work for this embed type": "Sorry, that link wont 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",