Files
docmost/apps/client/src/features/editor/extensions/markdown-clipboard.ts
T
2026-03-29 16:11:21 +01:00

140 lines
4.8 KiB
TypeScript

// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
priority: 101,
addOptions() {
return {
transformPastedText: false,
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
handlePaste: (view, event, slice) => {
if (!event.clipboardData) {
return false;
}
if (this.editor.isActive("codeBlock")) {
return false;
}
const text = event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
const isVscodeMarkdown = language === "markdown";
const isPlainTextOnly = !html && !vscode && !!text;
if (!isVscodeMarkdown && !isPlainTextOnly) {
return false;
}
if (isPlainTextOnly) {
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
return false;
}
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
if (link) {
return false;
}
}
const { tr } = view.state;
const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(elementFromString(parsed), {
preserveWhitespace: true,
});
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
},
// Strip trailing whitespace-only paragraphs from pasted content.
// Terminals (GNOME Terminal, etc.) often include trailing
// whitespace in their HTML clipboard data, which ProseMirror
// parses as an extra paragraph. Inside a list item this creates
// an orphan empty line that breaks the list structure.
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
if (
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
}
}
if (content !== slice.content) {
return new Slice(content, openStart, Math.max(openEnd, 1));
}
return slice;
},
},
}),
];
},
});
function elementFromString(value) {
// add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`;
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}