mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
fix: clipboard serialization
This commit is contained in:
@@ -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[]),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user