Skip to content

Commit

Permalink
fix: inline code blocks, code blocks and links have saner behaviour (#…
Browse files Browse the repository at this point in the history
…3318)

* fix: removed backticks in inline code blocks

* added better error handling while cancelling uploads

* fix: inline code blocks, code blocks and links have saner behaviour

- Inline code blocks are now exitable, don't have backticks, have better padding vertically and better regex matching
- Code blocks on the top and bottom of the document are now exitable via Up and Down Arrow keys
- Links are now exitable while being autolinkable via a custom re-write of the tiptap-link-extension

* fix: more robust link checking
  • Loading branch information
Palanikannan1437 authored and sriramveeraghanta committed Jan 22, 2024
1 parent 3a02b80 commit 4dfe1ef
Show file tree
Hide file tree
Showing 12 changed files with 568 additions and 32 deletions.
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

0 comments on commit 4dfe1ef

Please sign in to comment.