mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
657fdf8cb7
* Tiptap3 migration - WIP * fix collaboration * remove unused code * fix flicker * disable duplicate extensions * update tiptap version * Switch to useEditorState - Set shouldRerenderOnTransaction to false * fix editable state * add tippyoptions for reference * merge main * tiptap 3.6.1 * fix bubble menu * fix converter * fix menus * fix collaboration caret css * fix: Set `isInitialized` to force immediate react node view rendering * feat: Migrate tippy.js menus to Floating UI * feat: Update collaboration connection for HocusPocus v3 * fix: Connect/disconnect websocketProvider * cleanup * cleanup * feat: Improved placeholder and upload handling for images * feat: Improved placeholder and upload handling for videos * refactor: Image node and view clean-up * feat: Improved placeholder and upload handling for attachments * fix: Video view styles * fix: Transaction handling on asset upload * fix: Use imageDimensionsFromStream * feat: Multiple file upload, improved placeholders, local previews * fix: Drag & drop, paste upload * fix: Allow media as attachment * * add skeleton pulse animation * add translation strings * fix attachment view responsiveness * fix collab connection status display * Tiptap v3.17.0 * fix suggestion menu exit bug * fix search shortcut * fix history editor css * tiptap 3.17.1 --------- Co-authored-by: Arek Nawo <areknawo@areknawo.com>
146 lines
4.3 KiB
TypeScript
146 lines
4.3 KiB
TypeScript
import { imageDimensionsFromStream } from "image-dimensions";
|
|
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
|
import { IAttachment } from "../types";
|
|
import { generateNodeId } from "../utils";
|
|
import { Node } from "@tiptap/pm/model";
|
|
import { Command } from "@tiptap/core";
|
|
|
|
const findImageNodeByPlaceholderId = (
|
|
doc: Node,
|
|
placeholderId: string,
|
|
): { node: Node; pos: number } | null => {
|
|
let result: { node: Node; pos: number } | null = null;
|
|
|
|
doc.descendants((node, pos) => {
|
|
if (result) return false;
|
|
if (
|
|
node.type.name === "image" &&
|
|
node.attrs.placeholder?.id === placeholderId
|
|
) {
|
|
result = { node, pos };
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return result;
|
|
};
|
|
const handleImageUpload =
|
|
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
|
async (file, editor, pos, pageId) => {
|
|
// check if the file is an image
|
|
const validated = validateFn?.(file);
|
|
// @ts-ignore
|
|
if (!validated) return;
|
|
|
|
const objectUrl = URL.createObjectURL(file);
|
|
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
|
const placeholderId = generateNodeId();
|
|
const aspectRatio = imageDimensions
|
|
? imageDimensions.width / imageDimensions.height
|
|
: undefined;
|
|
|
|
let placeholderInserted = false;
|
|
|
|
editor.storage.shared.imagePreviews =
|
|
editor.storage.shared.imagePreviews || {};
|
|
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
|
|
|
|
const insertPlaceholder = (): Command => {
|
|
return ({ tr, state }) => {
|
|
const initialPlaceholderNode = state.schema.nodes.image?.create({
|
|
placeholder: {
|
|
id: placeholderId,
|
|
name: file.name,
|
|
},
|
|
aspectRatio,
|
|
});
|
|
|
|
if (!initialPlaceholderNode) return false;
|
|
|
|
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);
|
|
}
|
|
|
|
return true;
|
|
};
|
|
};
|
|
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
|
|
return ({ tr }) => {
|
|
const { pos: currentPos = null } =
|
|
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
|
|
|
// If the placeholder is not found or attachment is missing, abort the process
|
|
if (currentPos === null || !attachment) return false;
|
|
|
|
// Update the placeholder node with the actual image data
|
|
tr.setNodeMarkup(currentPos, undefined, {
|
|
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
|
attachmentId: attachment.id,
|
|
size: attachment.fileSize,
|
|
aspectRatio,
|
|
});
|
|
|
|
return true;
|
|
};
|
|
};
|
|
const removePlaceholder = (): Command => {
|
|
return ({ tr }) => {
|
|
const { pos: currentPos = null } =
|
|
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
|
|
|
if (currentPos === null) return false;
|
|
|
|
// Remove the placeholder node
|
|
tr.delete(currentPos, currentPos + 2);
|
|
|
|
return true;
|
|
};
|
|
};
|
|
// Only show the placeholder if the upload takes more than 250ms
|
|
const insertPlaceholderTimeout = setTimeout(() => {
|
|
editor.commands.command(insertPlaceholder());
|
|
placeholderInserted = true;
|
|
}, 250);
|
|
const disposePreviewFile = () => {
|
|
URL.revokeObjectURL(objectUrl);
|
|
|
|
if (editor.storage.shared.imagePreviews) {
|
|
delete editor.storage.shared.imagePreviews[placeholderId];
|
|
}
|
|
};
|
|
|
|
try {
|
|
const attachment: IAttachment = await onUpload(file, pageId);
|
|
|
|
clearTimeout(insertPlaceholderTimeout);
|
|
|
|
if (placeholderInserted) {
|
|
setTimeout(() => {
|
|
editor.commands.command(replacePlaceholderWithImage(attachment));
|
|
disposePreviewFile();
|
|
}, 100);
|
|
} else {
|
|
editor
|
|
.chain()
|
|
.command(insertPlaceholder())
|
|
.command(replacePlaceholderWithImage(attachment))
|
|
.run();
|
|
disposePreviewFile();
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(insertPlaceholderTimeout);
|
|
|
|
editor.commands.command(removePlaceholder());
|
|
disposePreviewFile();
|
|
}
|
|
};
|
|
|
|
export { handleImageUpload };
|