Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: inline code blocks, code blocks and links have saner behaviour #3318

Merged
merged 5 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/editor/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
"dependencies": {
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code": "^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-link": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-task-item": "^2.1.13",
Expand All @@ -48,6 +48,7 @@
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.294.0",
"react-moveable": "^0.54.2",
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/core/src/styles/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
display: none;
}

.ProseMirror code::before,
.ProseMirror code::after {
display: none;
}

.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
Expand Down
31 changes: 31 additions & 0 deletions packages/editor/core/src/ui/extensions/code-inline/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { markInputRule, markPasteRule } from "@tiptap/core";
import Code from "@tiptap/extension-code";

export const inputRegex = /(?<!`)`([^`]*)`(?!`)/;
export const pasteRegex = /(?<!`)`([^`]+)`(?!`)/g;

export const CustomCodeInlineExtension = Code.extend({
exitable: true,
inclusive: false,
addInputRules() {
return [
markInputRule({
find: inputRegex,
type: this.type,
}),
];
},
addPasteRules() {
return [
markPasteRule({
find: pasteRegex,
type: this.type,
}),
];
},
}).configure({
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
});
76 changes: 74 additions & 2 deletions packages/editor/core/src/ui/extensions/code/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,61 @@ import ts from "highlight.js/lib/languages/typescript";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);

export const CustomCodeBlock = CodeBlockLowlight.extend({
import { Selection } from "@tiptap/pm/state";

export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;

if (!empty || $from.parent.type !== this.type) {
return false;
}

// Use ProseMirror's insertText transaction to insert the tab character
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
editor.view.dispatch(tr);

return true;
},
ArrowUp: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;

if (!empty || $from.parent.type !== this.type) {
return false;
}

const isAtStart = $from.parentOffset === 0;

if (!isAtStart) {
return false;
}

// Check if codeBlock is the first node
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;

if (isFirstNode) {
// Insert a new paragraph at the start of the document and move the cursor to it
return editor.commands.command(({ tr }) => {
const node = editor.schema.nodes.paragraph.create();
tr.insert(0, node);
tr.setSelection(Selection.near(tr.doc.resolve(1)));
return true;
});
}

return false;
},
ArrowDown: ({ editor }) => {
if (!this.options.exitOnArrowDown) {
return false;
}

const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
Expand All @@ -18,7 +69,28 @@ export const CustomCodeBlock = CodeBlockLowlight.extend({
return false;
}

return editor.commands.insertContent(" ");
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 editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}

return editor.commands.exitCode();
},
};
},
Expand Down
118 changes: 118 additions & 0 deletions packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
getMarksBetween,
NodeWithPos,
} from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";

type AutolinkOptions = {
type: MarkType;
validate?: (url: string) => boolean;
};

export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey("autolink"),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"));

if (!docChanges || preventAutolink) {
return;
}

const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const changes = getChangedRanges(transform);

changes.forEach(({ newRange }) => {
// Now let’s see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock);

let textBlock: NodeWithPos | undefined;
let textBeforeWhitespace: string | undefined;

if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
" "
);
} else if (
nodesInChangedRanges.length &&
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ")
) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " ");
}

if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== "");

if (wordsBeforeWhitespace.length <= 0) {
return false;
}

const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);

if (!lastWordBeforeSpace) {
return false;
}

find(lastWordBeforeSpace)
.filter((link) => link.isLink)
// Calculate link position.
.map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// ignore link inside code mark
.filter((link) => {
if (!newState.schema.marks.code) {
return true;
}

return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code);
})
// validate link
.filter((link) => {
if (options.validate) {
return options.validate(link.value);
}
return true;
})
// Add link mark.
.forEach((link) => {
if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) {
return;
}

tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href,
})
);
});
}
});

if (!tr.steps.length) {
return;
}

return tr;
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getAttributes } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";

type ClickHandlerOptions = {
type: MarkType;
};

export function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handleClickLink"),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false;
}

const eventTarget = event.target as HTMLElement;

if (eventTarget.nodeName !== "A") {
return false;
}

const attrs = getAttributes(view.state, options.type.name);
const link = event.target as HTMLLinkElement;

const href = link?.href ?? attrs.href;
const target = link?.target ?? attrs.target;

if (link && href) {
if (view.editable) {
window.open(href, target);
}

return true;
}

return false;
},
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Editor } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";

type PasteHandlerOptions = {
editor: Editor;
type: MarkType;
};

export function pasteHandler(options: PasteHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handlePasteLink"),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;

if (empty) {
return false;
}

let textContent = "";

slice.content.forEach((node) => {
textContent += node.textContent;
});

const link = find(textContent).find((item) => item.isLink && item.value === textContent);

if (!textContent || !link) {
return false;
}

const html = event.clipboardData?.getData("text/html");

const hrefRegex = /href="([^"]*)"/;

const existingLink = html?.match(hrefRegex);

const url = existingLink ? existingLink[1] : link.href;

options.editor.commands.setMark(options.type, {
href: url,
});

return true;
},
},
});
}
Loading
Loading