Replace three divergent data paths (Marked HTML loading, regex post-processing saving, separate paste parsing) with one symmetric path through @tiptap/markdown. Key changes: - Create features/editor/ module with ContentEditor (unified edit+readonly) and TitleEditor, replacing components/common/ editor files - Load content via contentType: 'markdown' instead of markdownToHtml() hack - Save content via editor.getMarkdown() directly, no post-processing - Merge RichTextEditor + ReadonlyEditor into single ContentEditor with editable prop - Extract extensions into separate modules (mention, file-upload, markdown-paste, submit-shortcut, code-block-view) - Extract shared preprocessMentionShortcodes to components/markdown/mentions.ts - Add copyMarkdown utility for clipboard operations - Upgrade all @tiptap packages from 3.20.5 to 3.22.1 (lexer isolation fix, HTML entity roundtrip fix, table alignment support) - Delete markdownToHtml.ts, readonly-editor.tsx, and 10 old component files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
import { Extension } from "@tiptap/core";
|
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function removeImageBySrc(editor: any, src: string) {
|
|
if (!editor) return;
|
|
const { tr } = editor.state;
|
|
let deleted = false;
|
|
editor.state.doc.descendants((node: any, pos: number) => {
|
|
if (deleted) return false;
|
|
if (node.type.name === "image" && node.attrs.src === src) {
|
|
tr.delete(pos, pos + node.nodeSize);
|
|
deleted = true;
|
|
return false;
|
|
}
|
|
});
|
|
if (deleted) editor.view.dispatch(tr);
|
|
}
|
|
|
|
/**
|
|
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
|
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
|
*/
|
|
export async function uploadAndInsertFile(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
editor: any,
|
|
file: File,
|
|
handler: (file: File) => Promise<UploadResult | null>,
|
|
pos?: number,
|
|
) {
|
|
const isImage = file.type.startsWith("image/");
|
|
|
|
if (isImage) {
|
|
const blobUrl = URL.createObjectURL(file);
|
|
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
|
} else {
|
|
editor.chain().focus().setImage(imgAttrs).run();
|
|
}
|
|
|
|
try {
|
|
const result = await handler(file);
|
|
if (result) {
|
|
const { tr } = editor.state;
|
|
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
|
|
if (node.type.name === "image" && node.attrs.src === blobUrl) {
|
|
tr.setNodeMarkup(nodePos, undefined, {
|
|
...node.attrs,
|
|
src: result.link,
|
|
alt: result.filename,
|
|
uploading: false,
|
|
});
|
|
}
|
|
});
|
|
editor.view.dispatch(tr);
|
|
} else {
|
|
removeImageBySrc(editor, blobUrl);
|
|
}
|
|
} catch {
|
|
removeImageBySrc(editor, blobUrl);
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
} else {
|
|
// Non-image: upload first, then insert link
|
|
const result = await handler(file);
|
|
if (!result) return;
|
|
const linkText = `[${result.filename}](${result.link})`;
|
|
if (pos !== undefined) {
|
|
editor.chain().focus().insertContentAt(pos, linkText).run();
|
|
} else {
|
|
editor.chain().focus().insertContent(linkText).run();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createFileUploadExtension(
|
|
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
|
) {
|
|
return Extension.create({
|
|
name: "fileUpload",
|
|
addProseMirrorPlugins() {
|
|
const { editor } = this;
|
|
|
|
const handleFiles = async (files: FileList) => {
|
|
const handler = onUploadFileRef.current;
|
|
if (!handler) return false;
|
|
for (const file of Array.from(files)) {
|
|
await uploadAndInsertFile(editor, file, handler);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("fileUpload"),
|
|
props: {
|
|
handlePaste(_view, event) {
|
|
const files = event.clipboardData?.files;
|
|
if (!files?.length) return false;
|
|
if (!onUploadFileRef.current) return false;
|
|
handleFiles(files);
|
|
return true;
|
|
},
|
|
handleDrop(_view, event) {
|
|
const files = (event as DragEvent).dataTransfer?.files;
|
|
if (!files?.length) return false;
|
|
if (!onUploadFileRef.current) return false;
|
|
handleFiles(files);
|
|
return true;
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|