AcadeDoc/apps/client/src/features/editor/extensions/extensions.ts
Olivier Lambert 5de1c8e3ed
fix: inline code input rule deletes character before opening backtick (#1923)
The upstream TipTap Code extension input rule regex /(^|[^`])`([^`]+)`(?!`)$/
uses a capture group (^|[^`]) that includes the character preceding the
opening backtick in the full match. When markInputRule processes this,
it deletes everything from the match start to the code content, which
removes that preceding character along with the backtick delimiters.

For example, typing foo(`bar` would result in foo`bar` (formatted)
instead of the expected foo(`bar` (formatted) — the ( is lost.

Fix: disable the built-in Code extension from StarterKit and register it
separately with a corrected regex that uses a lookbehind assertion
(?:^|(?<=[^`])) instead of a capture group. The lookbehind asserts the
preceding character without including it in the match, so markInputRule
only deletes the backtick delimiters.

Functionally tested on Firefox and Chrome.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:51:24 +00:00

352 lines
10 KiB
TypeScript

import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
Comment,
Details,
DetailsContent,
DetailsSummary,
MathBlock,
MathInline,
TableCell,
TableRow,
TableHeader,
CustomTable,
TrailingNode,
TiptapImage,
Callout,
TiptapVideo,
LinkExtension,
Selection,
Attachment,
CustomCodeBlock,
Drawio,
Excalidraw,
Embed,
SearchAndReplace,
Mention,
TableDndExtension,
Subpages,
Heading,
Highlight,
UniqueID,
SharedStorage,
Columns,
Column,
} from "@docmost/editor-ext";
import {
randomElement,
userColors,
} from "@/features/editor/extensions/utils.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import ImageView from "@/features/editor/components/image/image-view.tsx";
import {
createImageHandle,
imageResizeClasses,
} from "@/features/editor/components/image/image-resize-handles.ts";
import {
createResizeHandle,
buildResizeClasses,
} from "@/features/editor/components/common/node-resize-handles.ts";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
import elixir from "highlight.js/lib/languages/elixir";
import erlang from "highlight.js/lib/languages/erlang";
import dockerfile from "highlight.js/lib/languages/dockerfile";
import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
lowlight.register("powershell", powershell);
lowlight.register("abap", abap);
lowlight.register("erlang", erlang);
lowlight.register("elixir", elixir);
lowlight.register("dockerfile", dockerfile);
lowlight.register("clojure", clojure);
lowlight.register("fortran", fortran);
lowlight.register("haskell", haskell);
lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [
StarterKit.configure({
heading: false,
undoRedo: false,
link: false,
trailingNode: false,
dropcursor: {
width: 3,
color: "#70CFF8",
},
codeBlock: false,
code: false,
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
// to delete it. Using a lookbehind avoids including it in the match.
Code.configure({
HTMLAttributes: {
spellcheck: false,
},
}).extend({
addInputRules() {
return [
markInputRule({
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
type: this.type,
}),
];
},
}),
SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({
placeholder: ({ editor, node, pos }) => {
if (node.type.name === "heading") {
return i18n.t("Heading {{level}}", { level: node.attrs.level });
}
if (node.type.name === "detailsSummary") {
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
const $pos = editor.state.doc.resolve(pos);
const parentName = $pos.parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
}
return i18n.t('Write anything. Enter "/" for commands');
}
},
includeChildren: true,
showOnlyWhenEditable: true,
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList,
TaskItem.configure({
nested: true,
}),
LinkExtension.configure({
openOnClick: false,
}),
Superscript,
SubScript,
Highlight.configure({
multicolor: true,
}),
Typography,
TrailingNode,
GlobalDragHandle,
TextStyle,
Color,
SlashCommand,
EmojiCommand,
Comment.configure({
HTMLAttributes: {
class: "comment-mark",
},
}),
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => {
return [];
},
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
CustomTable.configure({
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
MathInline.configure({
view: MathInlineView,
}),
MathBlock.configure({
view: MathBlockView,
}),
Details,
DetailsSummary,
DetailsContent,
Youtube.configure({
addPasteHandler: false,
controls: true,
nocookie: true,
}),
TiptapImage.configure({
view: ImageView,
allowBase64: false,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createImageHandle,
className: imageResizeClasses,
},
}),
TiptapVideo.configure({
view: VideoView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-video"),
},
}),
Callout.configure({
view: CalloutView,
}),
CustomCodeBlock.configure({
view: CodeBlockView,
//@ts-ignore
lowlight,
HTMLAttributes: {
spellcheck: false,
},
}),
Selection,
Attachment.configure({
view: AttachmentView,
}),
Drawio.configure({
view: DrawioView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-drawio"),
},
}),
Excalidraw.configure({
view: ExcalidrawView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-excalidraw"),
},
}),
Embed.configure({
view: EmbedView,
}),
Subpages.configure({
view: SubpagesView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
SearchAndReplace.extend({
addKeyboardShortcuts() {
return {
"Mod-f": () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return false;
},
};
},
}).configure(),
Columns,
Column,
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({
document: provider.document,
provider,
}),
CollaborationCaret.configure({
provider,
user: {
name: user.name,
color: randomElement(userColors),
},
}),
];