diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx
index 6f2c9b9c..a1699f93 100644
--- a/apps/client/src/features/editor/components/image/image-menu.tsx
+++ b/apps/client/src/features/editor/components/image/image-menu.tsx
@@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return false;
}
- return editor.isActive("image");
+ return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css
new file mode 100644
index 00000000..8ee9bc06
--- /dev/null
+++ b/apps/client/src/features/editor/components/image/image-view.module.css
@@ -0,0 +1,10 @@
+.imagePlaceholder {
+ border-radius: 8px;
+ @mixin light {
+ background-color: var(--mantine-color-gray-0);
+ }
+
+ @mixin dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+}
diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx
index dbdb8396..8fdd434d 100644
--- a/apps/client/src/features/editor/components/image/image-view.tsx
+++ b/apps/client/src/features/editor/components/image/image-view.tsx
@@ -3,11 +3,11 @@ import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
+import classes from "./image-view.module.css";
export default function ImageView(props: NodeViewProps) {
const { node, selected } = props;
- const { src, width, align, title } = node.attrs;
-
+ const { src, width, align, title, aspectRatio } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -17,14 +17,21 @@ export default function ImageView(props: NodeViewProps) {
return (
-
+
+ {src && (
+
+ )}
+
);
}
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 362c1686..049e1503 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -174,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
+
uploadImageAction(file, editor.view, pos, pageId);
}
}
+
+ // Reset the input value to allow uploading the same file again if needed
+ input.value = "";
};
input.click();
},
diff --git a/package.json b/package.json
index 86333599..4ddc8a85 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
+ "image-dimensions": "^2.5.0",
"ioredis": "^5.4.1",
"jszip": "^3.10.1",
"linkifyjs": "^4.3.2",
diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts
index 9a759903..e2494651 100644
--- a/packages/editor-ext/src/lib/image/image-upload.ts
+++ b/packages/editor-ext/src/lib/image/image-upload.ts
@@ -1,127 +1,105 @@
-import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
-import { Decoration, DecorationSet } from "@tiptap/pm/view";
-import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
+import { imageDimensionsFromData } from "image-dimensions";
+import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
+import { generateNodeId } from "../utils";
+import { Node } from "@tiptap/pm/model";
-const uploadKey = new PluginKey("image-upload");
+const findImageNodeByPlaceholderId = (
+ doc: Node,
+ placeholderId: string,
+): { node: Node; pos: number } | null => {
+ let result: { node: Node; pos: number } | null = null;
-export const ImageUploadPlugin = ({
- placeholderClass,
-}: {
- placeholderClass: string;
-}) =>
- new Plugin({
- key: uploadKey,
- state: {
- init() {
- return DecorationSet.empty;
- },
- apply(tr, set) {
- set = set.map(tr.mapping, tr.doc);
- // See if the transaction adds or removes any placeholders
- //@-ts-expect-error - not yet sure what the type I need here
- const action = tr.getMeta(this);
- if (action?.add) {
- const { id, pos, src } = action.add;
-
- const placeholder = document.createElement("div");
- placeholder.setAttribute("class", "img-placeholder");
- const image = document.createElement("img");
- image.setAttribute("class", placeholderClass);
- image.src = src;
- placeholder.appendChild(image);
- const deco = Decoration.widget(pos + 1, placeholder, {
- id,
- });
- set = set.add(tr.doc, [deco]);
- } else if (action?.remove) {
- set = set.remove(
- set.find(
- undefined,
- undefined,
- (spec) => spec.id == action.remove.id,
- ),
- );
- }
- return set;
- },
- },
- props: {
- decorations(state) {
- return this.getState(state);
- },
- },
+ doc.descendants((node, pos) => {
+ if (result) return false;
+ if (
+ node.type.name === "image" &&
+ node.attrs.placeholderId === placeholderId
+ ) {
+ result = { node, pos };
+ return false;
+ }
+ return true;
});
-function findPlaceholder(state: EditorState, id: {}) {
- const decos = uploadKey.getState(state) as DecorationSet;
- const found = decos.find(undefined, undefined, (spec) => spec.id == id);
- return found.length ? found[0]?.from : null;
-}
-
-export const handleImageUpload =
+ return result;
+};
+const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => {
// check if the file is an image
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
- // A fresh object to act as the ID for this upload
- const id = {};
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => {
- const tr = view.state.tr;
- // Replace the selection with a placeholder
- if (!tr.selection.empty) tr.deleteSelection();
+ const imageDimensions = imageDimensionsFromData(await file.bytes());
+ const placeholderId = generateNodeId();
+ const aspectRatio = imageDimensions
+ ? imageDimensions.width / imageDimensions.height
+ : undefined;
+ const initialPlaceholderNode = view.state.schema.nodes.image?.create({
+ placeholderId,
+ aspectRatio,
+ });
- tr.setMeta(uploadKey, {
- add: {
- id,
- pos,
- src: reader.result,
- },
- });
+ let placeholderShown = false;
+ let tr = view.state.tr;
- insertTrailingNode(tr, pos, view);
+ if (!initialPlaceholderNode) return;
+
+ const { parent } = tr.doc.resolve(pos);
+ const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+
+ if (isEmptyTextBlock) {
+ // Replace e.g. empty paragraph with the image
+ tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
+ } else {
+ tr.insert(pos, initialPlaceholderNode);
+ }
+
+ // Only show the placeholder if the upload takes more than 250ms
+ const displayPlaceholderTimeout = setTimeout(() => {
view.dispatch(tr);
- };
+ placeholderShown = true;
+ tr = view.state.tr;
+ }, 250);
- await onUpload(file, pageId).then(
- (attachment: IAttachment) => {
- const { schema } = view.state;
+ try {
+ const attachment: IAttachment = await onUpload(file, pageId);
+ const { pos: currentPos = null } =
+ findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
- const pos = findPlaceholder(view.state, id);
+ // If the placeholder is not found or attachment is missing, abort the process
+ if (currentPos === null || !attachment) return;
- // If the content around the placeholder has been deleted, drop
- // the image
- if (pos == null) return;
+ // Update the placeholder node with the actual image data
+ tr.setNodeMarkup(currentPos, undefined, {
+ src: `/api/files/${attachment.id}/${attachment.fileName}`,
+ attachmentId: attachment.id,
+ title: attachment.fileName,
+ size: attachment.fileSize,
+ aspectRatio,
+ });
+ } catch (error) {
+ const { pos: currentPos = null } =
+ findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
- // Otherwise, insert it at the placeholder's position, and remove
- // the placeholder
+ if (currentPos === null) return;
- if (!attachment) return;
+ // Delete the image placeholder on error
+ tr.delete(currentPos, currentPos + 2);
+ } finally {
+ clearTimeout(displayPlaceholderTimeout);
- const node = schema.nodes.image?.create({
- src: `/api/files/${attachment.id}/${attachment.fileName}`,
- attachmentId: attachment.id,
- title: attachment.fileName,
- size: attachment.fileSize,
- });
- if (!node) return;
-
- const transaction = view.state.tr
- .replaceWith(pos, pos, node)
- .setMeta(uploadKey, { remove: { id } });
- view.dispatch(transaction);
- },
- () => {
- // Deletes the image placeholder on error
- const transaction = view.state.tr
- .delete(pos, pos)
- .setMeta(uploadKey, { remove: { id } });
- view.dispatch(transaction);
- },
- );
+ // If the placeholder was shown, delay showing the image to avoid flicker
+ if (placeholderShown) {
+ setTimeout(() => {
+ view.dispatch(tr);
+ }, 100);
+ } else {
+ view.dispatch(tr);
+ }
+ }
};
+
+export { handleImageUpload };
diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts
index cc8ba220..8ee2230d 100644
--- a/packages/editor-ext/src/lib/image/image.ts
+++ b/packages/editor-ext/src/lib/image/image.ts
@@ -1,7 +1,6 @@
import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
-import { ImageUploadPlugin } from "./image-upload";
import { mergeAttributes, Range } from "@tiptap/core";
export interface ImageOptions extends DefaultImageOptions {
@@ -15,6 +14,8 @@ export interface ImageAttributes {
attachmentId?: string;
size?: number;
width?: number;
+ aspectRatio?: number;
+ placeholderId?: string;
}
declare module "@tiptap/core" {
@@ -22,7 +23,7 @@ declare module "@tiptap/core" {
imageBlock: {
setImage: (attributes: ImageAttributes) => ReturnType;
setImageAt: (
- attributes: ImageAttributes & { pos: number | Range }
+ attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType;
@@ -90,6 +91,14 @@ export const TiptapImage = Image.extend({
"data-size": attributes.size,
}),
},
+ placeholderId: {
+ default: null,
+ rendered: false,
+ },
+ aspectRatio: {
+ default: null,
+ rendered: false,
+ },
};
},
@@ -140,12 +149,4 @@ export const TiptapImage = Image.extend({
return ReactNodeViewRenderer(this.options.view);
},
-
- addProseMirrorPlugins() {
- return [
- ImageUploadPlugin({
- placeholderClass: "image-upload",
- }),
- ];
- },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e42353c3..7a6d7756 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -150,6 +150,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
+ image-dimensions:
+ specifier: ^2.5.0
+ version: 2.5.0
ioredis:
specifier: ^5.4.1
version: 5.4.1
@@ -6839,6 +6842,11 @@ packages:
image-blob-reduce@3.0.1:
resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==}
+ image-dimensions@2.5.0:
+ resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
image-size@0.5.5:
resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==}
engines: {node: '>=0.10.0'}
@@ -17647,6 +17655,8 @@ snapshots:
dependencies:
pica: 7.1.1
+ image-dimensions@2.5.0: {}
+
image-size@0.5.5:
optional: true