mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-480] fix: code block paste from VSCode and indentation (#4198)
* fix: stroing the transactions in page * fix: page details changes * chore: page response change * chore: removed duplicated endpoints * chore: optimised the urls * chore: removed archived and favorite pages * chore: revamping pages store and components * mentions loading state part done * fixed mentions not showing in modals * removed comments and cleaned up types * removed unused types * reset: head * chore: pages store and component updates * style: pages list item UI * fix: improved colors and drag handle width * fix: slash commands are no more shown in the code blocks * fix: cleanup/hide drag handles post drop * fix: hide/cleanup drag handles post drag start * fix: aligning the drag handles better with the node post css changes of the length * fix: juggling back and forth of drag handles in ordered and unordered lists * chore: fix imports, ts errors and other things * fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes * chore: clearNodes after delete in case of selections being present * fix: hiding link selector in the bubble menu if inline code block is selected * chore: filtering, ordering and searching implemented * chore: updated pages store and updated UI * chore: new core editor just for document editor created * chore: removed setIsSubmitting prop in doc editor * fix: fixed submitting state for image uploads * refactor: setShouldShowAlert removed * refactor: rerenderOnPropsChange prop removed * chore: type inference magic in ref to expose an api for controlling editor menu items from outside * fix: naming imports * chore: change names of the exposed functions and removing old types * refactor: remove debouncedUpdatesEnabled prop; * refactor: editor heading markings now parsed using html * chore: removed unrelated components from the document editor * refactor: page details granular components * fix: remove onActionCompleteHandler * refactor: removed rerenderOnProps change prop * feat: added getMarkDown function * chore: update dropdown option actions * fix: sidebar markings update logic * chore: add image and to-do list actions to the toolbar * fix: handling refs and populating them via callbacks * feat: scroll to node api exposed * cleaning up editor refs when the editor is destroyed * feat: scrolling added to read only instance of the editor * fix: markings logic * fix: build errors with types * fix: build erros * fix: subscribing to transactions of editor via ref * chore: remove debug statements * fix: type errors * fix: temporary different slash commands for document editor * chore: inline code extension style * chore: remove border from readOnly editor * fix: editor bottom padding * chore: pages improvements * chore: handle Enter key on the page title * feat: added loading indicator logic in mentions * fix: mentions and slash commands now work well with multiple editors in one place * refactor: page store structure, filtering logic * feat: added better seperation in inline code blocks * feat: list autojoining added * fix: pages folder structure * fix: image refocus from external parts * working lists somewhat * chore: implement page reactions * fix: build errors * fix: build errors * fixed drag handles stuff * task list item fixed * working * fix: working on multiple nested lists * chore: remove debug statements * fix: Tab key on first list item handled to not go out of editor focus * feat: threshold auto scroll support added and multi nested list selection fixed * fix: caret color bug with improved inline code blocks * fix: node range error when bulk deleting with list * fix: removed slash commands from working in code blocks * chore: update typography margins * chore: new field added in page model * fix: better type inference in slash commands * chore: code block UI * feat: image insertion at correct position using ref added * feat: added improved mentions support for space * fix: type errors in mentions for comments in web app * sync: core with document-core * fix: build errors * fix: fallback for appendTo not being able to find active container instantly * fix: page store * fix: page description * fix: css quality issues * chore: code cleanup * chore: removed placeholder text in codeblocks * chore: archived pages response change * chore: archived pages response change * fix: initial pages list fetch * fix: pages list filters and ordering * chore: add access change option in the quick actions dropdown * fix: inline code block caret fixed * regression: removing extra text * chore: caret color removed * feat: copy code button added in code blocks * fix: initial load of page details * fix: initial load of page details * fix: image resizing weird behavior on click/expanding it too much fixed now * chore: copy page response * fix: todo list spacing * chore: description html in the copy page * chore: handle latest description on refetch * fix: saner scroll behaviours * fix: block menu positioning * fix: updated empty string description * feat: tab change sync support added * fix: infinite rerendering with markings * fix: block menu finally * fix: intial load on reload bug fixed * fix: nested lists alignment * fix: editor padding * fix: first level list items copyable * chore: list spacing * fix: title change * fix: pages list block items interaction * fix: saving chip position * fix: delete action from block menu to focus properly * fix: margin-bottom as 0 to avoid weird spacing when a paragraph node follows a list node * style: table, chore: lite text editor toolbar * fix: page description tab sync * fix: lists spacing and alignment * refactor: document editor props * feat: rich text editor wrapper created and migrated core * feat: created wrapper around lite text editor and merged core * chore: add lite text editor toolbar * fix: build errors * fix: type errors and addead live updation of toolbar * chore: pages migration * fix: inbox issue * refactor: remove redundant package * refactor: unused files * fix: add dompurify to space app * fix: inline code margin * fix: editor className props * fix: build errors * fix: traversing up the tree before assuming the parent is not a list item * fix: drag handle positions for list items fixed * fix: removed focus at end logic after deleting block * fix: image wrapper overflow scroll fix with block menu's position * fix: selection and deletion logic for nested lists fixed!! * fix: hiding the block menu while scrolling in the document/app * fix: merge conflicts resolved from develop * fix: inbox issue description * chore: move page title to the web app * fix: handling edge cases for table selection * chore: lint issues * refactor: list item functions moved to same file * refactor: use mention hook * fix: added try catch blocks for mention suggestions * chore: remove unused code * fix: remove console logs * fix: remove console logs * fix: code block paste handler * fix: tracking image uploading status to prevent sync rerenders * chore: remove unnecessary props * fix: code block pasting logic from vs code handled properly * feat: additional checks for doc bounds * fix: type of cancel button changed to button instead of default submit * feat: editor focus on saved position while syncing via swr * fix: type errors * fix: changing names of plugins and removing packages * fix: readonly editor synced * removed console logs * fix: stringifying instead of dom injection * fix: use-editor try catch error handling * fix: editor container click in try catch * fix: some more error handling * chore: removed commented out code * fix: backspace error handling --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
59772be014
commit
27db8699c8
@ -31,8 +31,6 @@
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
||||
"@tiptap/extension-color": "^2.1.13",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-list-item": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
|
@ -7,11 +7,22 @@ export const insertContentAtSavedSelection = (
|
||||
content: string,
|
||||
savedSelection: Selection
|
||||
) => {
|
||||
if (editorRef.current && savedSelection) {
|
||||
editorRef.current
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(savedSelection?.anchor, content)
|
||||
.run();
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSelection) {
|
||||
console.error("Saved selection is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
|
||||
|
||||
try {
|
||||
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting content at saved selection:", error);
|
||||
}
|
||||
};
|
||||
|
@ -108,6 +108,7 @@ export const useEditor = ({
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value === null || value === undefined) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value);
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
@ -118,6 +119,9 @@ export const useEditor = ({
|
||||
} else {
|
||||
editor.commands.focus("end");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error syncing editor content with external value:", error);
|
||||
}
|
||||
}
|
||||
}, [editor, value, id]);
|
||||
|
||||
@ -179,12 +183,21 @@ export const useEditor = ({
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current) return;
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docSize = editorRef.current.state.doc.content.size;
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editorRef.current
|
||||
.chain()
|
||||
.insertContentAt(position, [{ type: "paragraph" }])
|
||||
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while setting focus at position:", error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editorRef, savedSelection, uploadFile]
|
||||
|
@ -34,33 +34,83 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().toggleUnderline().run();
|
||||
};
|
||||
|
||||
const replaceCodeBlockWithContent = (editor: Editor) => {
|
||||
try {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
let replaced = false;
|
||||
|
||||
const replaceCodeBlock = (from: number, to: number, textContent: string) => {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// split the textContent by new lines to handle each line as a separate paragraph
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
|
||||
const tr = editor.state.tr;
|
||||
|
||||
// Calculate the position for inserting the first paragraph
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
const paragraphNode = paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
// Update insertPos for the next insertion
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Dispatch the transaction
|
||||
editor.view.dispatch(tr);
|
||||
replaced = true;
|
||||
};
|
||||
|
||||
editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => {
|
||||
if (node.type === schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
replaceCodeBlock(startPos, endPos, textContent);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
// Check if code block is active then toggle code block
|
||||
try {
|
||||
if (editor.isActive("codeBlock")) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
replaceCodeBlockWithContent(editor);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user hasn't selected any text
|
||||
const isSelectionEmpty = editor.state.selection.empty;
|
||||
const { from, to } = range || editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
const isMultiline = text.includes("\n");
|
||||
|
||||
if (isSelectionEmpty) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
if (editor.state.selection.empty) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else if (isMultiline) {
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
|
||||
} else {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while toggling code block:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
|
@ -15,6 +15,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const handleContainerClick = () => {
|
||||
if (!editor) return;
|
||||
if (!editor.isEditable) return;
|
||||
try {
|
||||
if (editor.isFocused) return; // If editor is already focused, do nothing
|
||||
|
||||
const { selection } = editor.state;
|
||||
@ -45,6 +46,9 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -0,0 +1,30 @@
|
||||
// import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block";
|
||||
|
||||
import { CodeBlockOptions, CodeBlock } from "./code-block";
|
||||
import { LowlightPlugin } from "./lowlight-plugin";
|
||||
|
||||
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}
|
||||
|
||||
export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: {},
|
||||
defaultLanguage: null,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
name: this.name,
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
@ -49,7 +49,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
|
||||
</button>
|
||||
|
||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
|
||||
<NodeViewContent as="code" />
|
||||
<NodeViewContent as="code" className="whitespace-[pre-wrap]" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
346
packages/editor/core/src/ui/extensions/code/code-block.ts
Normal file
346
packages/editor/core/src/ui/extensions/code/code-block.ts
Normal file
@ -0,0 +1,346 @@
|
||||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
* Adds a prefix to language classes that are applied to code tags.
|
||||
* Defaults to `'language-'`.
|
||||
*/
|
||||
languageClassPrefix: string;
|
||||
/**
|
||||
* Define whether the node should be exited on triple enter.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
exitOnTripleEnter: boolean;
|
||||
/**
|
||||
* Define whether the node should be exited on arrow down if there is no node after it.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
exitOnArrowDown: boolean;
|
||||
/**
|
||||
* Custom HTML attributes that should be added to the rendered HTML tag.
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
codeBlock: {
|
||||
/**
|
||||
* Set a code block
|
||||
*/
|
||||
setCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||
/**
|
||||
* Toggle a code block
|
||||
*/
|
||||
toggleCodeBlock: (attributes?: { language: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||
|
||||
export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
name: "codeBlock",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
languageClassPrefix: "language-",
|
||||
exitOnTripleEnter: true,
|
||||
exitOnArrowDown: true,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
content: "text*",
|
||||
|
||||
marks: "",
|
||||
|
||||
group: "block",
|
||||
|
||||
code: true,
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
language: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const { languageClassPrefix } = this.options;
|
||||
// @ts-expect-error element is a DOM element
|
||||
const classNames = [...(element.firstElementChild?.classList || [])];
|
||||
const languages = classNames
|
||||
.filter((className) => className.startsWith(languageClassPrefix))
|
||||
.map((className) => className.replace(languageClassPrefix, ""));
|
||||
const language = languages[0];
|
||||
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return language;
|
||||
},
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "pre",
|
||||
preserveWhitespace: "full",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
[
|
||||
"code",
|
||||
{
|
||||
class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null,
|
||||
},
|
||||
0,
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.setNode(this.name, attributes),
|
||||
toggleCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, "paragraph", attributes),
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
|
||||
|
||||
// remove code block when at start of document or code block is empty
|
||||
Backspace: () => {
|
||||
try {
|
||||
const { empty, $anchor } = this.editor.state.selection;
|
||||
const isAtStart = $anchor.pos === 1;
|
||||
|
||||
if (!empty || $anchor.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAtStart || !$anchor.parent.textContent.length) {
|
||||
return this.editor.commands.clearNodes();
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling Backspace in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on triple enter
|
||||
Enter: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnTripleEnter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
|
||||
|
||||
if (!isAtEnd || !endsWithDoubleNewline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.delete($from.pos - 2, $from.pos);
|
||||
|
||||
return true;
|
||||
})
|
||||
.exitCode()
|
||||
.run();
|
||||
} catch (error) {
|
||||
console.error("Error handling Enter in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on arrow down
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
textblockTypeInputRule({
|
||||
find: backtickInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
language: match[1],
|
||||
}),
|
||||
}),
|
||||
textblockTypeInputRule({
|
||||
find: tildeInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
language: match[1],
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("codeBlockVSCodeHandlerCustom"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
try {
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive(this.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive("code")) {
|
||||
// Check if it's an inline code block
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
if (!text) {
|
||||
console.error("Pasted text is empty.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { $from, $to } = tr.selection;
|
||||
|
||||
if ($from.pos > $to.pos) {
|
||||
console.error("Invalid selection range.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const docSize = tr.doc.content.size;
|
||||
if ($from.pos < 0 || $to.pos > docSize) {
|
||||
console.error("Selection range is out of document bounds.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extend the current selection to replace it with the pasted text
|
||||
// wrapped in an inline code mark
|
||||
const codeMark = view.state.schema.marks.code.create();
|
||||
tr.replaceWith($from.pos, $to.pos, view.state.schema.text(text, [codeMark]));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
const language = vscodeData?.mode;
|
||||
|
||||
if (vscodeData && language) {
|
||||
const { tr } = view.state;
|
||||
const { $from } = tr.selection;
|
||||
|
||||
// Check if the current line is empty
|
||||
const isCurrentLineEmpty = !$from.parent.textContent.trim();
|
||||
|
||||
let insertPos;
|
||||
|
||||
if (isCurrentLineEmpty) {
|
||||
// If the current line is empty, use the current position
|
||||
insertPos = $from.pos - 1;
|
||||
} else {
|
||||
// If the current line is not empty, insert below the current block node
|
||||
insertPos = $from.end($from.depth) + 1;
|
||||
}
|
||||
|
||||
// Ensure insertPos is within document bounds
|
||||
if (insertPos < 0 || insertPos > tr.doc.content.size) {
|
||||
console.error("Invalid insert position.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a new code block node with the pasted content
|
||||
const textNode = view.state.schema.text(text.replace(/\r\n?/g, "\n"));
|
||||
const codeBlock = this.type.create({ language }, textNode);
|
||||
if (insertPos <= tr.doc.content.size) {
|
||||
tr.insert(insertPos, codeBlock);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
// TODO: complicated paste logic, to be handled later
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling paste in CodeBlock extension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
@ -1,5 +1,3 @@
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
@ -9,6 +7,7 @@ lowlight.register("ts", ts);
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { CodeBlockComponent } from "./code-block-node-view";
|
||||
import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
|
||||
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
@ -18,6 +17,7 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
@ -31,8 +31,13 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
@ -61,8 +66,13 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
@ -97,6 +107,10 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
153
packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts
Normal file
153
packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { findChildren } from "@tiptap/core";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import highlight from "highlight.js/lib/core";
|
||||
|
||||
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const classes = [...className, ...(node.properties ? node.properties.className : [])];
|
||||
|
||||
if (node.children) {
|
||||
return parseNodes(node.children, classes);
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes,
|
||||
};
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function getHighlightNodes(result: any) {
|
||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||
return result.value || result.children || [];
|
||||
}
|
||||
|
||||
function registered(aliasOrLanguage: string) {
|
||||
return Boolean(highlight.getLanguage(aliasOrLanguage));
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
doc: ProsemirrorNode;
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||
let from = block.pos + 1;
|
||||
const language = block.node.attrs.language || defaultLanguage;
|
||||
const languages = lowlight.listLanguages();
|
||||
|
||||
const nodes =
|
||||
language && (languages.includes(language) || registered(language))
|
||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
|
||||
|
||||
parseNodes(nodes).forEach((node) => {
|
||||
const to = from + node.text.length;
|
||||
|
||||
if (node.classes.length) {
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: node.classes.join(" "),
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
from = to;
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
function isFunction(param: () => any) {
|
||||
return typeof param === "function";
|
||||
}
|
||||
|
||||
export function LowlightPlugin({
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
name: string;
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}) {
|
||||
if (!["highlight", "highlightAuto", "listLanguages"].every((api) => isFunction(lowlight[api]))) {
|
||||
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey("lowlight"),
|
||||
|
||||
state: {
|
||||
init: (_, { doc }) =>
|
||||
getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}),
|
||||
apply: (transaction, decorationSet, oldState, newState) => {
|
||||
const oldNodeName = oldState.selection.$head.parent.type.name;
|
||||
const newNodeName = newState.selection.$head.parent.type.name;
|
||||
const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
|
||||
const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
|
||||
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
([oldNodeName, newNodeName].includes(name) ||
|
||||
// OR transaction adds/removes named node,
|
||||
newNodes.length !== oldNodes.length ||
|
||||
// OR transaction has changes that completely encapsulte a node
|
||||
// (for example, a transaction that affects the entire document).
|
||||
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||
transaction.steps.some(
|
||||
(step) =>
|
||||
// @ts-ignore
|
||||
step.from !== undefined &&
|
||||
// @ts-ignore
|
||||
step.to !== undefined &&
|
||||
oldNodes.some(
|
||||
(node) =>
|
||||
// @ts-ignore
|
||||
node.pos >= step.from &&
|
||||
// @ts-ignore
|
||||
node.pos + node.node.nodeSize <= step.to
|
||||
)
|
||||
))
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return lowlightPlugin.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return lowlightPlugin;
|
||||
}
|
@ -46,6 +46,7 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
@ -59,6 +60,10 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("error in handling delete:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Delete": ({ editor }) => {
|
||||
let handled = false;
|
||||
@ -76,6 +81,7 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
return handled;
|
||||
},
|
||||
Backspace: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
@ -89,6 +95,10 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("error in handling Backspace:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Backspace": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
@ -11,7 +11,7 @@ export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("dropHandler"),
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
||||
|
@ -2,6 +2,7 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
@ -23,7 +24,9 @@ export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor })
|
||||
// Since we want to insert above the image, we use the imagePos directly
|
||||
const insertPos = imagePos;
|
||||
|
||||
if (insertPos < 0) return false;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (insertPos < 0 || insertPos > docSize) return false;
|
||||
|
||||
// Check for an existing node immediately before the image
|
||||
if (insertPos === 0) {
|
||||
@ -42,4 +45,8 @@ export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor })
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line above the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
|
||||
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
@ -43,4 +44,8 @@ export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor })
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line below the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -28,9 +28,9 @@ import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
import { CustomCodeMarkPlugin } from "./custom-code-inline/inline-code-plugin";
|
||||
import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { DropHandlerExtension } from "./drop";
|
||||
import { DropHandlerExtension } from "src/ui/extensions/drop";
|
||||
|
||||
type TArguments = {
|
||||
mentionConfig: {
|
||||
|
@ -4,6 +4,7 @@ export const CustomQuoteExtension = Blockquote.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: () => {
|
||||
try {
|
||||
const { $from, $to, $head } = this.editor.state.selection;
|
||||
const parent = $head.node(-1);
|
||||
|
||||
@ -19,6 +20,10 @@ export const CustomQuoteExtension = Blockquote.extend({
|
||||
this.editor.chain().splitBlock().lift(this.name).run();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Enter in blockquote:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
@ -47,4 +48,8 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("failed to insert line above table", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
@ -45,4 +46,8 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("failed to insert line above table", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -1,7 +1,6 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
@ -50,7 +49,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: { class: "my-4" },
|
||||
HTMLAttributes: {
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
@ -71,7 +72,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
@ -85,7 +85,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4",
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeInlineExtension,
|
||||
|
12
yarn.lock
12
yarn.lock
@ -2385,11 +2385,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.13.tgz#0a26731ebf98ddfd268884ff1712f7189be7b63c"
|
||||
integrity sha512-NkWlQ5bLPUlcROj6G/d4oqAxMf3j3wfndGOPp0z8OoXJtVbVoXl/aMSlLbVgE6n8r6CS8MYxKhXNxrb7Ll2foA==
|
||||
|
||||
"@tiptap/extension-code-block-lowlight@^2.1.13":
|
||||
version "2.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.13.tgz#91110f44d6cc8a12d95ac92aee0c848fdedefb0d"
|
||||
integrity sha512-PlU0lzAEbUGqPykl7fYqlAiY7/zFRtQExsbrpi2kctSIzxC+jgMM4vEpWxLS4jZEXl7jVHvBRH6lRNINDHWmQA==
|
||||
|
||||
"@tiptap/extension-code-block@^2.1.13":
|
||||
version "2.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.1.13.tgz#3e441d171d3ed821e67291dbf4cbad7e2ea29809"
|
||||
@ -2400,11 +2395,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.1.13.tgz#27a5ca5705e59ca97390fad4d6631bf431690480"
|
||||
integrity sha512-f5fLYlSgliVVa44vd7lQGvo49+peC+Z2H0Fn84TKNCH7tkNZzouoJsHYn0/enLaQ9Sq+24YPfqulfiwlxyiT8w==
|
||||
|
||||
"@tiptap/extension-color@^2.1.13":
|
||||
version "2.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-color/-/extension-color-2.1.13.tgz#f1ea3805db93f308aaf99d8ac80b18fcf13de050"
|
||||
integrity sha512-T3tJXCIfFxzIlGOhvbPVIZa3y36YZRPYIo2TKsgkTz8LiMob6hRXXNFjsrFDp2Fnu3DrBzyvrorsW7767s4eYg==
|
||||
|
||||
"@tiptap/extension-document@^2.1.13":
|
||||
version "2.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.1.13.tgz#5b68fa08e8a79eebd41f1360982db2ddd28ad010"
|
||||
@ -2757,7 +2747,7 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
|
||||
"@types/react@*", "@types/react@^18.2.42":
|
||||
version "18.2.42"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
|
||||
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
|
||||
|
Loading…
Reference in New Issue
Block a user