fix: clipboard serialization

This commit is contained in:
Palanikannan M
2025-01-30 17:16:20 +05:30
parent 133091b580
commit e934300662
4 changed files with 181 additions and 86 deletions

View File

@@ -1,4 +1,4 @@
import { Extensions } from "@tiptap/core";
import { AnyExtension, Extensions } from "@tiptap/core";
import BulletList from "@tiptap/extension-bullet-list";
import CharacterCount from "@tiptap/extension-character-count";
import ListItem from "@tiptap/extension-list-item";
@@ -42,6 +42,7 @@ import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import { DropCursorExtension } from "./drop-cursor";
import { MarkdownClipboard } from "@/plugins/clipboard";
import { createCopyToClipboardExtension } from "./clipboard-new";
type TArguments = {
disabledExtensions: TExtensions[];
@@ -218,15 +219,16 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
transformCopiedText: false,
transformPastedText: true,
breaks: true,
}),
MarkdownClipboard,
// MarkdownClipboard,
createCopyToClipboardExtension(),
...(CoreEditorAdditionalExtensions({
disabledExtensions,
}) as AnyExtension[]),
];
};

View File

@@ -116,9 +116,6 @@ export const Table = Node.create({
({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
if (dispatch) {
const offset = tr.selection.anchor + 1;
if (dispatch) {
const { selection } = tr;
const position = selection.$from.before(selection.$from.depth);
@@ -139,9 +136,9 @@ export const Table = Node.create({
const cellPos = position + 1;
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)).scrollIntoView();
}
}
return true;
return true;
}
},
addColumnBefore:
() =>
@@ -237,7 +234,7 @@ export const Table = Node.create({
return {
Tab: () => {
if (this.editor.isActive("table")) {
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem") || this.editor.isActive("list")) {
return false;
}
if (this.editor.commands.goToNextCell()) {
@@ -252,7 +249,17 @@ export const Table = Node.create({
}
return false;
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
"Shift-Tab": () => {
if (this.editor.isActive("table")) {
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem") || this.editor.isActive("list")) {
return false;
}
}
if (this.editor.commands.goToPreviousCell()) {
return true;
}
return false;
},
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,

View File

@@ -170,9 +170,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
}
}
}
if (range)
editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
};
export const insertImage = ({

View File

@@ -1,4 +1,5 @@
import { Extension } from "@tiptap/core";
import { Fragment, DOMSerializer, Node, Schema } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
@@ -15,9 +16,39 @@ export const MarkdownClipboard = Extension.create({
key: new PluginKey("markdownClipboardNew"),
props: {
clipboardTextSerializer: (slice) => {
console.log("slice", slice.content);
const isCompleteNodeSelection = slice.openStart === 0 && slice.openEnd === 0;
const text = slice.content.textBetween(0, slice.content.size, " ");
console.log("text", text);
if (isCompleteNodeSelection) {
// For complete node selections, use the markdown serializer
return this.editor.storage.markdown.serializer.serialize(slice.content);
} else {
// For partial selections, just serialize the text content
let textContent = "";
slice.content.forEach((node) => {
console.log("node", node);
if (node.isText) {
textContent += node.text;
} else if (node.content) {
node.content.forEach((childNode) => {
console.log("aaya", childNode.content.content[0]?.text);
if (childNode.type.name === "paragraph") textContent += childNode.content.content[0]?.text;
});
}
});
console.log("textContent", textContent);
return textContent;
}
const markdownSerializedContent = this.editor.storage.markdown.serializer.serialize(slice.content);
const htmlSerializedContent = parseHTMLToMarkdown(markdownSerializedContent);
return htmlSerializedContent;
const a = transformSliceContent(slice);
// __AUTO_GENERATED_PRINT_VAR_START__
// console.log("addProseMirrorPlugins#(anon) a:", a); // __AUTO_GENERATED_PRINT_VAR_END__
console.log(markdownSerializedContent);
// const htmlSerializedContent = parseHTMLToMarkdown(markdownSerializedContent);
// console.log(htmlSerializedContent);
return markdownSerializedContent;
},
},
}),
@@ -28,88 +59,144 @@ export const MarkdownClipboard = Extension.create({
function parseHTMLToMarkdown(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const output: string[] = [];
const currentListSequence: { [key: number]: number } = {}; // Track counters for each level
// Process nodes sequentially
doc.body.childNodes.forEach((node) => {
// Track list sequences at each nesting level
const sequences: { [level: number]: number } = {};
let currentLevel = 0;
// Process the document by walking through nodes
function processNode(node: Node): string {
// Text node - return as is
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim();
if (text) output.push(text);
} else if (node instanceof HTMLElement) {
if (node.classList.contains("prosemirror-flat-list")) {
const listItem = processListItem(node, currentListSequence);
if (listItem) output.push(listItem);
} else {
const text = node.textContent?.trim();
if (text) output.push(text);
}
return node.textContent || "";
}
});
return output.join("\n");
}
// Element node
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
function processListItem(element: HTMLElement, sequence: { [key: number]: number }, level = 0): string {
const kind = element.getAttribute("data-list-kind");
const content = element.querySelector(".list-content");
if (!content) return "";
// Handle prosemirror-flat-list
if (element.classList.contains("prosemirror-flat-list")) {
const listKind = element.getAttribute("data-list-kind");
if (!listKind) return element.outerHTML;
const lines: string[] = [];
const indent = " ".repeat(level);
// Calculate nesting level
let level = 0;
let parent = element.parentElement;
while (parent) {
if (parent.classList.contains("list-content")) level++;
parent = parent.parentElement;
}
// Initialize sequence for this level if not exists
if (!(level in sequence)) sequence[level] = 0;
// Reset sequence if level decreases
if (level < currentLevel) {
sequences[level] = 0;
}
currentLevel = level;
// Process each child in the content
Array.from(content.children).forEach((child) => {
if (child instanceof HTMLElement) {
if (child.classList.contains("prosemirror-flat-list")) {
// Nested list
const nestedItem = processListItem(child, sequence, level + 1);
if (nestedItem) lines.push(nestedItem);
} else {
// Regular content
const text = child.textContent?.trim();
if (text) {
sequence[level]++; // Increment counter for current level
const marker = getListMarker(kind, sequence[level], level);
lines.push(`${indent}${marker}${text}`);
// Increment sequence for this level
sequences[level] = (sequences[level] || 0) + 1;
// Get the content
const contentDiv = element.querySelector(".list-content");
if (!contentDiv) return element.outerHTML;
const firstChild = contentDiv.firstElementChild;
if (!firstChild) return element.outerHTML;
const text = firstChild.textContent?.trim() || "";
if (!text) return element.outerHTML;
// Create proper indentation
const indent = " ".repeat(level);
// Format based on list kind
switch (listKind) {
case "ordered":
return `${indent}${sequences[level]}. ${text}`;
case "bullet":
return `${indent}- ${text}`;
case "task":
const isChecked = element.hasAttribute("data-list-checked");
return `${indent}- [${isChecked ? "x" : " "}] ${text}`;
default:
return element.outerHTML;
}
}
}
});
return lines.join("\n");
}
// For non-list elements, process children and return original HTML
if (element.childNodes.length > 0) {
const childResults = Array.from(element.childNodes)
.map((child) => processNode(child))
.join("");
function getListMarker(kind: string | null, count: number, level: number): string {
switch (kind) {
case "ordered":
// For nested ordered lists, use different markers based on level
switch (level % 3) {
case 0:
return `${count}. `;
case 1:
return `${String.fromCharCode(96 + count)}. `; // a, b, c...
case 2:
return `${toRoman(count)}. `; // i, ii, iii...
default:
return `${count}. `;
// If this is the original element, return as is with processed children
if (element === element.ownerDocument.documentElement) {
return childResults;
}
// Reconstruct the element with processed children
const clone = element.cloneNode(false) as HTMLElement;
clone.innerHTML = childResults;
return clone.outerHTML;
}
case "bullet":
return "- ";
case "task":
return "- [ ] ";
case "toggle":
return "> ";
default:
return "- ";
// Empty element, return as is
return element.outerHTML;
}
// Any other node type, return as is
return "";
}
return processNode(doc.documentElement);
}
// Helper function to convert numbers to roman numerals for third level
function toRoman(num: number): string {
const romanNumerals = [["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"]];
return romanNumerals[0][num - 1] || `${num}`;
// Function to convert a ProseMirror Fragment to an HTML string
function fragmentToHTML(fragment: Fragment, schema: Schema) {
const tempDiv = document.createElement("div");
const serializer = DOMSerializer.fromSchema(schema);
const domNode = serializer.serializeFragment(fragment);
tempDiv.appendChild(domNode);
return tempDiv.innerHTML;
}
function transformSliceContent(slice) {
function processNode(node: Node) {
// console.log("node:", node);
if (node.type?.name.toLowerCase().includes("list")) {
// Get the HTML representation of the list node itself
const listHTML = fragmentToHTML(Fragment.empty, node.type.schema);
// Process children to markdown
const childrenMarkdown = [];
node.content.forEach((child) => {
if (child.content) {
// console.log("child:", child.content);
// Convert each child's content to markdown
// const markdown = this.editor.storage.markdown.serializer.serialize(child.content);
// childrenMarkdown.push(markdown);
}
});
// Create a hybrid representation: HTML list tags with markdown content
const openTag = listHTML.split("</ul>")[0]; // or '</ol>' for ordered lists
const closeTag = "</ul>"; // or '</ol>' for ordered lists
return `${openTag}${childrenMarkdown.join("\n")}${closeTag}`;
}
// For non-list nodes, process normally
// if (node.content) {
// const newContent = [];
// node.content.forEach((child) => {
// newContent.push(processNode(child));
// });
// return node?.copy(Fragment.from(newContent));
// }
return node;
}
return processNode(slice.content);
}