Compare commits

...

95 Commits

Author SHA1 Message Date
Palanikannan M
b1b174e31b Merge branch 'chore/editor-packages' into feat/flat-lists 2025-06-25 14:04:20 +05:30
Aaryan Khandelwal
81e2906cc8 chore: update editor packages 2025-06-24 20:51:15 +05:30
Palanikannan M
917e164d79 fix: collapsible and tasklist markdown fixed 2025-06-23 05:54:24 +05:30
Palanikannan M
4f13d0a503 fix: copy pasting 2025-06-23 05:21:01 +05:30
Palanikannan M
594fa82826 Merge branch 'preview' into feat/flat-lists 2025-06-23 02:50:26 +05:30
VipinDevelops
b52ae00bb9 feat: minor fixes 2025-03-20 17:36:48 +05:30
VipinDevelops
ac0c0717db feat: minor fixes 2025-03-20 17:28:43 +05:30
VipinDevelops
2983be8049 Merge branch 'fix-copy_text' into feat/flat-lists 2025-03-20 16:57:16 +05:30
VipinDevelops
ae6cad2ac3 chore: remove comments 2025-03-20 16:50:37 +05:30
VipinDevelops
17b910aebd Merge branch 'fix/copying_markdown' into fix-copy_text 2025-03-19 17:53:39 +05:30
Palanikannan M
f1db64827f fix: mention id 2025-03-19 17:48:13 +05:30
Palanikannan M
8aa5cbccf3 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-19 17:43:10 +05:30
Palanikannan M
cd146aa608 fix: store hooks fixed 2025-03-19 17:26:00 +05:30
VipinDevelops
366ed387a3 fix: header and code handler 2025-03-18 13:56:00 +05:30
VipinDevelops
44bd3c6c92 refactor clipboard extension: remove options and integrate MarkdownClipboard into core extensions 2025-03-17 22:21:39 +05:30
VipinDevelops
35376d4a61 update transform copied to false 2025-03-17 21:48:58 +05:30
VipinDevelops
bdce78e15c update recursion with loop 2025-03-06 14:12:50 +05:30
VipinDevelops
d064517462 fix root node bug 2025-03-06 14:02:04 +05:30
VipinDevelops
1bdc1e845a fix code rabbit suggestions 2025-03-05 01:45:18 +05:30
VipinDevelops
0eac8a0556 add types 2025-03-04 17:32:16 +05:30
VipinDevelops
291898a63e handle using recursion 2025-03-04 16:50:53 +05:30
VipinDevelops
9112b4d381 handle multple cell in table 2025-03-03 20:37:53 +05:30
VipinDevelops
c8ee874a59 handle param 2025-03-03 19:58:40 +05:30
VipinDevelops
f8ec83206f handle using group block 2025-03-03 19:54:42 +05:30
VipinDevelops
44296c4ff2 update return statement 2025-03-03 17:45:15 +05:30
VipinDevelops
c785aac533 update markdown handling code 2025-03-03 17:22:05 +05:30
VipinDevelops
a7489eb4a3 feat: all case converd 2025-02-28 13:32:10 +05:30
VipinDevelops
6471ca6ec8 handle whole table handler 2025-02-28 11:37:11 +05:30
VipinDevelops
e5a5dc4117 handle table seletion cases 2025-02-28 01:26:47 +05:30
VipinDevelops
cf41100003 handle multi types in table 2025-02-28 00:21:48 +05:30
VipinDevelops
157ba6c34f update the min elements 2025-02-27 17:22:56 +05:30
VipinDevelops
e6b9d155b1 handle all possible cases of copy in table 2025-02-27 16:17:14 +05:30
VipinDevelops
d2a5faf54b lists fixed 2025-02-27 15:59:32 +05:30
VipinDevelops
b820a6724e handle block and list 2025-02-27 14:49:37 +05:30
VipinDevelops
1de79f1185 triple tap select current line 2025-02-27 12:17:50 +05:30
VipinDevelops
ce19996d4f handle triple click in cell 2025-02-26 21:02:43 +05:30
VipinDevelops
c02b5e17ef handle tables 2025-02-26 15:20:30 +05:30
VipinDevelops
fc6d99d22d update readabliity 2025-02-25 23:58:09 +05:30
VipinDevelops
f949a856ad add open close end handler 2025-02-25 23:10:32 +05:30
VipinDevelops
27ba1b9035 remove log 2025-02-25 18:58:02 +05:30
VipinDevelops
34a18e74b0 better smaller logic 2025-02-25 18:36:06 +05:30
VipinDevelops
2dea7e8feb update node import 2025-02-25 16:32:02 +05:30
VipinDevelops
81e1fb6b0b improve readibility 2025-02-25 16:04:50 +05:30
VipinDevelops
ec4f4e229b remove useless code 2025-02-25 15:53:53 +05:30
VipinDevelops
324e41b26d init working fix 2025-02-25 15:52:44 +05:30
VipinDevelops
cdbce9fbbb add the new copy extension 2025-02-25 11:06:48 +05:30
Palanikannan M
feab2b218d chore: renamed funcion name 2025-02-11 16:02:14 +05:30
Palanikannan M
4acc5989dc fix: refactored the component to use the same function 2025-02-11 15:57:12 +05:30
Palanikannan M
a1b91a58a1 fix: copying text in mentions 2025-02-11 15:47:43 +05:30
Palanikannan M
5fafdddb1e fix: markdown for mentions fixed 2025-02-11 15:00:42 +05:30
Palanikannan M
fbf299fbc3 wip: markdown 2025-02-08 20:04:19 +05:30
Palanikannan M
4e2a9668a5 wip: markdown for lists 2025-02-05 17:11:59 +05:30
Palanikannan M
e934300662 fix: clipboard serialization 2025-01-30 17:16:20 +05:30
Palanikannan M
133091b580 Merge branch 'preview' into feat/flat-lists 2025-01-29 14:14:45 +05:30
Palanikannan M
5e2182343e fix: hijacking markdown clipboard 2025-01-28 20:31:35 +05:30
Palanikannan M
9f6daaf0c7 fix: drop cursor fixed for multiline content in lists 2025-01-27 17:35:12 +05:30
Palanikannan M
e5c4614f58 fix: add keyboard shortcuts of complex blocks inside lists fixed 2025-01-27 17:34:32 +05:30
Palanikannan M
9bca7316f8 Merge branch 'preview' into feat/flat-lists 2025-01-27 13:51:10 +05:30
Palanikannan M
c48247ecde Merge branch 'preview' into feat/flat-lists 2025-01-17 18:16:05 +05:30
Palanikannan M
8487bb348d fix: remove paragraph tag's hardcoded values 2025-01-06 13:20:09 +05:30
Palanikannan M
61ded9611d fix: read only extensions and write extensions synced 2025-01-06 13:17:05 +05:30
Palanikannan M
e094b494f6 Merge branch 'preview' into feat/flat-lists 2025-01-06 13:08:01 +05:30
Palanikannan M
151dc428e6 fix: type 2025-01-06 07:52:18 +05:30
Palanikannan M
97f30288e1 removed console logs 2025-01-06 07:50:58 +05:30
Palanikannan M
7536a7886a chore: remove testing code 2025-01-03 20:56:35 +05:30
Palanikannan M
26f344220e fix: add taskItem dependency 2025-01-03 20:48:28 +05:30
Palanikannan M
ab02542691 fix: keeping old keymaps 2025-01-03 20:38:42 +05:30
Palanikannan M
f8d884809c fix: loading flat lists before old extensions 2025-01-03 20:17:25 +05:30
Palanikannan M
278a8141f2 wip: migration logic 2025-01-03 18:07:08 +05:30
Palanikannan M
81796afad9 fix: add toggle list item in the menu bar 2025-01-03 16:05:43 +05:30
Palanikannan M
e2af5b40d4 fix: insert table inside list 2025-01-03 14:42:44 +05:30
Palanikannan M
d90bbcd9d0 fix: migration script added and drag drop for all type of nodes 2025-01-03 14:07:10 +05:30
Palanikannan M
f5e28ddb30 fix: migration script added 2025-01-02 21:19:55 +05:30
Palanikannan M
5fab502a98 fix: drop cursor for list stabilized 2025-01-02 21:09:12 +05:30
Palanikannan M
bace1a07cf wip 2025-01-02 16:55:26 +05:30
Palanikannan M
c5fde5f5a2 Merge branch 'preview' into feat/flat-lists 2025-01-02 15:56:53 +05:30
Palanikannan M
c0e40bcbde fix: drop between lists stopped working 2025-01-02 15:10:28 +05:30
Palanikannan M
841d6ebe52 fix: remove react scan script 2025-01-02 14:05:56 +05:30
Palanikannan M
723ee1d598 fix: optimization of drop cursor 2024-12-31 17:16:42 +05:30
Palanikannan M
8d8df45b90 fix: css fixes 2024-12-31 13:11:35 +05:30
Aaryan Khandelwal
0cdee27066 chore: update css 2024-12-30 21:27:05 +05:30
Aaryan Khandelwal
f66cb7cbf9 fix: lists padding 2024-12-30 19:01:40 +05:30
Aaryan Khandelwal
893fe6cc93 fix: lists as first node of the editor 2024-12-30 18:53:49 +05:30
Aaryan Khandelwal
d5a5a247ba fix: editor lists flickering 2024-12-30 17:59:54 +05:30
Aaryan Khandelwal
a530f642ab fix: editor lists flickering 2024-12-30 16:56:10 +05:30
Aaryan Khandelwal
e80d308501 style: editor lists 2024-12-26 18:04:07 +05:30
Palanikannan M
c89f4b56d2 fix: remove items from list 2024-12-13 16:16:04 +05:30
Palanikannan M
62d778f5ae merge preview into feat/flat-lists 2024-12-13 13:31:20 +05:30
Palanikannan M
ecfc30696a fix: css and edge cases while dropping 2024-12-12 20:27:47 +05:30
Palanikannan M
7147cfd70e fix: added nested drop consistently 2024-12-12 09:32:04 +05:30
Palanikannan M
fc2b3aa443 fix: drop cursor optimized 2024-12-11 20:25:45 +05:30
Palanikannan M
649e43edf0 fix: drop cursor implementation cleaned up 2024-12-10 19:42:46 +05:30
Palanikannan M
89d2f38127 fix: drop cursor above and below added 2024-12-10 15:44:51 +05:30
Palanikannan M
76f0c8b6c1 fix: list extension drop behaviour manipulated 2024-12-06 14:47:42 +05:30
Palanikannan M
8a12eba836 fix: prosemirror flat list integrated 2024-12-03 17:11:29 +05:30
111 changed files with 7077 additions and 2721 deletions

View File

@@ -23,8 +23,8 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cors": "^2.8.5",

View File

@@ -33,7 +33,8 @@
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0",
"tar-fs": "3.0.9"
"tar-fs": "3.0.9",
"prosemirror-view": "1.40.0"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -39,32 +39,31 @@
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "2.10.4",
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-character-count": "2.11.0",
"@tiptap/extension-collaboration": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-list-item": "2.11.0",
"@tiptap/extension-mention": "2.11.0",
"@tiptap/extension-placeholder": "2.11.0",
"@tiptap/extension-task-item": "2.11.0",
"@tiptap/extension-task-list": "2.11.0",
"@tiptap/extension-text-align": "2.11.0",
"@tiptap/extension-text-style": "2.11.0",
"@tiptap/extension-underline": "2.11.0",
"@tiptap/html": "2.11.0",
"@tiptap/pm": "2.11.0",
"@tiptap/react": "2.11.0",
"@tiptap/starter-kit": "2.11.0",
"@tiptap/suggestion": "2.11.0",
"class-variance-authority": "^0.7.0",
"@tiptap/core": "^2.22.3",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.469.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"prosemirror-safari-ime-span": "^1.0.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",

View File

@@ -18,6 +18,7 @@ import {
HeadingFiveItem,
HeadingSixItem,
EditorMenuItem,
ToggleListItem,
} from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
@@ -42,6 +43,7 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
BulletListItem(editor),
NumberedListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];

View File

@@ -22,6 +22,7 @@ import {
MinusSquare,
Palette,
AlignCenter,
ListCollapse,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -35,13 +36,14 @@ import {
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeading,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
@@ -136,27 +138,35 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
command: () => toggleBulletList(editor),
isActive: () => editor?.isActive("list", { kind: "bullet" }),
command: () => toggleFlatBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
command: () => toggleOrderedList(editor),
isActive: () => editor?.isActive("list", { kind: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListOrderedIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
command: () => toggleTaskList(editor),
isActive: () => editor?.isActive("list", { kind: "task" }),
command: () => toggleFlatTaskList(editor),
icon: CheckSquare,
});
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
key: "toggle-list",
name: "Toggle list",
isActive: () => editor?.isActive("list", { kind: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListCollapse,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -248,6 +258,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
StrikeThroughItem(editor),
BulletListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
CodeItem(editor),
NumberedListItem(editor),
QuoteItem(editor),

View File

@@ -41,4 +41,5 @@ export enum CORE_EXTENSIONS {
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
LIST = "list",
}

View File

@@ -10,14 +10,14 @@ import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
type Props = NodeViewProps & {
export type CustomCalloutNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<Props> = (props) => {
export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);

View File

@@ -1,6 +1,6 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
@@ -63,6 +63,8 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
},
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
},
});

View File

@@ -1,6 +1,6 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
@@ -9,6 +9,8 @@ export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.exten
draggable: false,
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
},
});

View File

@@ -0,0 +1,419 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import { Fragment, Node } from "prosemirror-model";
import { NodeSelection, Plugin } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import * as pmView from "prosemirror-view";
import { EditorView } from "prosemirror-view";
function fragmentToExternalHTML(view: pmView.EditorView, selectedFragment: Fragment, editor: Editor) {
let isWithinBlockContent = false;
const isWithinTable = view.state.selection instanceof CellSelection;
if (!isWithinTable) {
// Checks whether block ancestry should be included when creating external
// HTML. If the selection is within a block content node, the block ancestry
// is excluded as we only care about the inline content.
const fragmentWithoutParents = view.state.doc.slice(
view.state.selection.from,
view.state.selection.to,
false
).content;
// __AUTO_GENERATED_PRINT_VAR_START__
console.log(
"fragmentToExternalHTML#if fragmentWithoutParents: ",
fragmentWithoutParents,
JSON.stringify(fragmentWithoutParents) === JSON.stringify(selectedFragment)
); // __AUTO_GENERATED_PRINT_VAR_END__
const children: Node[] = [];
for (let i = 0; i < fragmentWithoutParents.childCount; i++) {
children.push(fragmentWithoutParents.child(i));
}
isWithinBlockContent =
children.find((child) => {
// console.clear();
console.log("child name:", child.type.name);
console.log("child spec group:", child.type.spec.group);
console.log("child isInGroup block:", child.type.isInGroup("block"));
return child.type.isInGroup("block") || child.type.name === "block" || child.type.spec.group === "block";
}) === undefined;
console.log("isWithinBlockContent", isWithinBlockContent);
if (isWithinBlockContent) {
selectedFragment = fragmentWithoutParents;
}
}
let externalHTML: string;
const externalHTMLExporter = createExternalHTMLExporter(view.state.schema, editor);
// if (isWithinTable) {
// // if (selectedFragment.firstChild?.type.name === "table") {
// // // contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
// // // but cellselection.content() returns the table node itself if all cells and columns are selected
// // selectedFragment = selectedFragment.firstChild.content;
// // }
// //
// // // first convert selection to blocknote-style table content, and then
// // // pass this to the exporter
// // const ic = contentNodeToTableContent(
// // selectedFragment as any,
// // editor.schema.inlineContentSchema,
// // editor.schema.styleSchema
// // );
// //
// // // Wrap in table to ensure correct parsing by spreadsheet applications
// // externalHTML = `<table>${externalHTMLExporter.exportInlineContent(ic as any, {})}</table>`;
// if (isWithinBlockContent) {
// // first convert selection to blocknote-style inline content, and then
// // pass this to the exporter
// const ic = contentNodeToInlineContent(
// selectedFragment as any,
// editor.schema.inlineContentSchema,
// editor.schema.styleSchema
// );
// externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
// }
// } else {
// const blocks = fragmentToBlocks(selectedFragment, editor.schema);
// externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
// }
// return externalHTML;
}
export function selectedFragmentToHTML(
view: EditorView,
editor: Editor
): {
clipboardHTML: string;
externalHTML: string;
markdown?: string;
} {
// Checks if a `blockContent` node is being copied and expands
// the selection to the parent `blockContainer` node. This is
// for the use-case in which only a block without content is
// selected, e.g. an image block.
if ("node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent") {
editor.view.dispatch(
editor.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1)))
);
}
// Uses default ProseMirror clipboard serialization.
const clipboardHTML: string = (pmView as any).__serializeForClipboard(view, view.state.selection.content()).dom
.innerHTML;
const selectedFragment = view.state.selection.content().content;
console.log("selectedFragment", selectedFragment);
const externalHTML = fragmentToExternalHTML(view, selectedFragment, editor);
// const markdown = cleanHTMLToMarkdown(externalHTML);
return { clipboardHTML, externalHTML };
}
const copyToClipboard = (editor: Editor, view: EditorView, event: ClipboardEvent) => {
// Stops the default browser copy behaviour.
event.preventDefault();
event.clipboardData!.clearData();
const { clipboardHTML, externalHTML } = selectedFragmentToHTML(view, editor);
// TODO: Writing to other MIME types not working in Safari for
// some reason.
event.clipboardData!.setData("blocknote/html", clipboardHTML);
event.clipboardData!.setData("text/html", externalHTML);
// event.clipboardData!.setData("text/plain", markdown);
};
export const createCopyToClipboardExtension = () =>
Extension.create({
name: "copyToClipboard",
addProseMirrorPlugins(this) {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("copyToClipboard"),
props: {
handleDOMEvents: {
copy(view, event) {
copyToClipboard(editor, view, event);
// Prevent default PM handler to be called
return true;
},
cut(view, event) {
copyToClipboard(editor, view, event);
if (view.editable) {
view.dispatch(view.state.tr.deleteSelection());
}
// Prevent default PM handler to be called
return true;
},
// This is for the use-case in which only a block without content
// is selected, e.g. an image block, and dragged (not using the
// drag handle).
// dragstart(view, event) {
// // Checks if a `NodeSelection` is active.
// if (!("node" in view.state.selection)) {
// return;
// }
//
// // Checks if a `blockContent` node is being dragged.
// if ((view.state.selection.node as Node).type.spec.group !== "blockContent") {
// return;
// }
//
// // Expands the selection to the parent `blockContainer` node.
// editor.dispatch(
// editor._tiptapEditor.state.tr.setSelection(
// new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
// )
// );
//
// // Stops the default browser drag start behaviour.
// event.preventDefault();
// event.dataTransfer!.clearData();
//
// const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor);
//
// // TODO: Writing to other MIME types not working in Safari for
// // some reason.
// event.dataTransfer!.setData("blocknote/html", clipboardHTML);
// event.dataTransfer!.setData("text/html", externalHTML);
// event.dataTransfer!.setData("text/plain", markdown);
//
// // Prevent default PM handler to be called
// return true;
// },
},
},
}),
];
},
});
export function contentNodeToInlineContent(contentNode: Node, inlineContentSchema: any, styleSchema: any) {
const content = [];
let currentContent;
// Most of the logic below is for handling links because in ProseMirror links are marks
// while in BlockNote links are a type of inline content
contentNode.content.forEach((node) => {
// hardBreak nodes do not have an InlineContent equivalent, instead we
// add a newline to the previous node.
if (node.type.name === "hardBreak") {
if (currentContent) {
// Current content exists.
if (isStyledTextInlineContent(currentContent)) {
// Current content is text.
currentContent.text += "\n";
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
currentContent.content[currentContent.content.length - 1].text += "\n";
} else {
throw new Error("unexpected");
}
} else {
// Current content does not exist.
currentContent = {
type: "text",
text: "\n",
styles: {},
};
}
return;
}
if (node.type.name !== "link" && node.type.name !== "text" && inlineContentSchema[node.type.name]) {
if (currentContent) {
content.push(currentContent);
currentContent = undefined;
}
content.push(nodeToCustomInlineContent(node, inlineContentSchema, styleSchema));
return;
}
const styles = {};
let linkMark;
for (const mark of node.marks) {
if (mark.type.name === "link") {
linkMark = mark;
} else {
const config = styleSchema[mark.type.name];
if (!config) {
throw new Error(`style ${mark.type.name} not found in styleSchema`);
}
if (config.propSchema === "boolean") {
(styles as any)[config.type] = true;
} else if (config.propSchema === "string") {
(styles as any)[config.type] = mark.attrs.stringValue;
} else {
}
}
}
// Parsing links and text.
// Current content exists.
if (currentContent) {
// Current content is text.
if (isStyledTextInlineContent(currentContent)) {
if (!linkMark) {
// Node is text (same type as current content).
if (JSON.stringify(currentContent.styles) === JSON.stringify(styles)) {
// Styles are the same.
currentContent.text += node.textContent;
} else {
// Styles are different.
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// Node is a link (different type to current content).
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else if (isLinkInlineContent(currentContent)) {
// Current content is a link.
if (linkMark) {
// Node is a link (same type as current content).
// Link URLs are the same.
if (currentContent.href === linkMark.attrs.href) {
// Styles are the same.
if (
JSON.stringify(currentContent.content[currentContent.content.length - 1].styles) ===
JSON.stringify(styles)
) {
currentContent.content[currentContent.content.length - 1].text += node.textContent;
} else {
// Styles are different.
currentContent.content.push({
type: "text",
text: node.textContent,
styles,
});
}
} else {
// Link URLs are different.
content.push(currentContent);
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
} else {
// Node is text (different type to current content).
content.push(currentContent);
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
} else {
// TODO
}
}
// Current content does not exist.
else {
// Node is text.
if (!linkMark) {
currentContent = {
type: "text",
text: node.textContent,
styles,
};
}
// Node is a link.
else {
currentContent = {
type: "link",
href: linkMark.attrs.href,
content: [
{
type: "text",
text: node.textContent,
styles,
},
],
};
}
}
});
if (currentContent) {
content.push(currentContent);
}
return content;
}
export function isLinkInlineContent(content: { type: string }): boolean {
return content.type === "link";
}
export function isStyledTextInlineContent(content: { type: string }): boolean {
return typeof content !== "string" && content.type === "text";
}
export function nodeToCustomInlineContent(node: Node, inlineContentSchema: any, styleSchema: any) {
if (node.type.name === "text" || node.type.name === "link") {
throw new Error("unexpected");
}
const props: any = {};
const icConfig = inlineContentSchema[node.type.name];
for (const [attr, value] of Object.entries(node.attrs)) {
if (!icConfig) {
throw Error("ic node is of an unrecognized type: " + node.type.name);
}
const propSchema = icConfig.propSchema;
if (attr in propSchema) {
props[attr] = value;
}
}
let content;
if (icConfig.content === "styled") {
content = contentNodeToInlineContent(node, inlineContentSchema, styleSchema) as any; // TODO: is this safe? could we have Links here that are undesired?
} else {
content = undefined;
}
const ic = {
type: node.type.name,
props,
content,
};
return ic;
}

View File

@@ -0,0 +1,89 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = this.editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
}
}
const traverseToParentOfLeaf = (
node: Node | null,
parent: Fragment | Node,
depth: number
): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === "listItem") {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
}),
];
},
});

View File

@@ -1,4 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import { Selection, TextSelection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
@@ -14,28 +14,169 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return ReactNodeViewRenderer(CodeBlockComponent);
},
//@ts-expect-error todo
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
if (editor.isActive("codeBlock")) {
return editor.commands.newlineInCode();
}
},
Tab: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
const { $from, $to, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
if ($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);
let tr = state.tr;
// Store initial selection positions
const initialFrom = selection.from;
const initialTo = selection.to;
let offset = 0;
// Handle selection case
if (!empty) {
// Find the start of the first line in selection
let startPos = $from.pos;
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
startPos--;
}
// Find the end of the last line in selection
let endPos = $to.pos;
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
endPos++;
}
// Get the text content between start and end
const selectedText = state.doc.textBetween(startPos, endPos);
const lines = selectedText.split("\n");
// Add tabs to each line
let currentOffset = 0;
lines.forEach((line, index) => {
const pos = startPos + currentOffset;
tr = tr.insertText("\t", pos, pos);
currentOffset += line.length + 1 + 1; // +1 for newline, +1 for the inserted tab
// Update the total offset for selection adjustment
if (pos < initialFrom) offset++;
});
// Restore selection with adjusted positions
const newSelection = TextSelection.create(tr.doc, initialFrom + offset, initialTo + offset);
tr = tr.setSelection(newSelection);
} else {
// Single line case
let lineStart = $from.pos;
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
lineStart--;
}
tr = tr.insertText("\t", lineStart, lineStart);
// Adjust cursor position
const newSelection = TextSelection.create(tr.doc, initialFrom + 1, initialTo + 1);
tr = tr.setSelection(newSelection);
}
editor.view.dispatch(tr);
return true;
} catch (error) {
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
return false;
}
},
"Shift-Tab": ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, $to, empty } = selection;
if ($from.parent.type !== this.type) {
return false;
}
let tr = state.tr;
// Store initial selection positions
const initialFrom = selection.from;
const initialTo = selection.to;
let offset = 0;
// Handle selection case
if (!empty) {
// Find the start of the first line in selection
let startPos = $from.pos;
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
startPos--;
}
// Find the end of the last line in selection
let endPos = $to.pos;
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
endPos++;
}
// Get the text content between start and end
const selectedText = state.doc.textBetween(startPos, endPos);
const lines = selectedText.split("\n");
// Remove tabs from each line
let currentOffset = 0;
for (let i = 0; i < lines.length; i++) {
const pos = startPos + currentOffset;
const firstChar = state.doc.textBetween(pos, pos + 1);
if (firstChar === "\t") {
tr = tr.delete(pos, pos + 1);
if (pos < initialFrom) offset--;
currentOffset += lines[i].length; // Don't add 1 for the deleted tab
} else {
currentOffset += lines[i].length + 1; // +1 for newline
}
}
// Restore selection with adjusted positions
const newSelection = TextSelection.create(
tr.doc,
Math.max(initialFrom + offset, 0),
Math.max(initialTo + offset, 0)
);
tr = tr.setSelection(newSelection);
} else {
// Single line case
let lineStart = $from.pos;
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
lineStart--;
}
const firstChar = state.doc.textBetween(lineStart, lineStart + 1);
if (firstChar === "\t") {
tr = tr.delete(lineStart, lineStart + 1);
// Adjust cursor position
const newSelection = TextSelection.create(
tr.doc,
Math.max(initialFrom - 1, lineStart),
Math.max(initialTo - 1, lineStart)
);
tr = tr.setSelection(newSelection);
}
}
editor.view.dispatch(tr);
return true;
} catch (error) {
console.error("Error handling Shift-Tab in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowUp: ({ editor }) => {
try {
const { state } = editor;

View File

@@ -1,4 +1,3 @@
import { Selection } from "@tiptap/pm/state";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
// components
@@ -7,108 +6,7 @@ import { CodeBlockLowlight } from "./code-block-lowlight";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
try {
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;
} 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;
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;
} catch (error) {
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
return false;
}
},
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 editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},
};
},
}).configure({
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.configure({
lowlight,
defaultLanguage: "plaintext",
exitOnTripleEnter: false,

View File

@@ -8,7 +8,7 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
@@ -115,7 +115,9 @@ export const CustomImageExtension = (props: Props) => {
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
return ReactNodeViewRenderer((props) => (
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
));
},
});
};

View File

@@ -1 +0,0 @@
export * from "./list-keymap";

View File

@@ -1,377 +0,0 @@
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node, NodeType } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode: Node | null = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth);
if (currentNode.type === nodeType) {
targetDepth = currentDepth;
} else {
currentDepth -= 1;
currentPos -= 1;
}
}
if (targetDepth === null) {
return null;
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
};
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth > listItemPos.depth) {
return true;
}
return false;
};
const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
return depth;
};
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
let depth = 0;
const pos = listItemPos.$pos;
// Adjust the position to ensure we're within the list item, especially for edge cases
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
node.type.name as CORE_EXTENSIONS
)
) {
// Increment depth for each list ancestor found
depth++;
}
}
// Subtract 1 from the calculated depth to get the parent list's depth
// This adjustment is necessary because the depth calculation includes the current list
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
depth = depth > 0 ? depth - 1 : 0;
// Double the depth value to get results as 2, 4, 6, 8, etc.
depth = depth * 2;
return depth;
};
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true;
}
// Check if a node range is selected, and if so, fall back to default backspace functionality
const { from, to } = editor.state.selection;
if (from !== to) {
// A range is selected, not just a cursor position; fall back to default behavior
return false; // Let the editor handle backspace by default
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
const { $anchor } = editor.state.selection;
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
const listDescendants: Array<{ node: Node; pos: number }> = [];
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos });
}
});
const lastItem = listDescendants.at(-1);
if (!lastItem) {
return false;
}
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
// Check if positions are within the valid range
const startPos = $anchor.start() - 1;
const endPos = $anchor.end() + 1;
if (startPos < 0 || endPos > editor.state.doc.content.size) {
return false; // Invalid position, abort operation
}
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
// is the paragraph node inside of the current list item (maybe with a hard break)
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
if (!listItemPos) {
return false;
}
const currentNode = listItemPos.$pos.node(listItemPos.depth);
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
return false;
}
if (currentListItemHasSubList && isCurrentListItemSublist) {
editor.chain().liftListItem(name).run();
return editor.commands.joinItemBackward();
}
if (isCurrentListItemSublist && nextListItemIsSibling) {
return false;
}
if (isCurrentListItemSublist) {
return false;
}
if (currentListItemHasSubList) {
return false;
}
if (hasListItemBefore(name, editor.state)) {
return editor.chain().liftListItem(name).run();
}
if (!currentListItemHasSubList) {
return false;
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
return editor.chain().liftListItem(name).run();
};
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false;
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run();
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run();
}
return editor.commands.joinItemForward();
};
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection;
const previousNodePos = Math.max(0, $anchor.pos - 2);
const previousNode = editorState.doc.resolve(previousNodePos).node();
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false;
}
return true;
};
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getPrevListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth === listItemPos.depth) {
return true;
}
return false;
};
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
if (!node) {
return false;
}
const nodeType = getNodeType(typeOrName, state.schema);
let hasSubList = false;
node.descendants((child) => {
if (child.type === nodeType) {
hasSubList = true;
}
});
return hasSubList;
};
const isCurrentParagraphASibling = (state: EditorState): boolean => {
const { $from } = state.selection;
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
) {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
paragraphNodesCount++;
}
});
// If there are more than one paragraph nodes, the current paragraph is a sibling.
return paragraphNodesCount > 1;
}
return false;
};
export function isCursorInSubList(editor: Editor) {
const { selection } = editor.state;
const { $from } = selection;
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
const taskItem = editor.schema.nodes.taskItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem || node.type === taskItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList ||
parent.type === editor.schema.nodes.orderedList ||
parent.type === editor.schema.nodes.taskList)
) {
return true;
}
}
}
return false;
}
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - 2);
if ($targetPos.index() === 0) {
return false;
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false;
}
return true;
};

View File

@@ -1,134 +0,0 @@
import { Extension } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
export type ListKeymapOptions = {
listTypes: Array<{
itemName: string;
wrapperNames: string[];
}>;
};
export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
Extension.create<ListKeymapOptions>({
name: "listKeymap",
addOptions() {
return {
listTypes: [
{
itemName: "listItem",
wrapperNames: ["bulletList", "orderedList"],
},
{
itemName: "taskItem",
wrapperNames: ["taskList"],
},
],
};
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
return true;
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
return true;
}
return true;
}
// if tabIndex is set, we don't want to handle Tab key
if (tabIndex !== undefined && tabIndex !== null) {
return false;
}
return true;
},
"Shift-Tab": () => {
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
return true;
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
return true;
}
// if tabIndex is set, we don't want to handle Tab key
if (tabIndex !== undefined && tabIndex !== null) {
return false;
}
return true;
},
Delete: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Delete:", e);
return false;
}
},
"Mod-Delete": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleDelete(editor, itemName)) {
handled = true;
}
});
return handled;
},
Backspace: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Backspace:", e);
return false;
}
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
};
},
});

View File

@@ -0,0 +1,476 @@
import { Editor, Extension } from "@tiptap/core";
import { Plugin, EditorState, PluginKey, NodeSelection } from "@tiptap/pm/state";
import { dropPoint } from "@tiptap/pm/transform";
import { EditorView } from "@tiptap/pm/view";
interface DropCursorOptions {
/// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
color?: string | false;
/// The precise width of the cursor in pixels. Defaults to 1.
width?: number;
/// A CSS class name to add to the cursor element.
class?: string;
}
export function dropCursor(options: DropCursorOptions = {}, tiptapEditorOptions: { editor: Editor }): Plugin {
const pluginKey = new PluginKey("dropCursor");
return new Plugin({
key: pluginKey,
state: {
init() {
return { dropPosByDropCursorPos: null };
},
apply(tr, state) {
// Get the new state from meta
const meta = tr.getMeta(pluginKey);
if (meta) {
return { dropPosByDropCursorPos: meta.dropPosByDropCursorPos };
}
return state;
},
},
view(editorView) {
return new DropCursorView(editorView, options, tiptapEditorOptions.editor, pluginKey);
},
props: {
handleDrop(view, event, slice, moved) {
const { isBetweenFlatLists, isHoveringOverListContent } =
rawIsBetweenFlatListsFn(event, tiptapEditorOptions.editor) || {};
const state = pluginKey.getState(view.state);
let dropPosByDropCursorPos = state?.dropPosByDropCursorPos;
if (isHoveringOverListContent) {
dropPosByDropCursorPos -= 1;
}
if (isBetweenFlatLists && dropPosByDropCursorPos) {
const tr = view.state.tr;
if (moved) {
// Get the size of content to be deleted
const selection = tr.selection;
const deleteSize = selection.to - selection.from;
// Adjust drop position if it's after the deletion point
if (dropPosByDropCursorPos > selection.from) {
dropPosByDropCursorPos -= deleteSize;
}
tr.deleteSelection();
}
// Insert the content
tr.insert(dropPosByDropCursorPos, slice.content);
// Create a NodeSelection on the newly inserted content
const $pos = tr.doc.resolve(dropPosByDropCursorPos);
const node = $pos.nodeAfter;
if (node) {
const nodeSelection = NodeSelection.create(tr.doc, dropPosByDropCursorPos);
tr.setSelection(nodeSelection);
}
view.dispatch(tr);
return true;
}
return false;
},
},
});
}
// Add disableDropCursor to NodeSpec
declare module "prosemirror-model" {
interface NodeSpec {
disableDropCursor?:
| boolean
| ((view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => boolean);
}
}
class DropCursorView {
private width: number;
private color: string | undefined;
private class: string | undefined;
private cursorPos: number | null = null;
private element: HTMLElement | null = null;
private timeout: ReturnType<typeof setTimeout> | null = null;
private handlers: { name: string; handler: (event: Event) => void }[];
private editor: Editor;
// Throttled version of our isBetweenFlatListsFn
private isBetweenFlatListsFn: (event: DragEvent) => ReturnType<typeof rawIsBetweenFlatListsFn>;
constructor(
private readonly editorView: EditorView,
options: DropCursorOptions,
editor: Editor,
private readonly pluginKey: PluginKey
) {
this.width = options.width ?? 1;
this.color = options.color === false ? undefined : options.color || `rgb(115, 115, 115)`;
this.class = options.class;
this.editor = editor;
// Create the throttled function and store for use in dragover
this.isBetweenFlatListsFn = createThrottledIsBetweenFlatListsFn(editor);
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
const handler = (e: Event) => {
(this as any)[name](e);
};
editorView.dom.addEventListener(name, handler);
return { name, handler };
});
}
destroy() {
this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
}
update(editorView: EditorView, prevState: EditorState) {
if (this.cursorPos != null && prevState.doc != editorView.state.doc) {
if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null);
else this.updateOverlay();
}
}
setCursor(pos: number | null, isBetweenFlatLists?: boolean) {
this.cursorPos = pos;
if (pos == null) {
if (this.element?.parentNode) {
this.element.parentNode.removeChild(this.element);
}
this.element = null;
} else {
this.updateOverlay(isBetweenFlatLists);
}
}
updateOverlay(isBetweenFlatLists?: boolean) {
const isBetweenFlatList = isBetweenFlatLists ?? false;
const $pos = this.editorView.state.doc.resolve(this.cursorPos!);
const isBlock = !$pos.parent.inlineContent;
let rect: Partial<DOMRect> | undefined;
const editorDOM = this.editorView.dom;
const editorRect = editorDOM.getBoundingClientRect();
const scaleX = editorRect.width / editorDOM.offsetWidth;
const scaleY = editorRect.height / editorDOM.offsetHeight;
if (isBlock) {
const before = $pos.nodeBefore;
const after = $pos.nodeAfter;
if (before || after) {
const node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
if (node) {
const nodeRect = (node as HTMLElement).getBoundingClientRect();
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after) {
top = (top + (this.editorView.nodeDOM(this.cursorPos!) as HTMLElement).getBoundingClientRect().top) / 2;
}
const halfWidth = (this.width / 2) * scaleY;
rect = {
left: nodeRect.left,
right: nodeRect.right,
top: top - halfWidth,
bottom: top + halfWidth,
};
}
}
}
if (!rect) {
const coords = this.editorView.coordsAtPos(this.cursorPos!);
const halfWidth = (this.width / 2) * scaleX;
rect = {
left: coords.left - halfWidth,
right: coords.left + halfWidth,
top: coords.top,
bottom: coords.bottom,
};
}
const parent = this.editorView.dom.offsetParent as HTMLElement;
if (!this.element) {
this.element = parent.appendChild(document.createElement("div"));
if (this.class) this.element.className = this.class;
this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none";
if (this.color) {
this.element.style.backgroundColor = this.color;
}
}
this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
let parentLeft: number, parentTop: number;
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
parentLeft = -window.scrollX;
parentTop = -window.scrollY;
} else {
const parentRect = parent.getBoundingClientRect();
const parentScaleX = parentRect.width / parent.offsetWidth;
const parentScaleY = parentRect.height / parent.offsetHeight;
parentLeft = parentRect.left - parent.scrollLeft * parentScaleX;
parentTop = parentRect.top - parent.scrollTop * parentScaleY;
}
// Adjust left if we're between flat lists
const finalLeft = (rect.left! - parentLeft) / scaleX;
const finalTop = (rect.top! - parentTop) / scaleY;
const finalWidth = (rect.right! - rect.left!) / scaleX;
const finalHeight = (rect.bottom! - rect.top!) / scaleY;
this.element.style.transform = isBetweenFlatList ? `translateX(${-20}px` : `translateX(0px)`;
this.element.style.left = finalLeft + "px";
this.element.style.top = finalTop + "px";
this.element.style.width = finalWidth + "px";
this.element.style.height = finalHeight + "px";
}
scheduleRemoval(timeout: number) {
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.setCursor(null), timeout);
}
dragover(event: DragEvent) {
if (!this.editorView.editable) return;
const pos = this.editorView.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) return;
// Throttled call to the function
const result = this.isBetweenFlatListsFn(event);
let isHoveringOverListContentVar = false;
let isBetweenFlatListsVar = false;
if (result) {
if ("pos" in result) {
const { isBetweenFlatLists, pos: posList, isHoveringOverListContent } = result;
isBetweenFlatListsVar = isBetweenFlatLists;
isHoveringOverListContentVar = isHoveringOverListContent;
if (isBetweenFlatLists && this.element) {
pos.pos = posList;
}
} else {
const { isBetweenFlatLists, isHoveringOverListContent } = result;
isBetweenFlatListsVar = isBetweenFlatLists;
isHoveringOverListContentVar = isHoveringOverListContent;
}
}
const node = pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
const disableDropCursor = node && node.type.spec.disableDropCursor;
const disabled =
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
if (pos && !disabled) {
let target = pos.pos;
if (this.editorView.dragging && this.editorView.dragging.slice) {
const point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
if (point != null) target = point;
}
this.dropPosByDropCursorPos = target;
this.setCursor(target, !!isBetweenFlatListsVar && !isHoveringOverListContentVar);
this.scheduleRemoval(5000);
}
}
dragend() {
this.scheduleRemoval(20);
}
drop() {
this.scheduleRemoval(20);
}
dragleave(event: DragEvent) {
const relatedTarget = event.relatedTarget as Node | null;
if (relatedTarget && !this.editorView.dom.contains(relatedTarget)) {
this.setCursor(null);
}
}
set dropPosByDropCursorPos(pos: number | null) {
const tr = this.editorView.state.tr;
tr.setMeta(this.pluginKey, { dropPosByDropCursorPos: pos });
this.editorView.dispatch(tr);
}
get dropPosByDropCursorPos(): number | null {
return this.pluginKey.getState(this.editorView.state)?.dropPosByDropCursorPos;
}
}
export const DropCursorExtension = Extension.create({
name: "dropCursor",
addProseMirrorPlugins() {
return [
dropCursor(
{
width: 2,
class: "transition-all duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
this
),
];
},
});
function findDirectChild(element: HTMLElement, parentClass: string) {
const parent = element.closest(`.${parentClass}`);
if (!parent) return null;
// Get all direct children of parent that contain our element
const directChildren = Array.from(parent.children);
return directChildren.find((child) => child.contains(element));
}
function rawIsBetweenFlatListsFn(event: DragEvent, editor: Editor) {
const coords = {
left: event.clientX,
top: event.clientY,
};
const positionCache = new WeakMap();
const elementUnderDrag = document.elementFromPoint(coords.left, coords.top);
if (!elementUnderDrag) return null;
const currentFlatList = elementUnderDrag.closest(".prosemirror-flat-list");
if (!currentFlatList) return null;
const currentListContent = currentFlatList.querySelector(".list-content");
const children = Array.from(currentListContent?.childNodes || []);
if (event.target instanceof HTMLElement) {
const child = findDirectChild(event.target as HTMLElement, "list-content");
const offset = children.indexOf(child as HTMLElement);
if (offset > 0) {
return { isBetweenFlatLists: false, isHoveringOverListContent: false };
}
}
let isInsideToggleOrTask = false;
if (
currentFlatList.getAttribute("data-list-kind") === "toggle" ||
currentFlatList.getAttribute("data-list-kind") === "task"
) {
isInsideToggleOrTask = true;
}
const state = {
isHoveringOverListContent: !elementUnderDrag.classList.contains("prosemirror-flat-list"),
isBetweenFlatLists: true,
hasNestedLists: false,
pos: null as number | null,
listLevel: 0,
isNestedList: false,
};
if (isInsideToggleOrTask) {
const firstChildListMarker = currentFlatList.firstChild as HTMLElement;
state.isHoveringOverListContent = firstChildListMarker?.classList.contains("list-marker");
}
const getPositionFromElement = (element: Element, some?: boolean): number | null => {
if (positionCache.has(element)) {
return positionCache.get(element);
}
const pos = editor.view.posAtDOM(element, 0);
function getNodeAtPos(state: EditorState, pos: number) {
const $pos = state.doc.resolve(pos);
return $pos.node();
}
const editorNode = getNodeAtPos(editor.view.state, pos);
let result = pos ?? null;
if (some) {
result = pos + editorNode.nodeSize;
}
positionCache.set(element, result);
return result;
};
// Check for child list within the current list item
const childList = currentFlatList?.querySelector(".prosemirror-flat-list");
if (childList) {
state.pos = getPositionFromElement(childList);
state.hasNestedLists = true;
state.isNestedList = true;
} else {
// Existing logic for other cases
const sibling = currentFlatList.nextElementSibling;
const firstNestedList = currentFlatList.querySelector(":scope > .prosemirror-flat-list");
const level = getListLevelOptimized(currentFlatList);
state.listLevel = level;
state.isNestedList = level >= 1;
if (sibling) {
state.pos = getPositionFromElement(sibling);
} else if (firstNestedList) {
state.pos = getPositionFromElement(firstNestedList);
state.hasNestedLists = true;
} else if (level >= 1 && !sibling) {
const parent = currentFlatList.parentElement?.parentElement;
const poss = getPositionFromElement(currentFlatList as Element, true);
if (parent) {
state.pos = poss;
}
}
}
if (!state.pos) return null;
return {
...state,
pos: state.pos - 1,
};
}
// Optimized list level calculation
function getListLevelOptimized(element: Element): number {
let level = 0;
let current = element.parentElement;
// Use a more efficient selector matching
while (current && !current.classList.contains("ProseMirror")) {
if (current.classList.contains("prosemirror-flat-list")) {
level++;
}
current = current.parentElement;
}
return level;
}
function createThrottledIsBetweenFlatListsFn(
editor: Editor,
moveThreshold = 8 // px of mouse movement before re-checking
) {
let lastX = 0;
let lastY = 0;
let lastResult: ReturnType<typeof rawIsBetweenFlatListsFn> | null = null;
return function throttledIsBetweenFlatListsFn(event: DragEvent) {
const dx = Math.abs(event.clientX - lastX);
const dy = Math.abs(event.clientY - lastY);
// Only recalc if we moved enough OR enough time passed
if (dx < moveThreshold && dy < moveThreshold) {
return lastResult;
}
lastX = event.clientX;
lastY = event.clientY;
lastResult = rawIsBetweenFlatListsFn(event, editor);
return lastResult;
};
}

View File

@@ -1,8 +1,11 @@
import { 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";
// import OrderedList from "@tiptap/extension-ordered-list";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
// import TaskItem from "@tiptap/extension-task-item";
// import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
@@ -23,12 +26,13 @@ import {
CustomTextAlignExtension,
CustomTypographyExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
TableRow,
FlatListExtension,
UtilityExtension,
DropCursorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -49,34 +53,14 @@ type TArguments = Pick<
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const { disabledExtensions, enableHistory, fileHandler, flaggedExtensions, mentionHandler, placeholder, editable } =
args;
const extensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
bulletList: false,
orderedList: false,
listItem: false,
code: false,
codeBlock: false,
horizontalRule: false,
@@ -91,12 +75,78 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
class: "editor-heading-block",
},
},
dropcursor: {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
// dropcursor: {
// class: "text-custom-text-300",
// },
dropcursor: false,
...(enableHistory ? {} : { history: false }),
}),
DropCursorExtension,
FlatListExtension,
// BulletList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-disc pl-7 space-y-2",
// },
// }),
// OrderedList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-decimal pl-7 space-y-2",
// },
// }),
// ListItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose space-y-2",
// },
// }),
// TaskList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose pl-2 space-y-2",
// },
// }),
// TaskItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// addKeyboardShortcuts() {
// return {};
// },
// }).configure({
// HTMLAttributes: {
// class: "relative",
// },
// nested: true,
// }),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
@@ -104,7 +154,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
// ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -119,24 +169,58 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTypographyExtension,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeInlineExtension,
Markdown.configure({
Markdown.extend({
addMarkdownSerializerRules() {
return {
list: (
state: {
write: (text: string) => void;
ensureNewLine: () => void;
serializeFragment: (fragment: any) => string;
},
node: { attrs: Record<string, any>; content: any }
) => {
// Custom serializer for flat-list nodes
const attrs = node.attrs as { kind?: string; order?: number; checked?: boolean; collapsed?: boolean };
const listKind = attrs.kind || "bullet";
const isChecked = attrs.checked;
const isCollapsed = attrs.collapsed;
// Serialize the content of this list item
const content = state.serializeFragment(node.content);
// Create the appropriate markdown based on list type
switch (listKind) {
case "task":
state.write(`- [${isChecked ? "x" : " "}] ${content}`);
break;
case "toggle": {
const togglePrefix = isCollapsed ? "▶" : "▼";
state.write(`- ${togglePrefix} ${content}`);
break;
}
case "ordered": {
const orderNum = attrs.order || 1;
state.write(`${orderNum}. ${content}`);
break;
}
case "bullet":
default:
state.write(`- ${content}`);
break;
}
state.ensureNewLine();
},
};
},
}).configure({
html: true,
transformCopiedText: false,
transformPastedText: true,
@@ -202,6 +286,5 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};

View File

@@ -0,0 +1,279 @@
import { Fragment, NodeRange, Slice } from "@tiptap/pm/model"
import { type Command, type Transaction } from "@tiptap/pm/state"
import { ReplaceAroundStep } from "@tiptap/pm/transform"
import { withAutoFixList } from '../utils/auto-fix-list'
import {
atEndBlockBoundary,
atStartBlockBoundary,
} from '../utils/block-boundary'
import { getListType } from '../utils/get-list-type'
import { isListNode } from '../utils/is-list-node'
import { findListsRange, isListsRange } from '../utils/list-range'
import { mapPos } from '../utils/map-pos'
import { safeLift } from '../utils/safe-lift'
import { zoomInRange } from '../utils/zoom-in-range'
import { withVisibleSelection } from './set-safe-selection'
/**
* @public
*/
export interface DedentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number
}
/**
* Returns a command function that decreases the indentation of selected list nodes.
*
* @public @group Commands
*/
export function createDedentListCommand(options?: DedentListOptions): Command {
const dedentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr
const $from =
options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from)
const $to =
options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to)
const range = findListsRange($from, $to)
if (!range) return false
if (dedentRange(range, tr)) {
dispatch?.(tr)
return true
}
return false
}
return withVisibleSelection(withAutoFixList(dedentListCommand))
}
function dedentRange(
range: NodeRange,
tr: Transaction,
startBoundary?: boolean,
endBoundary?: boolean,
): boolean {
const { depth, $from, $to } = range
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1)
if (!startBoundary) {
const { startIndex, endIndex } = range
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range)
return contentRange ? dedentRange(contentRange, tr) : false
} else {
return splitAndDedentRange(range, tr, startIndex + 1)
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1)
if (!endBoundary) {
fixEndBoundary(range, tr)
const endOfParent = $to.end(depth)
range = new NodeRange(
tr.doc.resolve($from.pos),
tr.doc.resolve(endOfParent),
depth,
)
return dedentRange(range, tr, undefined, true)
}
if (
range.startIndex === 0 &&
range.endIndex === range.parent.childCount &&
isListNode(range.parent)
) {
return dedentNodeRange(new NodeRange($from, $to, depth - 1), tr)
}
return dedentNodeRange(range, tr)
}
/**
* Split a range into two parts, and dedent them separately.
*/
function splitAndDedentRange(
range: NodeRange,
tr: Transaction,
splitIndex: number,
): boolean {
const { $from, $to, depth } = range
const splitPos = $from.posAtIndex(splitIndex, depth)
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1))
if (!range1) return false
const getRange2From = mapPos(tr, splitPos + 1)
const getRange2To = mapPos(tr, $to.pos)
dedentRange(range1, tr, undefined, true)
let range2 = tr.doc
.resolve(getRange2From())
.blockRange(tr.doc.resolve(getRange2To()))
if (range2 && range2.depth >= depth) {
range2 = new NodeRange(range2.$from, range2.$to, depth)
dedentRange(range2, tr, true, undefined)
}
return true
}
export function dedentNodeRange(range: NodeRange, tr: Transaction) {
if (isListNode(range.parent)) {
return safeLiftRange(tr, range)
} else if (isListsRange(range)) {
return dedentOutOfList(tr, range)
} else {
return safeLiftRange(tr, range)
}
}
function safeLiftRange(tr: Transaction, range: NodeRange): boolean {
if (moveRangeSiblings(tr, range)) {
const $from = tr.doc.resolve(range.$from.pos)
const $to = tr.doc.resolve(range.$to.pos)
range = new NodeRange($from, $to, range.depth)
}
return safeLift(tr, range)
}
function moveRangeSiblings(tr: Transaction, range: NodeRange): boolean {
const listType = getListType(tr.doc.type.schema)
const { $to, depth, end, parent, endIndex } = range
const endOfParent = $to.end(depth)
if (end < endOfParent) {
// There are siblings after the lifted items, which must become
// children of the last item
const lastChild = parent.maybeChild(endIndex - 1)
if (!lastChild) return false
const canAppend =
endIndex < parent.childCount &&
lastChild.canReplace(
lastChild.childCount,
lastChild.childCount,
parent.content,
endIndex,
parent.childCount,
)
if (canAppend) {
tr.step(
new ReplaceAroundStep(
end - 1,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true,
),
)
return true
} else {
tr.step(
new ReplaceAroundStep(
end,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 0, 0),
1,
true,
),
)
return true
}
}
return false
}
function fixEndBoundary(range: NodeRange, tr: Transaction): void {
if (range.endIndex - range.startIndex >= 2) {
range = new NodeRange(
range.$to.doc.resolve(
range.$to.posAtIndex(range.endIndex - 1, range.depth),
),
range.$to,
range.depth,
)
}
const contentRange = zoomInRange(range)
if (contentRange) {
fixEndBoundary(contentRange, tr)
range = new NodeRange(
tr.doc.resolve(range.$from.pos),
tr.doc.resolve(range.$to.pos),
range.depth,
)
}
moveRangeSiblings(tr, range)
}
export function dedentOutOfList(tr: Transaction, range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range
const getRangeStart = mapPos(tr, range.start)
const getRangeEnd = mapPos(tr, range.end)
// Merge the list nodes into a single big list node
for (let end = getRangeEnd(), i = endIndex - 1; i > startIndex; i--) {
end -= parent.child(i).nodeSize
tr.delete(end - 1, end + 1)
}
const $start = tr.doc.resolve(getRangeStart())
const listNode = $start.nodeAfter
if (!listNode) return false
const start = range.start
const end = start + listNode.nodeSize
if (getRangeEnd() !== end) return false
if (
!$start.parent.canReplace(
startIndex,
startIndex + 1,
Fragment.from(listNode),
)
) {
return false
}
tr.step(
new ReplaceAroundStep(
start,
end,
start + 1,
end - 1,
new Slice(Fragment.empty, 0, 0),
0,
true,
),
)
return true
}

View File

@@ -0,0 +1,10 @@
import { chainCommands, createParagraphNear, newlineInCode, splitBlock } from "@tiptap/pm/commands";
import { type Command } from "@tiptap/pm/state";
/**
* This command has the same behavior as the `Enter` keybinding from
* `prosemirror-commands`, but without the `liftEmptyBlock` command.
*
* @internal
*/
export const enterWithoutLift: Command = chainCommands(newlineInCode, createParagraphNear, splitBlock);

View File

@@ -0,0 +1,185 @@
import { Fragment, type NodeRange, Slice } from "@tiptap/pm/model"
import { type Command, type Transaction } from "@tiptap/pm/state"
import { ReplaceAroundStep } from "@tiptap/pm/transform"
import { type ListAttributes } from '../types'
import { withAutoFixList } from '../utils/auto-fix-list'
import {
atEndBlockBoundary,
atStartBlockBoundary,
} from '../utils/block-boundary'
import { getListType } from '../utils/get-list-type'
import { inCollapsedList } from '../utils/in-collapsed-list'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { mapPos } from '../utils/map-pos'
import { zoomInRange } from '../utils/zoom-in-range'
import { withVisibleSelection } from './set-safe-selection'
/**
* @public
*/
export interface IndentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number
}
/**
* Returns a command function that increases the indentation of selected list
* nodes.
*
* @public @group Commands
*/
export function createIndentListCommand(options?: IndentListOptions): Command {
const indentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr
const $from =
options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from)
const $to =
options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to)
const range = findListsRange($from, $to) || $from.blockRange($to)
if (!range) return false
if (indentRange(range, tr)) {
dispatch?.(tr)
return true
}
return false
}
return withVisibleSelection(withAutoFixList(indentListCommand))
}
function indentRange(
range: NodeRange,
tr: Transaction,
startBoundary?: boolean,
endBoundary?: boolean,
): boolean {
const { depth, $from, $to } = range
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1)
if (!startBoundary) {
const { startIndex, endIndex } = range
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range)
return contentRange ? indentRange(contentRange, tr) : false
} else {
return splitAndIndentRange(range, tr, startIndex + 1)
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1)
if (!endBoundary && !inCollapsedList($to)) {
const { startIndex, endIndex } = range
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range)
return contentRange ? indentRange(contentRange, tr) : false
} else {
return splitAndIndentRange(range, tr, endIndex - 1)
}
}
return indentNodeRange(range, tr)
}
/**
* Split a range into two parts, and indent them separately.
*/
function splitAndIndentRange(
range: NodeRange,
tr: Transaction,
splitIndex: number,
): boolean {
const { $from, $to, depth } = range
const splitPos = $from.posAtIndex(splitIndex, depth)
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1))
if (!range1) return false
const getRange2From = mapPos(tr, splitPos + 1)
const getRange2To = mapPos(tr, $to.pos)
indentRange(range1, tr, undefined, true)
const range2 = tr.doc
.resolve(getRange2From())
.blockRange(tr.doc.resolve(getRange2To()))
if (range2) {
indentRange(range2, tr, true, undefined)
}
return true
}
/**
* Increase the indentation of a block range.
*/
function indentNodeRange(range: NodeRange, tr: Transaction): boolean {
const listType = getListType(tr.doc.type.schema)
const { parent, startIndex } = range
const prevChild = startIndex >= 1 && parent.child(startIndex - 1)
// If the previous node before the range is a list node, move the range into
// the previous list node as its children
if (prevChild && isListNode(prevChild)) {
const { start, end } = range
tr.step(
new ReplaceAroundStep(
start - 1,
end,
start,
end,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true,
),
)
return true
}
// If we can avoid to add a new bullet visually, we can wrap the range with a
// new list node.
const isParentListNode = isListNode(parent)
const isFirstChildListNode = isListNode(parent.maybeChild(startIndex))
if ((startIndex === 0 && isParentListNode) || isFirstChildListNode) {
const { start, end } = range
const listAttrs: ListAttributes | null = isFirstChildListNode
? parent.child(startIndex).attrs
: isParentListNode
? parent.attrs
: null
tr.step(
new ReplaceAroundStep(
start,
end,
start,
end,
new Slice(Fragment.from(listType.create(listAttrs)), 0, 0),
1,
true,
),
)
return true
}
// Otherwise we cannot indent
return false
}

View File

@@ -0,0 +1,56 @@
import { type ResolvedPos } from "@tiptap/pm/model"
import { type Command, TextSelection } from "@tiptap/pm/state"
import { type ListAttributes } from '../types'
import { atTextblockStart } from '../utils/at-textblock-start'
import { isListNode } from '../utils/is-list-node'
import { joinTextblocksAround } from './join-textblocks-around'
/**
* If the selection is empty and at the start of a block, and there is a
* collapsed list node right before the cursor, move current block and append it
* to the first child of the collapsed list node (i.e. skip the hidden content).
*
* @public @group Commands
*/
export const joinCollapsedListBackward: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view)
if (!$cursor) return false
const $cut = findCutBefore($cursor)
if (!$cut) return false
const { nodeBefore, nodeAfter } = $cut
if (
nodeBefore &&
nodeAfter &&
isListNode(nodeBefore) &&
(nodeBefore.attrs as ListAttributes).collapsed &&
nodeAfter.isBlock
) {
const tr = state.tr
const listPos = $cut.pos - nodeBefore.nodeSize
tr.delete($cut.pos, $cut.pos + nodeAfter.nodeSize)
const insert = listPos + 1 + nodeBefore.child(0).nodeSize
tr.insert(insert, nodeAfter)
const $insert = tr.doc.resolve(insert)
tr.setSelection(TextSelection.near($insert))
if (joinTextblocksAround(tr, $insert, dispatch)) {
return true
}
}
return false
}
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L150
function findCutBefore($pos: ResolvedPos): ResolvedPos | null {
if (!$pos.parent.type.spec.isolating)
for (let i = $pos.depth - 1; i >= 0; i--) {
if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1))
if ($pos.node(i).type.spec.isolating) break
}
return null
}

View File

@@ -0,0 +1,105 @@
import { NodeRange, type ResolvedPos } from "@tiptap/pm/model";
import { TextSelection, type Command, type EditorState, type Transaction } from "@tiptap/pm/state";
import { atTextblockStart } from "../utils/at-textblock-start";
import { isListNode } from "../utils/is-list-node";
import { safeLift } from "../utils/safe-lift";
/**
* If the text cursor is at the start of the first child of a list node, lift
* all content inside the list. If the text cursor is at the start of the last
* child of a list node, lift this child.
*
* @public @group Commands
*/
export const joinListUp: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view);
if (!$cursor) return false;
const before = $cursor.pos - 1;
const $before = state.doc.resolve(before);
const nodeBefore = $before.nodeBefore;
// Handle case when there's a list node before
if (
nodeBefore?.type.name === "list" &&
nodeBefore?.lastChild?.isBlock &&
!nodeBefore.lastChild.type.name.startsWith("paragraph")
) {
if (dispatch) {
const tr = state.tr;
// Get the last child of the list
const lastChild = nodeBefore.lastChild;
if (!lastChild) return false;
// Calculate positions
const deleteFrom = $before.pos;
const deleteTo = deleteFrom + $cursor.parent.nodeSize;
// Get the content to join
const contentToJoin = $cursor.parent.content;
// Delete the current paragraph
tr.delete(deleteFrom, deleteTo);
// Calculate the position to insert at (end of last list item's content)
const insertPos = $before.pos - 1;
// Insert the content at the end of the last list item
tr.insert(insertPos, contentToJoin);
// Calculate the position of the last child
const lastChildPos = $before.pos;
// Set selection to the end of the last child
const $lastChildPos = tr.doc.resolve(lastChildPos);
tr.setSelection(TextSelection.near($lastChildPos, -1));
dispatch(tr);
}
return true;
}
const { depth } = $cursor;
if (depth < 2) return false;
const listDepth = depth - 1;
const listNode = $cursor.node(listDepth);
if (!isListNode(listNode)) return false;
const indexInList = $cursor.index(listDepth);
if (indexInList === 0) {
if (dispatch) {
liftListContent(state, dispatch, $cursor);
}
return true;
}
if (indexInList === listNode.childCount - 1) {
if (dispatch) {
liftParent(state, dispatch, $cursor);
}
return true;
}
return false;
};
function liftListContent(state: EditorState, dispatch: (tr: Transaction) => void, $cursor: ResolvedPos) {
const tr = state.tr;
const listDepth = $cursor.depth - 1;
const range = new NodeRange($cursor, tr.doc.resolve($cursor.end(listDepth)), listDepth);
if (safeLift(tr, range)) {
dispatch(tr);
}
}
function liftParent(state: EditorState, dispatch: (tr: Transaction) => void, $cursor: ResolvedPos) {
const tr = state.tr;
const range = $cursor.blockRange();
if (range && safeLift(tr, range)) {
dispatch(tr);
}
}

View File

@@ -0,0 +1,36 @@
/* eslint-disable prefer-const */
import { type ResolvedPos, Slice } from "@tiptap/pm/model"
import { TextSelection, type Transaction } from "@tiptap/pm/state"
import { replaceStep, ReplaceStep } from "@tiptap/pm/transform"
// prettier-ignore
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L94
function joinTextblocksAround(tr: Transaction, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) {
let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1
for (; !beforeText.isTextblock; beforePos--) {
if (beforeText.type.spec.isolating) return false
let child = beforeText.lastChild
if (!child) return false
beforeText = child
}
let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1
for (; !afterText.isTextblock; afterPos++) {
if (afterText.type.spec.isolating) return false
let child = afterText.firstChild
if (!child) return false
afterText = child
}
let step = replaceStep(tr.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null
if (!step || step.from != beforePos ||
step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false
if (dispatch) {
tr.step(step)
tr.setSelection(TextSelection.create(tr.doc, beforePos))
dispatch(tr.scrollIntoView())
}
return true
}
export { joinTextblocksAround }

View File

@@ -0,0 +1,83 @@
import {
chainCommands,
deleteSelection,
joinTextblockBackward,
joinTextblockForward,
selectNodeBackward,
selectNodeForward,
} from "@tiptap/pm/commands";
import { createDedentListCommand } from "./dedent-list";
import { createIndentListCommand } from "./indent-list";
import { joinCollapsedListBackward } from "./join-collapsed-backward";
import { joinListUp } from "./join-list-up";
import { protectCollapsed } from "./protect-collapsed";
import { createSplitListCommand } from "./split-list";
/**
* Keybinding for `Enter`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - {@link createSplitListCommand}
*
* @public @group Commands
*/
export const enterCommand = chainCommands(protectCollapsed, createSplitListCommand());
/**
* Keybinding for `Backspace`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - {@link joinListUp}
* - {@link joinCollapsedListBackward}
* - [joinTextblockBackward](https://prosemirror.net/docs/ref/#commands.joinTextblockBackward)
* - [selectNodeBackward](https://prosemirror.net/docs/ref/#commands.selectNodeBackward)
*
* @public @group Commands
*
*/
export const backspaceCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinListUp,
joinCollapsedListBackward,
joinTextblockBackward,
selectNodeBackward
);
/**
* Keybinding for `Delete`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - [joinTextblockForward](https://prosemirror.net/docs/ref/#commands.joinTextblockForward)
* - [selectNodeForward](https://prosemirror.net/docs/ref/#commands.selectNodeForward)
*
* @public @group Commands
*
*/
export const deleteCommand = chainCommands(protectCollapsed, deleteSelection, joinTextblockForward, selectNodeForward);
/**
* Returns an object containing the keymap for the list commands.
*
* - `Enter`: See {@link enterCommand}.
* - `Backspace`: See {@link backspaceCommand}.
* - `Delete`: See {@link deleteCommand}.
* - `Mod-[`: Decrease indentation. See {@link createDedentListCommand}.
* - `Mod-]`: Increase indentation. See {@link createIndentListCommand}.
*
* @public @group Commands
*/
export const listKeymap = {
Enter: enterCommand,
Backspace: backspaceCommand,
Delete: deleteCommand,
"Mod-[": createDedentListCommand(),
"Mod-]": createIndentListCommand(),
};

View File

@@ -0,0 +1,86 @@
import { type Command, type Transaction } from "@tiptap/pm/state"
import { withAutoFixList } from '../utils/auto-fix-list'
import { cutByIndex } from '../utils/cut-by-index'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { safeLift } from '../utils/safe-lift'
/**
* Returns a command function that moves up or down selected list nodes.
*
* @public @group Commands
*
*/
export function createMoveListCommand(direction: 'up' | 'down'): Command {
const moveList: Command = (state, dispatch): boolean => {
const tr = state.tr
if (doMoveList(tr, direction, true, !!dispatch)) {
dispatch?.(tr)
return true
}
return false
}
return withAutoFixList(moveList)
}
/** @internal */
export function doMoveList(
tr: Transaction,
direction: 'up' | 'down',
canDedent: boolean,
dispatch: boolean,
): boolean {
const { $from, $to } = tr.selection
const range = findListsRange($from, $to)
if (!range) return false
const { parent, depth, startIndex, endIndex } = range
if (direction === 'up') {
if (startIndex >= 2 || (startIndex === 1 && isListNode(parent.child(0)))) {
const before = cutByIndex(parent.content, startIndex - 1, startIndex)
const selected = cutByIndex(parent.content, startIndex, endIndex)
if (
parent.canReplace(startIndex - 1, endIndex, selected.append(before))
) {
if (dispatch) {
tr.insert($from.posAtIndex(endIndex, depth), before)
tr.delete(
$from.posAtIndex(startIndex - 1, depth),
$from.posAtIndex(startIndex, depth),
)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
} else {
if (endIndex < parent.childCount) {
const selected = cutByIndex(parent.content, startIndex, endIndex)
const after = cutByIndex(parent.content, endIndex, endIndex + 1)
if (parent.canReplace(startIndex, endIndex + 1, after.append(selected))) {
if (dispatch) {
tr.delete(
$from.posAtIndex(endIndex, depth),
$from.posAtIndex(endIndex + 1, depth),
)
tr.insert($from.posAtIndex(startIndex, depth), after)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
}
}

View File

@@ -0,0 +1,43 @@
import type { Command } from "@tiptap/pm/state"
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
/**
* This command will protect the collapsed items from being deleted.
*
* If current selection contains a collapsed item, we don't want the user to
* delete this selection by pressing Backspace or Delete, because this could
* be unintentional.
*
* In such case, we will stop the delete action and expand the collapsed items
* instead. Therefore the user can clearly know what content he is trying to
* delete.
*
* @public @group Commands
*
*/
export const protectCollapsed: Command = (state, dispatch): boolean => {
const tr = state.tr
let found = false
const { from, to } = state.selection
state.doc.nodesBetween(from, to, (node, pos, parent, index) => {
if (found && !dispatch) {
return false
}
if (parent && isCollapsedListNode(parent) && index >= 1) {
found = true
if (!dispatch) {
return false
}
const $pos = state.doc.resolve(pos)
tr.setNodeAttribute($pos.before($pos.depth), 'collapsed', false)
}
})
if (found) {
dispatch?.(tr)
}
return found
}

View File

@@ -0,0 +1,70 @@
import { type ResolvedPos } from "@tiptap/pm/model"
import {
type Selection,
TextSelection,
type Transaction,
} from "@tiptap/pm/state"
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
import { patchCommand } from '../utils/patch-command'
import { setListAttributes } from '../utils/set-list-attributes'
function moveOutOfCollapsed(
$pos: ResolvedPos,
minDepth: number,
): Selection | null {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
const before = $pos.posAtIndex(1, depth)
const $before = $pos.doc.resolve(before)
return TextSelection.near($before, -1)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, move the selection outside of it
*
* @internal
*/
export function setSafeSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const selection =
moveOutOfCollapsed($from, 0) ||
moveOutOfCollapsed($to, $from.sharedDepth(to))
if (selection) {
tr.setSelection(selection)
}
return tr
}
export const withSafeSelection = patchCommand(setSafeSelection)
function getCollapsedPosition($pos: ResolvedPos, minDepth: number) {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
return $pos.before(depth)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, expand it
*
* @internal
*/
export function setVisibleSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const pos =
getCollapsedPosition($from, 0) ??
getCollapsedPosition($to, $from.sharedDepth(to))
if (pos != null) {
tr.doc.resolve(pos)
setListAttributes(tr, pos, { collapsed: false })
}
return tr
}
export const withVisibleSelection = patchCommand(setVisibleSelection)

View File

@@ -0,0 +1,161 @@
import { chainCommands } from "@tiptap/pm/commands";
import { Fragment, type Node as ProsemirrorNode, Slice } from "@tiptap/pm/model";
import { type Command, type EditorState, Selection, TextSelection, type Transaction } from "@tiptap/pm/state";
import { canSplit } from "@tiptap/pm/transform";
import { type ListAttributes } from "../types";
import { withAutoFixList } from "../utils/auto-fix-list";
import { createAndFill } from "../utils/create-and-fill";
import { isBlockNodeSelection } from "../utils/is-block-node-selection";
import { isListNode } from "../utils/is-list-node";
import { isTextSelection } from "../utils/is-text-selection";
import { enterWithoutLift } from "./enter-without-lift";
/**
* Returns a command that split the current list node.
*
* @public @group Commands
*
*/
export function createSplitListCommand(): Command {
return withAutoFixList(chainCommands(splitBlockNodeSelectionInListCommand, splitListCommand));
}
function deriveListAttributes(listNode: ProsemirrorNode): ListAttributes {
// For the new list node, we don't want to inherit any list attribute (For example: `checked`) other than `kind`
return { kind: (listNode.attrs as ListAttributes).kind };
}
const splitBlockNodeSelectionInListCommand: Command = (state, dispatch) => {
if (!isBlockNodeSelection(state.selection)) {
return false;
}
const selection = state.selection;
const { $to, node } = selection;
const parent = $to.parent;
// We only cover the case that
// 1. the list node only contains one child node
// 2. this child node is not a list node
if (isListNode(node) || !isListNode(parent) || parent.childCount !== 1 || parent.firstChild !== node) {
return false;
}
const listType = parent.type;
const nextList = listType.createAndFill(deriveListAttributes(parent));
if (!nextList) {
return false;
}
if (dispatch) {
const tr = state.tr;
const cutPoint = $to.pos;
tr.replace(cutPoint, cutPoint, new Slice(Fragment.fromArray([listType.create(), nextList]), 1, 1));
const newSelection = TextSelection.near(tr.doc.resolve(cutPoint));
if (isTextSelection(newSelection)) {
tr.setSelection(newSelection);
dispatch(tr);
}
}
return true;
};
const splitListCommand: Command = (state, dispatch): boolean => {
if (isBlockNodeSelection(state.selection)) {
return false;
}
const { $from, $to } = state.selection;
if (!$from.sameParent($to)) {
return false;
}
if ($from.depth < 2) {
return false;
}
const listDepth = $from.depth - 1;
const listNode = $from.node(listDepth);
if (!isListNode(listNode)) {
return false;
}
const parent = $from.parent;
const indexInList = $from.index(listDepth);
const parentEmpty = parent.content.size === 0;
// When the cursor is inside the first child of the list:
// Split and create a new list node.
// When the cursor is inside the second or further children of the list:
// Create a new paragraph.
if (indexInList === 0) {
return doSplitList(state, listNode, dispatch);
} else {
if (parentEmpty) {
return enterWithoutLift(state, dispatch);
} else {
return false;
}
}
};
/**
* @internal
*/
export function doSplitList(
state: EditorState,
listNode: ProsemirrorNode,
dispatch?: (tr: Transaction) => void
): boolean {
const tr = state.tr;
const listType = listNode.type;
const attrs: ListAttributes = listNode.attrs;
const newAttrs: ListAttributes = deriveListAttributes(listNode);
tr.delete(tr.selection.from, tr.selection.to);
const { $from, $to } = tr.selection;
const { parentOffset } = $to;
const atStart = parentOffset == 0;
const atEnd = parentOffset == $to.parent.content.size;
if (atStart) {
if (dispatch) {
const pos = $from.before(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
dispatch(tr.scrollIntoView());
}
return true;
}
if (atEnd && attrs.collapsed) {
if (dispatch) {
const pos = $from.after(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
tr.setSelection(Selection.near(tr.doc.resolve(pos)));
dispatch(tr.scrollIntoView());
}
return true;
}
// If split the list at the start or at the middle, we want to inherit the
// current parent type (e.g. heading); otherwise, we want to create a new
// default block type (typically paragraph)
const nextType = atEnd ? listNode.contentMatchAt(0).defaultType : undefined;
const typesAfter = [{ type: listType, attrs: newAttrs }, nextType ? { type: nextType } : null];
if (!canSplit(tr.doc, $from.pos, 2, typesAfter)) {
return false;
}
dispatch?.(tr.split($from.pos, 2, typesAfter).scrollIntoView());
return true;
}

View File

@@ -0,0 +1,64 @@
import { type Command } from "@tiptap/pm/state"
import { type ListAttributes, type ProsemirrorNode } from '../types'
import { isListNode } from '../utils/is-list-node'
import { setSafeSelection } from './set-safe-selection'
/**
* @public
*/
export interface ToggleCollapsedOptions {
/**
* If this value exists, the command will set the `collapsed` attribute to
* this value instead of toggle it.
*/
collapsed?: boolean
/**
* An optional function to accept a list node and return whether or not this
* node can toggle its `collapsed` attribute.
*/
isToggleable?: (node: ProsemirrorNode) => boolean
}
/**
* Return a command function that toggle the `collapsed` attribute of the list node.
*
* @public @group Commands
*/
export function createToggleCollapsedCommand({
collapsed = undefined,
isToggleable = defaultIsToggleable,
}: ToggleCollapsedOptions = {}): Command {
const toggleCollapsed: Command = (state, dispatch) => {
const { $from } = state.selection
for (let depth = $from.depth; depth >= 0; depth--) {
const node = $from.node(depth)
if (isListNode(node) && isToggleable(node)) {
if (dispatch) {
const pos = $from.before(depth)
const attrs = node.attrs as ListAttributes
const tr = state.tr
tr.setNodeAttribute(pos, 'collapsed', collapsed ?? !attrs.collapsed)
dispatch(setSafeSelection(tr))
}
return true
}
}
return false
}
return toggleCollapsed
}
function defaultIsToggleable(node: ProsemirrorNode): boolean {
const attrs = node.attrs as ListAttributes
return (
attrs.kind === 'toggle' &&
node.childCount >= 2 &&
!isListNode(node.firstChild)
)
}

View File

@@ -0,0 +1,27 @@
import { chainCommands } from "@tiptap/pm/commands";
import { type Command } from "@tiptap/pm/state";
import { type ListAttributes } from "../types";
import { createUnwrapListCommand } from "./unwrap-list";
import { createWrapInListCommand } from "./wrap-in-list";
/**
* Returns a command function that wraps the selection in a list with the given
* type and attributes, or change the list kind if the selection is already in
* another kind of list, or unwrap the selected list if otherwise.
*
* @public
*/
export function createToggleListCommand<T extends ListAttributes = ListAttributes>(
/**
* The list node attributes to toggle.
*
* @public
*/
attrs: T
): Command {
const unwrapList = createUnwrapListCommand({ kind: attrs.kind });
const wrapInList = createWrapInListCommand(attrs);
return chainCommands(unwrapList, wrapInList);
}

View File

@@ -0,0 +1,93 @@
import { type NodeRange } from "@tiptap/pm/model"
import { type Command } from "@tiptap/pm/state"
import { type ListAttributes, type ProsemirrorNode } from '../types'
import { isListNode } from '../utils/is-list-node'
import { isNodeSelection } from '../utils/is-node-selection'
import { safeLiftFromTo } from '../utils/safe-lift'
import { dedentOutOfList } from './dedent-list'
/**
* @public
*/
export interface UnwrapListOptions {
/**
* If given, only this kind of list will be unwrap.
*/
kind?: string
}
/**
* Returns a command function that unwraps the list around the selection.
*
* @public
*/
export function createUnwrapListCommand(options?: UnwrapListOptions): Command {
const kind = options?.kind
const unwrapList: Command = (state, dispatch) => {
const selection = state.selection
if (isNodeSelection(selection) && isTargetList(selection.node, kind)) {
if (dispatch) {
const tr = state.tr
safeLiftFromTo(tr, tr.selection.from + 1, tr.selection.to - 1)
dispatch(tr.scrollIntoView())
}
return true
}
const range = selection.$from.blockRange(selection.$to)
if (range && isTargetListsRange(range, kind)) {
const tr = state.tr
if (dedentOutOfList(tr, range)) {
dispatch?.(tr)
return true
}
}
if (range && isTargetList(range.parent, kind)) {
if (dispatch) {
const tr = state.tr
safeLiftFromTo(
tr,
range.$from.start(range.depth),
range.$to.end(range.depth),
)
dispatch(tr.scrollIntoView())
}
return true
}
return false
}
return unwrapList
}
function isTargetList(node: ProsemirrorNode, kind: string | undefined) {
if (isListNode(node)) {
if (kind) {
return (node.attrs as ListAttributes).kind === kind
}
return true
}
return false
}
function isTargetListsRange(
range: NodeRange,
kind: string | undefined,
): boolean {
const { startIndex, endIndex, parent } = range
for (let i = startIndex; i < endIndex; i++) {
if (!isTargetList(parent.child(i), kind)) {
return false
}
}
return true
}

View File

@@ -0,0 +1,101 @@
import { NodeRange } from "@tiptap/pm/model"
import { type Command } from "@tiptap/pm/state"
import { findWrapping } from "@tiptap/pm/transform"
import { type ListAttributes } from '../types'
import { getListType } from '../utils/get-list-type'
import { isListNode } from '../utils/is-list-node'
import { setNodeAttributes } from '../utils/set-node-attributes'
/**
* The list node attributes or a callback function to take the current
* selection block range and return list node attributes. If this callback
* function returns null, the command won't do anything.
*
* @public
*/
export type WrapInListGetAttrs<T extends ListAttributes> =
| T
| ((range: NodeRange) => T | null)
/**
* Returns a command function that wraps the selection in a list with the given
* type and attributes.
*
* @public @group Commands
*/
export function createWrapInListCommand<
T extends ListAttributes = ListAttributes,
>(getAttrs: WrapInListGetAttrs<T>): Command {
const wrapInList: Command = (state, dispatch): boolean => {
const { $from, $to } = state.selection
let range = $from.blockRange($to)
if (!range) {
return false
}
if (
rangeAllowInlineContent(range) &&
isListNode(range.parent) &&
range.depth > 0 &&
range.startIndex === 0
) {
range = new NodeRange($from, $to, range.depth - 1)
}
const attrs: T | null =
typeof getAttrs === 'function' ? getAttrs(range) : getAttrs
if (!attrs) {
return false
}
const { parent, startIndex, endIndex, depth } = range
const tr = state.tr
const listType = getListType(state.schema)
for (let i = endIndex - 1; i >= startIndex; i--) {
const node = parent.child(i)
if (isListNode(node)) {
const oldAttrs: T = node.attrs as T
const newAttrs: T = { ...oldAttrs, ...attrs }
setNodeAttributes(tr, $from.posAtIndex(i, depth), oldAttrs, newAttrs)
} else {
const beforeNode = $from.posAtIndex(i, depth)
const afterNode = $from.posAtIndex(i + 1, depth)
let nodeStart = beforeNode + 1
let nodeEnd = afterNode - 1
if (nodeStart > nodeEnd) {
;[nodeStart, nodeEnd] = [nodeEnd, nodeStart]
}
const range = new NodeRange(
tr.doc.resolve(nodeStart),
tr.doc.resolve(nodeEnd),
depth,
)
const wrapping = findWrapping(range, listType, attrs)
if (wrapping) {
tr.wrap(range, wrapping)
}
}
}
dispatch?.(tr)
return true
}
return wrapInList
}
function rangeAllowInlineContent(range: NodeRange): boolean {
const { parent, startIndex, endIndex } = range
for (let i = startIndex; i < endIndex; i++) {
if (parent.child(i).inlineContent) {
return true
}
}
return false
}

View File

@@ -0,0 +1,65 @@
import { type Node as ProsemirrorNode } from "@tiptap/pm/model";
import { type Command } from "@tiptap/pm/state";
import { type EditorView } from "@tiptap/pm/view";
import { withSafeSelection } from "./commands/set-safe-selection";
import { type ListAttributes } from "./types";
import { isListNode } from "./utils/is-list-node";
import { setNodeAttributes } from "./utils/set-node-attributes";
/** @internal */
export function handleListMarkerMouseDown({
view,
event,
onListClick = defaultListClickHandler,
}: {
view: EditorView;
event: MouseEvent;
onListClick?: ListClickHandler;
}): boolean {
const target = event.target as HTMLElement | null;
if (target?.closest(".list-marker-click-target")) {
event.preventDefault();
const pos = view.posAtDOM(target, -10, -10);
return handleMouseDown(pos, onListClick)(view.state, (tr) => view.dispatch(tr));
}
return false;
}
function handleMouseDown(pos: number, onListClick: ListClickHandler): Command {
const mouseDown: Command = (state, dispatch) => {
const tr = state.tr;
const $pos = tr.doc.resolve(pos);
const list = $pos.parent;
if (!isListNode(list)) {
return false;
}
const listPos = $pos.before($pos.depth);
const attrs = onListClick(list);
if (setNodeAttributes(tr, listPos, list.attrs, attrs)) {
dispatch?.(tr);
}
return true;
};
return withSafeSelection(mouseDown);
}
/** @internal */
export type ListClickHandler = (node: ProsemirrorNode) => ListAttributes;
/** @internal */
export const defaultListClickHandler: ListClickHandler = (node) => {
const attrs = node.attrs as ListAttributes;
if (attrs.kind === "task") {
return { ...attrs, checked: !attrs.checked };
} else if (attrs.kind === "toggle") {
return { ...attrs, collapsed: !attrs.collapsed };
} else {
return attrs;
}
};

View File

@@ -0,0 +1,38 @@
export { createDedentListCommand, type DedentListOptions } from "./commands/dedent-list";
export { enterWithoutLift } from "./commands/enter-without-lift";
export { createIndentListCommand, type IndentListOptions } from "./commands/indent-list";
export { joinCollapsedListBackward } from "./commands/join-collapsed-backward";
export { joinListUp } from "./commands/join-list-up";
export { backspaceCommand, deleteCommand, enterCommand, listKeymap } from "./commands/keymap";
export { createMoveListCommand } from "./commands/move-list";
export { protectCollapsed } from "./commands/protect-collapsed";
export { setSafeSelection } from "./commands/set-safe-selection";
export { createSplitListCommand, doSplitList } from "./commands/split-list";
export { createToggleCollapsedCommand, type ToggleCollapsedOptions } from "./commands/toggle-collapsed";
export { createToggleListCommand } from "./commands/toggle-list";
export { createUnwrapListCommand, type UnwrapListOptions } from "./commands/unwrap-list";
export { createWrapInListCommand, type WrapInListGetAttrs } from "./commands/wrap-in-list";
export { defaultListClickHandler, handleListMarkerMouseDown, type ListClickHandler } from "./dom-events";
export { listInputRules, wrappingListInputRule, type ListInputRuleAttributesGetter } from "./input-rule";
export { migrateDocJSON } from "./migrate";
export { createListNodeView } from "./node-view";
export {
createListClipboardPlugin,
createListEventPlugin,
createListPlugins,
createListRenderingPlugin,
createSafariInputMethodWorkaroundPlugin,
} from "./plugins";
export { createListSpec, flatListGroup } from "./schema/node-spec";
export { createParseDomRules } from "./schema/parse-dom";
export { defaultAttributesGetter, defaultMarkerGetter, listToDOM, type ListToDOMOptions } from "./schema/to-dom";
export type { ListAttributes, ListKind, ProsemirrorNode, ProsemirrorNodeJSON } from "./types";
export { getListType } from "./utils/get-list-type";
export { isCollapsedListNode } from "./utils/is-collapsed-list-node";
export { isListNode } from "./utils/is-list-node";
export { isListType } from "./utils/is-list-type";
export { findListsRange, isListsRange } from "./utils/list-range";
export { ListDOMSerializer, joinListElements } from "./utils/list-serializer";
export { parseInteger } from "./utils/parse-integer";
export { rangeToString } from "./utils/range-to-string";
export { unwrapListSlice } from "./utils/unwrap-list-slice";

View File

@@ -0,0 +1,103 @@
import { InputRule } from "@tiptap/pm/inputrules";
import { type Attrs } from "@tiptap/pm/model";
import { type Transaction } from "@tiptap/pm/state";
import { findWrapping } from "@tiptap/pm/transform";
import { type ListAttributes } from "./types";
import { getListType } from "./utils/get-list-type";
import { isListNode } from "./utils/is-list-node";
import { parseInteger } from "./utils/parse-integer";
/**
* A callback function to get the attributes for a list input rule.
*
* @public @group Input Rules
*/
export type ListInputRuleAttributesGetter<T extends ListAttributes = ListAttributes> = (options: {
/**
* The match result of the regular expression.
*/
match: RegExpMatchArray;
/**
* The previous attributes of the existing list node, if it exists.
*/
attributes?: T;
}) => T;
/**
* Build an input rule for automatically wrapping a textblock into a list node
* when a given string is typed.
*
* @public @group Input Rules
*/
export function wrappingListInputRule<T extends ListAttributes = ListAttributes>(
regexp: RegExp,
getAttrs: T | ListInputRuleAttributesGetter<T>
): InputRule {
return new InputRule(regexp, (state, match, start, end): Transaction | null => {
const tr = state.tr;
tr.deleteRange(start, end);
const $pos = tr.selection.$from;
const listNode = $pos.index(-1) === 0 && $pos.node(-1);
if (listNode && isListNode(listNode)) {
const oldAttrs: Attrs = listNode.attrs as ListAttributes;
const newAttrs: Attrs =
typeof getAttrs === "function" ? getAttrs({ match, attributes: oldAttrs as T }) : getAttrs;
const entries = Object.entries(newAttrs).filter(([key, value]) => oldAttrs[key] !== value);
if (entries.length === 0) {
return null;
} else {
const pos = $pos.before(-1);
for (const [key, value] of entries) {
tr.setNodeAttribute(pos, key, value);
}
return tr;
}
}
const $start = tr.doc.resolve(start);
const range = $start.blockRange();
if (!range) {
return null;
}
const newAttrs: Attrs = typeof getAttrs === "function" ? getAttrs({ match }) : getAttrs;
const wrapping = findWrapping(range, getListType(state.schema), newAttrs);
if (!wrapping) {
return null;
}
return tr.wrap(range, wrapping);
});
}
/**
* All input rules for lists.
*
* @public @group Input Rules
*/
export const listInputRules: InputRule[] = [
wrappingListInputRule<ListAttributes>(/^\s?([*-])\s$/, {
kind: "bullet",
collapsed: false,
}),
wrappingListInputRule<ListAttributes>(/^\s?(\d+)\.\s$/, ({ match }) => {
const order = parseInteger(match[1]);
return {
kind: "ordered",
collapsed: false,
order: order != null && order >= 2 ? order : null,
};
}),
wrappingListInputRule<ListAttributes>(/^\s?\[([\sXx]?)]\s$/, ({ match }) => ({
kind: "task",
checked: ["x", "X"].includes(match[1]),
collapsed: false,
})),
wrappingListInputRule<ListAttributes>(/^\s?>>\s$/, {
kind: "toggle",
}),
];

View File

@@ -0,0 +1,82 @@
import type { ListAttributes, ListKind, ProsemirrorNodeJSON } from "./types";
function migrateNodes(nodes: ProsemirrorNodeJSON[]): [ProsemirrorNodeJSON[], boolean] {
const content: ProsemirrorNodeJSON[] = [];
let updated = false;
for (const node of nodes) {
if (node.type === "bullet_list" || node.type === "bulletList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "bullet",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else if (node.type === "ordered_list" || node.type === "orderedList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "ordered",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else if (node.type === "task_list" || node.type === "taskList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "task",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else {
// Handle other node types, including those that may contain list items
const [migratedContent, contentUpdated] = migrateNodes(node.content ?? []);
content.push({ ...node, content: migratedContent });
updated = updated || contentUpdated;
}
}
return [content, updated];
}
function migrateNode(node: ProsemirrorNodeJSON, { kind }: { kind?: ListKind } = {}): [ProsemirrorNodeJSON, boolean] {
// Check if the node is a list item
if (node.type === "list_item" || node.type === "listItem" || node.type === "taskItem") {
const [content, updated] = migrateNodes(node.content ?? []);
return [
{
...node,
type: "list",
attrs: {
collapsed: Boolean(node.attrs?.closed),
...node.attrs,
kind: kind ?? "bullet",
} satisfies ListAttributes,
content,
},
true,
];
} else if (node.content) {
// If the node has content, we need to check for nested list items
const [content, updated] = migrateNodes(node.content);
return [{ ...node, content }, updated];
} else {
return [node, false];
}
}
/**
* Migrate a ProseMirror document JSON object from the old list structure to the
* new. A new document JSON object is returned if the document is updated,
* otherwise `null` is returned.
*
* @public
*/
export function migrateDocJSON(docJSON: ProsemirrorNodeJSON): ProsemirrorNodeJSON | null {
const [migrated, updated] = migrateNode(docJSON);
return updated ? migrated : null;
}

View File

@@ -0,0 +1,37 @@
import { type Node as ProsemirrorNode, DOMSerializer } from "@tiptap/pm/model";
import { type NodeViewConstructor } from "@tiptap/pm/view";
import * as browser from "./utils/browser";
/**
* A simple node view that is used to render the list node. It ensures that the
* list node get updated when its marker styling should changes.
*
* @public @group Plugins
*/
export const createListNodeView: NodeViewConstructor = (node) => {
let prevNode = node;
const prevNested = node.firstChild?.type === node.type;
const prevSingleChild = node.childCount === 1;
const spec = node.type.spec.toDOM!(node);
const { dom, contentDOM } = DOMSerializer.renderSpec(document, spec);
// iOS Safari will jump the text selection around with a toggle list since the element is empty,
// and adding an empty span as a child to the click target prevents that behavior
// See https://github.com/ocavue/prosemirror-flat-list/issues/89
if (browser.safari && node.attrs.kind === "toggle") {
(dom as HTMLElement).querySelector(".list-marker-click-target")?.appendChild(document.createElement("span"));
}
const update = (node: ProsemirrorNode): boolean => {
if (!node.sameMarkup(prevNode)) return false;
const nested = node.firstChild?.type === node.type;
const singleChild = node.childCount === 1;
if (prevNested !== nested || prevSingleChild !== singleChild) return false;
prevNode = node;
return true;
};
return { dom, contentDOM, update };
};

View File

@@ -0,0 +1,21 @@
import { type Schema } from "@tiptap/pm/model";
import { Plugin } from "@tiptap/pm/state";
import { ListDOMSerializer } from "../utils/list-serializer";
import { unwrapListSlice } from "../utils/unwrap-list-slice";
/**
* Serialize list nodes into native HTML list elements (i.e. `<ul>`, `<ol>`) to
* clipboard. See {@link ListDOMSerializer}.
*
* @public @group Plugins
*/
export function createListClipboardPlugin(schema: Schema): Plugin {
return new Plugin({
props: {
clipboardSerializer: ListDOMSerializer.fromSchema(schema),
transformCopied: unwrapListSlice,
},
});
}

View File

@@ -0,0 +1,18 @@
import { Plugin } from "@tiptap/pm/state";
import { handleListMarkerMouseDown } from "../dom-events";
/**
* Handle DOM events for list.
*
* @public @group Plugins
*/
export function createListEventPlugin(): Plugin {
return new Plugin({
props: {
handleDOMEvents: {
mousedown: (view, event) => handleListMarkerMouseDown({ view, event }),
},
},
});
}

View File

@@ -0,0 +1,36 @@
import { type Schema } from "@tiptap/pm/model";
import { type Plugin } from "@tiptap/pm/state";
import { createListClipboardPlugin } from "./clipboard";
import { createListEventPlugin } from "./event";
import { createListRenderingPlugin } from "./rendering";
import { createSafariInputMethodWorkaroundPlugin } from "./safari-workaround";
/**
* This function returns an array of plugins that are required for list to work.
*
* The plugins are shown below. You can pick and choose which plugins you want
* to use if you want to customize some behavior.
*
* - {@link createListEventPlugin}
* - {@link createListRenderingPlugin}
* - {@link createListClipboardPlugin}
* - {@link createSafariInputMethodWorkaroundPlugin}
*
* @public @group Plugins
*/
export function createListPlugins({ schema }: { schema: Schema }): Plugin[] {
return [
createListEventPlugin(),
createListRenderingPlugin(),
createListClipboardPlugin(schema),
createSafariInputMethodWorkaroundPlugin(),
];
}
export {
createListEventPlugin,
createListClipboardPlugin,
createListRenderingPlugin,
createSafariInputMethodWorkaroundPlugin,
};

View File

@@ -0,0 +1,18 @@
import { Plugin } from "@tiptap/pm/state";
import { createListNodeView } from "../node-view";
/**
* Handle the list node rendering.
*
* @public @group Plugins
*/
export function createListRenderingPlugin(): Plugin {
return new Plugin({
props: {
nodeViews: {
list: createListNodeView,
},
},
});
}

View File

@@ -0,0 +1,14 @@
import { imeSpan } from "prosemirror-safari-ime-span";
import { type Plugin } from "@tiptap/pm/state";
/**
* Return a plugin as a workaround for a bug in Safari that causes the composition
* based IME to remove the empty HTML element with CSS `position: relative`.
*
* See also https://github.com/ProseMirror/prosemirror/issues/934
*
* @public @group Plugins
*/
export function createSafariInputMethodWorkaroundPlugin(): Plugin {
return imeSpan;
}

View File

@@ -0,0 +1,56 @@
import { type DOMOutputSpec, type NodeSpec } from "@tiptap/pm/model";
import { createParseDomRules } from "./parse-dom";
import { listToDOM } from "./to-dom";
/**
* The default group name for list nodes. This is used to find the list node
* type from the schema.
*
* @internal Schema
*/
export const flatListGroup = "flatList";
export interface ListSpecOptions {
content?: string;
listTypeName?: string;
group?: string;
}
/**
* Return the spec for list node.
*
* @public @group Schema
*/
export function createListSpec(options: ListSpecOptions = {}): NodeSpec {
const { content = "block+", listTypeName = "list", group = `${flatListGroup} block` } = options;
return {
// what content could be inside the block
content,
// what is the group (an entity specified by which someone could refer
// to flatLists if they want to allow it in their content) of the current flatList node
group,
// AI
definingForContent: true,
// when selecting and pasting some content over flat lists, do we need
// to replace the entire content or not
definingAsContext: false,
attrs: {
kind: {
default: "bullet",
},
order: {
default: null,
},
checked: {
default: false,
},
collapsed: {
default: false,
},
},
toDOM: (node): DOMOutputSpec => listToDOM({ node }),
parseDOM: createParseDomRules(),
};
}

View File

@@ -0,0 +1,120 @@
import { type TagParseRule } from "@tiptap/pm/model";
import { type ListAttributes, type ListKind } from "../types";
import { parseInteger } from "../utils/parse-integer";
/**
* Returns a set of rules for parsing HTML into ProseMirror list nodes.
*
* @public @group Schema
*/
export function createParseDomRules(): readonly TagParseRule[] {
return [
{
tag: "div[data-list-kind]",
getAttrs: (element): ListAttributes => {
if (typeof element === "string") {
return {};
}
return {
kind: (element.getAttribute("data-list-kind") || "bullet") as ListKind,
order: parseInteger(element.getAttribute("data-list-order")),
checked: element.hasAttribute("data-list-checked"),
collapsed: element.hasAttribute("data-list-collapsed"),
};
},
},
{
tag: "div[data-list]",
getAttrs: (element): ListAttributes => {
if (typeof element === "string") {
return {};
}
return {
kind: (element.getAttribute("data-list-kind") || "bullet") as ListKind,
order: parseInteger(element.getAttribute("data-list-order")),
checked: element.hasAttribute("data-list-checked"),
collapsed: element.hasAttribute("data-list-collapsed"),
};
},
},
{
tag: "ul > li",
getAttrs: (element): ListAttributes => {
if (typeof element !== "string") {
let checkbox = element.firstChild as HTMLElement | null;
for (let i = 0; i < 3 && checkbox; i++) {
if (["INPUT", "UL", "OL", "LI"].includes(checkbox.nodeName)) {
break;
}
checkbox = checkbox.firstChild as HTMLElement | null;
}
if (checkbox && checkbox.nodeName === "INPUT" && checkbox.getAttribute("type") === "checkbox") {
return {
kind: "task",
checked: checkbox.hasAttribute("checked"),
};
}
if (element.hasAttribute("data-task-list-item") || element.getAttribute("data-list-kind") === "task") {
return {
kind: "task",
checked: element.hasAttribute("data-list-checked") || element.hasAttribute("data-checked"),
};
}
if (element.hasAttribute("data-toggle-list-item") || element.getAttribute("data-list-kind") === "toggle") {
return {
kind: "toggle",
collapsed: element.hasAttribute("data-list-collapsed"),
};
}
if (element.firstChild?.nodeType === 3 /* document.TEXT_NODE */) {
const textContent = element.firstChild.textContent;
if (textContent && /^\[[\sx|]]\s{1,2}/.test(textContent)) {
element.firstChild.textContent = textContent.replace(/^\[[\sx|]]\s{1,2}/, "");
return {
kind: "task",
checked: textContent.startsWith("[x]"),
};
}
}
}
return {
kind: "bullet",
};
},
},
{
tag: "ol > li",
getAttrs: (element): ListAttributes => {
if (typeof element === "string") {
return {
kind: "ordered",
};
}
return {
kind: "ordered",
order: parseInteger(element.getAttribute("data-list-order")),
};
},
},
{
// This rule is for handling nested lists copied from Dropbox Paper. It's
// technically invalid HTML structure.
tag: ":is(ul, ol) > :is(ul, ol)",
getAttrs: (): ListAttributes => {
return {
kind: "bullet",
};
},
},
];
}

View File

@@ -0,0 +1,106 @@
import { type DOMOutputSpec, type Node as ProsemirrorNode } from "@tiptap/pm/model";
import { type ListAttributes } from "../types";
/** @public */
export interface ListToDOMOptions {
/**
* The list node to be rendered.
*/
node: ProsemirrorNode;
/**
* If `true`, the list will be rendered as a native `<ul>` or `<ol>` element.
* You might want to use {@link joinListElements} to join the list elements
* afterward.
*
* @defaultValue false
*/
nativeList?: boolean;
/**
* An optional function to get elements inside `<div class="list-marker">`.
* Return `null` to hide the marker.
*/
getMarkers?: (node: ProsemirrorNode) => DOMOutputSpec[] | null;
/**
* An optional function to get the attributes added to HTML element.
*/
getAttributes?: (node: ProsemirrorNode) => Record<string, string | undefined>;
}
/**
* Renders a list node to DOM output spec.
*
* @public @group Schema
*/
export function listToDOM({
node,
nativeList = false,
getMarkers = defaultMarkerGetter,
getAttributes = defaultAttributesGetter,
}: ListToDOMOptions): DOMOutputSpec {
const attrs = node.attrs as ListAttributes;
const markerHidden = node.firstChild?.type === node.type;
const markers = markerHidden ? null : getMarkers(node);
const domAttrs = getAttributes(node);
const contentContainer: DOMOutputSpec = ["div", { class: "list-content" }, 0];
const markerContainer: DOMOutputSpec | null = markers && [
"div",
{
class: "list-marker list-marker-click-target",
// Set `contenteditable` to `false` so that the cursor won't be
// moved into the mark container when clicking on it.
contenteditable: "false",
},
...markers,
];
if (nativeList) {
const listTag = attrs.kind === "ordered" ? "ol" : "ul";
if (markerContainer) {
return [listTag, ["li", domAttrs, markerContainer, contentContainer]];
} else {
return [listTag, ["li", domAttrs, 0]];
}
} else {
if (markerContainer) {
return ["div", domAttrs, markerContainer, contentContainer];
} else {
return ["div", domAttrs, contentContainer];
}
}
}
/** @internal */
export function defaultMarkerGetter(node: ProsemirrorNode): DOMOutputSpec[] | null {
const attrs = node.attrs as ListAttributes;
switch (attrs.kind) {
case "task":
// Use a `label` element here so that the area around the checkbox is also checkable.
return [["label", ["input", { type: "checkbox", checked: attrs.checked ? "" : undefined }]]];
case "toggle":
return [];
default:
return null;
}
}
/** @internal */
export function defaultAttributesGetter(node: ProsemirrorNode) {
const attrs = node.attrs as ListAttributes;
const markerHidden = node.firstChild?.type === node.type;
const markerType = markerHidden ? undefined : attrs.kind || "bullet";
const domAttrs = {
class: "prosemirror-flat-list",
"data-list-kind": markerType,
"data-list-order": attrs.order != null ? String(attrs.order) : undefined,
"data-list-checked": attrs.checked ? "" : undefined,
"data-list-collapsed": attrs.collapsed ? "" : undefined,
"data-list-collapsable": node.childCount >= 2 ? "" : undefined,
style: attrs.order != null ? `--prosemirror-flat-list-order: ${attrs.order};` : undefined,
};
return domAttrs;
}

View File

@@ -0,0 +1,106 @@
.prosemirror-flat-list {
& {
padding: 0;
margin-top: 0;
margin-bottom: 0;
margin-left: 32px;
margin-bottom: 0;
position: relative;
display: list-item;
list-style: none;
}
&[data-list-kind="bullet"] {
list-style: disc;
}
&[data-list-kind="ordered"] {
/*
Ensure that the counters in children don't escape, so that the sub lists
won't affect the counter of the parent list.
See also https://github.com/ocavue/prosemirror-flat-list/issues/23
*/
& > * {
contain: style;
}
&::before {
position: absolute;
right: 100%;
font-variant-numeric: tabular-nums;
content: counter(prosemirror-flat-list-counter, decimal) ". ";
}
counter-increment: prosemirror-flat-list-counter;
/*
Reset the counter for the first list node in the sequence.
*/
&:first-child,
:not(&) + & {
counter-reset: prosemirror-flat-list-counter;
/*
If the first list node has a custom order number, set the counter to that value.
*/
&[data-list-order] {
@supports (counter-set: prosemirror-flat-list-counter 1) {
counter-set: prosemirror-flat-list-counter var(--prosemirror-flat-list-order);
}
/*
Safari older than version 17.2 doesn't support `counter-set`
*/
@supports not (counter-set: prosemirror-flat-list-counter 1) {
counter-increment: prosemirror-flat-list-counter var(--prosemirror-flat-list-order);
}
}
}
}
&[data-list-kind="task"] {
& > .list-marker {
position: absolute;
right: 100%;
text-align: center;
width: 1.5em;
width: 1lh;
&,
& * {
cursor: pointer;
}
}
}
&[data-list-kind="toggle"] {
& > .list-marker {
position: absolute;
right: 100%;
text-align: center;
width: 1.5em;
width: 1lh;
}
& > .list-marker::before {
content: "\23F7"; /* Black Medium Down-Pointing Triangle */
}
&[data-list-collapsable][data-list-collapsed] > .list-marker::before {
content: "\23F5"; /* Black Medium Right-Pointing Triangle */
}
&[data-list-collapsable] > .list-marker {
cursor: pointer;
}
&:not([data-list-collapsable]) > .list-marker {
opacity: 40%;
pointer-events: none;
}
/* If collapsed, hide the second and futher children */
&[data-list-collapsable][data-list-collapsed] > .list-content > *:nth-child(n + 2) {
display: none;
}
}
}

View File

@@ -0,0 +1,27 @@
import type { Attrs, Node } from "@tiptap/pm/model";
/**
* All default list node kinds.
*
* @public @group Schema
*/
export type ListKind = "bullet" | "ordered" | "task" | "toggle";
/** @public */
export interface ListAttributes {
kind?: string;
order?: number | null;
checked?: boolean;
collapsed?: boolean;
}
/** @public */
export interface ProsemirrorNodeJSON {
type: string;
marks?: Array<{ type: string; attrs?: Attrs } | string>;
text?: string;
content?: ProsemirrorNodeJSON[];
attrs?: Attrs;
}
export type { Node as ProsemirrorNode };

View File

@@ -0,0 +1,11 @@
import { type ResolvedPos } from "@tiptap/pm/model";
import { type EditorState, type TextSelection } from "@tiptap/pm/state";
import { type EditorView } from "@tiptap/pm/view";
// Copied from https://github.com/prosemirror/prosemirror-commands/blob/1.5.0/src/commands.ts#L157
export function atTextblockEnd(state: EditorState, view?: EditorView): ResolvedPos | null {
const { $cursor } = state.selection as TextSelection;
if (!$cursor || (view ? !view.endOfTextblock("forward", state) : $cursor.parentOffset < $cursor.parent.content.size))
return null;
return $cursor;
}

View File

@@ -0,0 +1,10 @@
import { type ResolvedPos } from "@tiptap/pm/model";
import { type EditorState, type TextSelection } from "@tiptap/pm/state";
import { type EditorView } from "@tiptap/pm/view";
// Copied from https://github.com/prosemirror/prosemirror-commands/blob/1.5.0/src/commands.ts#L15
export function atTextblockStart(state: EditorState, view?: EditorView): ResolvedPos | null {
const { $cursor } = state.selection as TextSelection;
if (!$cursor || (view ? !view.endOfTextblock("backward", state) : $cursor.parentOffset > 0)) return null;
return $cursor;
}

View File

@@ -0,0 +1,104 @@
import { type Transaction } from "@tiptap/pm/state";
import { canJoin, canSplit } from "@tiptap/pm/transform";
import { type ProsemirrorNode } from "../types";
import { isListNode } from "./is-list-node";
import { patchCommand } from "./patch-command";
/** @internal */
export function* getTransactionRanges(tr: Transaction): Generator<number[], never> {
const ranges: number[] = [];
let i = 0;
while (true) {
for (; i < tr.mapping.maps.length; i++) {
const map = tr.mapping.maps[i];
for (let j = 0; j < ranges.length; j++) {
ranges[j] = map.map(ranges[j]);
}
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => ranges.push(newStart, newEnd));
}
yield ranges;
}
}
/** @internal */
export function findBoundaries(
positions: number[],
doc: ProsemirrorNode,
prediction: (before: ProsemirrorNode, after: ProsemirrorNode, parent: ProsemirrorNode, index: number) => boolean
): number[] {
const boundaries = new Set<number>();
const joinable: number[] = [];
for (const pos of positions) {
const $pos = doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
const boundary = $pos.before(depth + 1);
if (boundaries.has(boundary)) {
break;
}
boundaries.add(boundary);
const index = $pos.index(depth);
const parent = $pos.node(depth);
const before = parent.maybeChild(index - 1);
if (!before) continue;
const after = parent.maybeChild(index);
if (!after) continue;
if (prediction(before, after, parent, index)) {
joinable.push(boundary);
}
}
}
// Sort in the descending order
return joinable.sort((a, b) => b - a);
}
function isListJoinable(before: ProsemirrorNode, after: ProsemirrorNode): boolean {
return isListNode(before) && isListNode(after) && isListNode(after.firstChild);
}
function isListSplitable(
before: ProsemirrorNode,
after: ProsemirrorNode,
parent: ProsemirrorNode,
index: number
): boolean {
if (index === 1 && isListNode(parent) && isListNode(before) && !isListNode(after)) {
return true;
}
return false;
}
function fixList(tr: Transaction): Transaction {
const ranges = getTransactionRanges(tr);
const joinable = findBoundaries(ranges.next().value, tr.doc, isListJoinable);
for (const pos of joinable) {
if (canJoin(tr.doc, pos)) {
tr.join(pos);
}
}
const splitable = findBoundaries(ranges.next().value, tr.doc, isListSplitable);
for (const pos of splitable) {
if (canSplit(tr.doc, pos)) {
tr.split(pos);
}
}
return tr;
}
/** @internal */
export const withAutoFixList = patchCommand(fixList);

View File

@@ -0,0 +1,29 @@
import { type ResolvedPos } from "@tiptap/pm/model";
export function atStartBlockBoundary($pos: ResolvedPos, depth: number): boolean {
for (let d = depth; d <= $pos.depth; d++) {
if ($pos.node(d).isTextblock) {
continue;
}
const index = $pos.index(d);
if (index !== 0) {
return false;
}
}
return true;
}
export function atEndBlockBoundary($pos: ResolvedPos, depth: number): boolean {
for (let d = depth; d <= $pos.depth; d++) {
if ($pos.node(d).isTextblock) {
continue;
}
const index = $pos.index(d);
if (index !== $pos.node(d).childCount - 1) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,12 @@
// Copied from https://github.com/prosemirror/prosemirror-view/blob/1.30.1/src/browser.ts
const nav = typeof navigator != "undefined" ? navigator : null;
const agent = (nav && nav.userAgent) || "";
const ie_edge = /Edge\/(\d+)/.exec(agent);
const ie_upto10 = /MSIE \d/.exec(agent);
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent);
const ie = !!(ie_upto10 || ie_11up || ie_edge);
export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor);

View File

@@ -0,0 +1,15 @@
import { type Attrs, type Fragment, type Mark, type Node as ProsemirrorNode, type NodeType } from "@tiptap/pm/model";
export function createAndFill(
type: NodeType,
attrs?: Attrs | null,
content?: Fragment | ProsemirrorNode | readonly ProsemirrorNode[] | null,
marks?: readonly Mark[]
) {
const node = type.createAndFill(attrs, content, marks);
if (!node) {
throw new RangeError(`Failed to create '${type.name}' node`);
}
node.check();
return node;
}

View File

@@ -0,0 +1,6 @@
import { type Fragment } from "@tiptap/pm/model";
export function cutByIndex(fragment: Fragment, from: number, to: number): Fragment {
// @ts-expect-error fragment.cutByIndex is internal API
return fragment.cutByIndex(from, to);
}

View File

@@ -0,0 +1,27 @@
import { type NodeType, type Schema } from "@tiptap/pm/model";
import { flatListGroup } from "../schema/node-spec";
/** @internal */
export function getListType(schema: Schema, listTypeName: string = "list"): NodeType {
const cacheKey = `PROSEMIRROR_FLAT_LIST_${listTypeName.toUpperCase()}_TYPE_NAME`;
let name: string = schema.cached["PROSEMIRROR_FLAT_LIST_LIST_TYPE_NAME"];
if (!name) {
for (const type of Object.values(schema.nodes)) {
if ((type.spec.group || "").split(" ").includes(flatListGroup)) {
name = type.name;
break;
}
}
if (!name) {
throw new TypeError("[prosemirror-flat-list] Unable to find a flat list type in the schema");
}
schema.cached["PROSEMIRROR_FLAT_LIST_LIST_TYPE_NAME"] = name;
}
return schema.nodes[name];
}

View File

@@ -0,0 +1,18 @@
import { type ResolvedPos } from "@tiptap/pm/model";
import { type ListAttributes } from "../types";
import { isListNode } from "./is-list-node";
export function inCollapsedList($pos: ResolvedPos): boolean {
for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth);
if (isListNode(node)) {
const attrs = node.attrs as ListAttributes;
if (attrs.collapsed) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,7 @@
import { type NodeSelection, type Selection } from "@tiptap/pm/state";
import { isNodeSelection } from "./is-node-selection";
export function isBlockNodeSelection(selection: Selection): selection is NodeSelection {
return isNodeSelection(selection) && selection.node.type.isBlock;
}

View File

@@ -0,0 +1,10 @@
import { type ListAttributes, type ProsemirrorNode } from "../types";
import { isListNode } from "./is-list-node";
/**
* @internal
*/
export function isCollapsedListNode(node: ProsemirrorNode): boolean {
return !!(isListNode(node) && (node.attrs as ListAttributes).collapsed);
}

View File

@@ -0,0 +1,9 @@
import { type Node as ProsemirrorNode } from "@tiptap/pm/model";
import { isListType } from "./is-list-type";
/** @public */
export function isListNode(node: ProsemirrorNode | null | undefined): boolean {
if (!node) return false;
return isListType(node.type);
}

View File

@@ -0,0 +1,8 @@
import { type NodeType } from "@tiptap/pm/model";
import { getListType } from "./get-list-type";
/** @public */
export function isListType(type: NodeType): boolean {
return getListType(type.schema) === type;
}

View File

@@ -0,0 +1,5 @@
import { type NodeSelection, type Selection } from "@tiptap/pm/state";
export function isNodeSelection(selection: Selection): selection is NodeSelection {
return Boolean((selection as NodeSelection).node);
}

View File

@@ -0,0 +1,5 @@
import { TextSelection } from "@tiptap/pm/state";
export function isTextSelection(value?: unknown): value is TextSelection {
return Boolean(value && value instanceof TextSelection);
}

View File

@@ -0,0 +1,44 @@
import { NodeRange, type ResolvedPos } from "@tiptap/pm/model";
import { isListNode } from "./is-list-node";
/**
* Returns a minimal block range that includes the given two positions and
* represents one or multiple sibling list nodes.
*
* @public
*/
export function findListsRange($from: ResolvedPos, $to: ResolvedPos = $from): NodeRange | null {
if ($to.pos < $from.pos) {
return findListsRange($to, $from);
}
let range = $from.blockRange($to);
while (range) {
if (isListsRange(range)) {
return range;
}
if (range.depth <= 0) {
break;
}
range = new NodeRange($from, $to, range.depth - 1);
}
return null;
}
/** @internal */
export function isListsRange(range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range;
for (let i = startIndex; i < endIndex; i++) {
if (!isListNode(parent.child(i))) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,69 @@
import {
type DOMOutputSpec,
DOMSerializer,
type Fragment,
type Node as ProsemirrorNode,
type Schema,
} from "@tiptap/pm/model";
import { listToDOM } from "../schema/to-dom";
/**
* A custom DOM serializer class that can serialize flat list nodes into native
* HTML list elements (i.e. `<ul>` and `<ol>`).
*
* @public @group Plugins
*/
export class ListDOMSerializer extends DOMSerializer {
static nodesFromSchema(schema: Schema): {
[node: string]: (node: ProsemirrorNode) => DOMOutputSpec;
} {
const nodes = DOMSerializer.nodesFromSchema(schema);
return {
...nodes,
list: (node) => listToDOM({ node, nativeList: true, getMarkers: () => null }),
};
}
static fromSchema(schema: Schema): ListDOMSerializer {
return (
(schema.cached.listDomSerializer as ListDOMSerializer) ||
(schema.cached.listDomSerializer = new ListDOMSerializer(
this.nodesFromSchema(schema),
this.marksFromSchema(schema)
))
);
}
serializeFragment(
fragment: Fragment,
options?: { document?: Document },
target?: HTMLElement | DocumentFragment
): HTMLElement | DocumentFragment {
const dom = super.serializeFragment(fragment, options, target);
return joinListElements(dom);
}
}
/**
* Merge adjacent <ul> elements or adjacent <ol> elements into a single list element.
*
* @public
*/
export function joinListElements<T extends Element | DocumentFragment>(parent: T): T {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.children.item(i);
if (!child) continue;
if (child.tagName === "UL" || child.tagName === "OL") {
let next: Element | null = null;
while (((next = child.nextElementSibling), next?.tagName === child.tagName)) {
child.append(...Array.from(next.children));
next.remove();
}
}
joinListElements(child);
}
return parent;
}

View File

@@ -0,0 +1,16 @@
import { type Transaction } from "@tiptap/pm/state";
export function mapPos(tr: Transaction, pos: number) {
let nextStepIndex = tr.steps.length;
const getPos = (): number => {
if (nextStepIndex < tr.steps.length) {
const mapping = tr.mapping.slice(nextStepIndex);
nextStepIndex = tr.steps.length;
pos = mapping.map(pos);
}
return pos;
};
return getPos;
}

View File

@@ -0,0 +1,33 @@
import { type Fragment, type Node as ProsemirrorNode } from "@tiptap/pm/model"
// Copy from https://github.com/prosemirror/prosemirror-model/blob/1.19.0/src/replace.ts#L88-L95
export function maxOpenStart(
fragment: Fragment | ProsemirrorNode,
openIsolating = true,
) {
let openStart = 0
for (
let n = fragment.firstChild;
n && !n.isLeaf && (openIsolating || !n.type.spec.isolating);
n = n.firstChild
) {
openStart++
}
return openStart
}
// Copy from https://github.com/prosemirror/prosemirror-model/blob/1.19.0/src/replace.ts#L88-L95
export function maxOpenEnd(
fragment: Fragment | ProsemirrorNode,
openIsolating = true,
) {
let openEnd = 0
for (
let n = fragment.lastChild;
n && !n.isLeaf && (openIsolating || !n.type.spec.isolating);
n = n.lastChild
) {
openEnd++
}
return openEnd
}

View File

@@ -0,0 +1,7 @@
/** @internal */
export function parseInteger(attr: string | null | undefined): number | null {
if (attr == null) return null;
const int = Number.parseInt(attr, 10);
if (Number.isInteger(int)) return int;
return null;
}

View File

@@ -0,0 +1,11 @@
import { type Command, type Transaction } from "@tiptap/pm/state";
export function patchCommand(patch: (tr: Transaction) => Transaction) {
const withPatch = (command: Command): Command => {
const patchedCommand: Command = (state, dispatch, view) =>
command(state, dispatch ? (tr: Transaction) => dispatch(patch(tr)) : undefined, view);
return patchedCommand;
};
return withPatch;
}

View File

@@ -0,0 +1,13 @@
import { type NodeRange } from "@tiptap/pm/model";
import { cutByIndex } from "./cut-by-index";
/**
* Return a debugging string that describes this range.
*
* @internal
*/
export function rangeToString(range: NodeRange): string {
const { parent, startIndex, endIndex } = range;
return cutByIndex(parent.content, startIndex, endIndex).toString();
}

View File

@@ -0,0 +1,20 @@
import { type NodeRange } from "@tiptap/pm/model";
import { type Transaction } from "@tiptap/pm/state";
import { liftTarget } from "@tiptap/pm/transform";
export function safeLift(tr: Transaction, range: NodeRange): boolean {
const target = liftTarget(range);
if (target == null) {
return false;
}
tr.lift(range, target);
return true;
}
export function safeLiftFromTo(tr: Transaction, from: number, to: number): boolean {
const $from = tr.doc.resolve(from);
const $to = tr.doc.resolve(to);
const range = $from.blockRange($to);
if (!range) return false;
return safeLift(tr, range);
}

View File

@@ -0,0 +1,22 @@
import { type Transaction } from "@tiptap/pm/state";
import { type ListAttributes } from "../types";
import { isListNode } from "./is-list-node";
import { setNodeAttributes } from "./set-node-attributes";
export function setListAttributes<T extends ListAttributes = ListAttributes>(
tr: Transaction,
pos: number,
attrs: T
): boolean {
const $pos = tr.doc.resolve(pos);
const node = $pos.nodeAfter;
if (node && isListNode(node)) {
const oldAttrs: T = node.attrs as T;
const newAttrs: T = { ...oldAttrs, ...attrs };
return setNodeAttributes(tr, pos, oldAttrs, newAttrs);
}
return false;
}

View File

@@ -0,0 +1,13 @@
import { type Attrs } from "@tiptap/pm/model";
import { type Transaction } from "@tiptap/pm/state";
export function setNodeAttributes(tr: Transaction, pos: number, oldAttrs: Attrs, newAttrs: Attrs): boolean {
let needUpdate = false;
for (const key of Object.keys(newAttrs)) {
if (newAttrs[key] !== oldAttrs[key]) {
tr.setNodeAttribute(pos, key, newAttrs[key]);
needUpdate = true;
}
}
return needUpdate;
}

View File

@@ -0,0 +1,34 @@
import { type Transaction } from "@tiptap/pm/state";
/**
* Split the node at the given position, and optionally, if `depth` is greater
* than one, any number of nodes above that. Unlike `tr.split`, this function
* will skip if the position is already at the boundary of a node. This will
* avoid creating empty nodes during the split.
*/
export function splitBoundary(tr: Transaction, pos: number, depth = 1): void {
if (depth <= 0) return;
const $pos = tr.doc.resolve(pos);
const parent = $pos.node();
if (parent.isTextblock) {
const parentOffset = $pos.parentOffset;
if (parentOffset == 0) {
return splitBoundary(tr, pos - 1, depth - 1);
} else if (parentOffset >= parent.content.size) {
return splitBoundary(tr, pos + 1, depth - 1);
} else {
tr.split(pos, depth);
}
} else {
const index = $pos.index($pos.depth);
if (index === 0) {
return splitBoundary(tr, pos - 1, depth - 1);
} else if (index === $pos.node().childCount) {
return splitBoundary(tr, pos + 1, depth - 1);
} else {
tr.split(pos, depth);
}
}
}

View File

@@ -0,0 +1,22 @@
import { Slice } from "@tiptap/pm/model";
import { isListNode } from "./is-list-node";
/**
* Reduce the open depth of a slice if it only contains a single list node. When
* copying some text from a deep nested list node, we don't want to paste the
* entire list structure into the document later.
*
* @internal
*/
export function unwrapListSlice(slice: Slice): Slice {
while (
slice.openStart >= 2 &&
slice.openEnd >= 2 &&
slice.content.childCount === 1 &&
isListNode(slice.content.child(0))
) {
slice = new Slice(slice.content.child(0).content, slice.openStart - 1, slice.openEnd - 1);
}
return slice;
}

View File

@@ -0,0 +1,19 @@
import { type NodeRange } from "@tiptap/pm/model";
/**
* Returns a deeper block range if possible
*/
export function zoomInRange(range: NodeRange): NodeRange | null {
const { $from, $to, depth, start, end } = range;
const doc = $from.doc;
const deeper = ($from.pos > start ? $from : doc.resolve(start + 1)).blockRange(
$to.pos < end ? $to : doc.resolve(end - 1)
);
if (deeper && deeper.depth > depth) {
return deeper;
}
return null;
}

View File

@@ -0,0 +1,153 @@
import { Node } from "@tiptap/core";
import { keymap } from "@tiptap/pm/keymap";
import { inputRules } from "@tiptap/pm/inputrules";
import {
ListAttributes,
IndentListOptions,
DedentListOptions,
createListSpec,
listKeymap,
listInputRules,
createWrapInListCommand,
createIndentListCommand,
createDedentListCommand,
createSplitListCommand,
enterWithoutLift,
createListPlugins,
} from "./core";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
flatHeadingListComponent: {
createList: (attrs: ListAttributes) => ReturnType;
indentList: (attrs: IndentListOptions) => ReturnType;
dedentList: (attrs: DedentListOptions) => ReturnType;
splitList: () => ReturnType;
createHeadedList: (attrs: ListAttributes & { title: string }) => ReturnType;
};
}
}
const { attrs, parseDOM, toDOM, group, definingForContent, definingAsContext } = createListSpec();
const listKeymapPlugin = keymap(listKeymap);
const listInputRulePlugin = inputRules({ rules: listInputRules });
export const FlatHeadingListExtension = Node.create({
name: "headingList",
content: "heading block*",
group,
definingForContent,
definingAsContext,
addAttributes() {
return attrs;
},
parseHTML() {
return parseDOM;
},
renderHTML({ node }) {
return toDOM(node);
},
addCommands() {
return {
createList:
(attrs: ListAttributes) =>
({ state, view }) => {
const wrapInList = createWrapInListCommand<ListAttributes>(attrs);
return wrapInList(state, view.dispatch);
},
indentList:
(attrs: IndentListOptions) =>
({ state, view }) => {
const indentList = createIndentListCommand(attrs);
return indentList(state, view.dispatch);
},
dedentList:
(attrs: DedentListOptions) =>
({ state, view }) => {
const dedentList = createDedentListCommand(attrs);
return dedentList(state, view.dispatch);
},
splitList:
() =>
({ state, view }) => {
const splitList = createSplitListCommand();
return splitList(state, view.dispatch);
},
createHeadedList:
(attrs: ListAttributes & { title: string }) =>
({ state, chain, commands }) => {
try {
chain()
.focus()
.setHeading({ level: 1 })
.setTextSelection(state.selection.from - 1)
.run();
return commands.createList({
kind: attrs.kind || "bullet",
order: attrs.order,
checked: attrs.checked,
collapsed: attrs.collapsed,
});
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
};
},
addKeyboardShortcuts(this) {
return {
Tab: ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
editor.chain().focus().indentList({ from: $from.pos });
return true;
}
return false;
},
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
editor.chain().focus().dedentList({ from: $from.pos });
return true;
}
return false;
},
Enter: ({ editor }) => {
if (editor.isActive(this.name)) {
editor.chain().focus().splitList();
return true;
}
return false;
},
"Shift-Enter": ({ editor }) => {
if (editor.isActive(this.name)) {
return enterWithoutLift(editor.state, editor.view.dispatch);
}
return false;
},
"Mod-Shift-7": ({ editor }) => {
try {
return editor.commands.createHeadedList({ title: "a", kind: "bullet" });
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
"Mod-Shift-8": ({ editor }) => {
try {
return editor.commands.createHeadedList({ title: "a", kind: "ordered" });
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
};
},
addProseMirrorPlugins() {
return [...createListPlugins({ schema: this.editor.schema }), listKeymapPlugin, listInputRulePlugin];
},
});

View File

@@ -0,0 +1 @@
export * from "./list-extension";

View File

@@ -0,0 +1,151 @@
import { Node } from "@tiptap/core";
import { inputRules } from "@tiptap/pm/inputrules";
import { keymap } from "@tiptap/pm/keymap";
import { ResolvedPos } from "@tiptap/pm/model";
import {
ListAttributes,
IndentListOptions,
DedentListOptions,
createListSpec,
listKeymap,
listInputRules,
createWrapInListCommand,
createIndentListCommand,
createDedentListCommand,
createSplitListCommand,
createListPlugins,
} from "./core";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
flatListComponent: {
createList: (attrs: ListAttributes) => ReturnType;
indentList: (attrs: IndentListOptions) => ReturnType;
dedentList: (attrs: DedentListOptions) => ReturnType;
splitList: () => ReturnType;
};
}
}
const { attrs, parseDOM, toDOM, content, group } = createListSpec();
const listKeymapPlugin = keymap(listKeymap);
const listInputRulePlugin = inputRules({ rules: listInputRules });
export const FlatListExtension = Node.create({
name: "list",
content,
group,
defining: true,
selectable: true,
draggable: true,
addAttributes() {
return attrs || {};
},
parseHTML() {
return parseDOM;
},
renderHTML({ node }) {
return toDOM?.(node) || ["div", 0];
},
addCommands() {
return {
createList:
(attrs: ListAttributes) =>
({ state, view }) => {
const wrapInList = createWrapInListCommand<ListAttributes>(attrs);
return wrapInList(state, view.dispatch);
},
indentList:
(attrs: IndentListOptions) =>
({ state, view }) => {
const indentList = createIndentListCommand(attrs);
return indentList(state, view.dispatch);
},
dedentList:
(attrs: DedentListOptions) =>
({ state, view }) => {
const dedentList = createDedentListCommand(attrs);
return dedentList(state, view.dispatch);
},
splitList:
() =>
({ state, view }) => {
const splitList = createSplitListCommand();
return splitList(state, view.dispatch);
},
};
},
addKeyboardShortcuts(this) {
return {
Tab: ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
const isBetweenListNodes = isBetweenLists($from);
if (editor.isActive(this.name) || isBetweenListNodes) {
const indentList = createIndentListCommand({ from: $from.pos });
return indentList(editor.state, editor.view.dispatch);
}
return false;
},
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
console.log("shift tab");
const dedentList = createDedentListCommand({ from: $from.pos });
return dedentList(editor.state, editor.view.dispatch);
}
return false;
},
Enter: ({ editor }) => {
if (editor.isActive(this.name)) {
const splitList = createSplitListCommand();
const ans = splitList(editor.state, editor.view.dispatch);
return ans;
}
return false;
},
};
},
addProseMirrorPlugins() {
return [
...createListPlugins({
schema: this.editor.schema,
}),
listKeymapPlugin,
listInputRulePlugin,
];
},
});
function isBetweenLists($pos: ResolvedPos): boolean {
let foundBefore = false;
let foundAfter = false;
// Single loop to check both directions
for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth);
const index = $pos.index(depth);
// Check previous sibling if not found yet
if (!foundBefore && index > 0) {
foundBefore = node.child(index - 1).type.name === "list";
}
// Check next sibling if not found yet
if (!foundAfter && index < node.childCount - 1) {
foundAfter = node.child(index + 1).type.name === "list";
}
// Early exit if both conditions are met
if (foundBefore && foundAfter) {
return true;
}
}
return false;
}

View File

@@ -4,7 +4,7 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
export type ImageExtensionStorage = {
@@ -47,7 +47,9 @@ export const ImageExtension = (props: Props) => {
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
return ReactNodeViewRenderer((props) => (
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
));
},
});
};

View File

@@ -2,7 +2,6 @@ export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./image";
export * from "./mentions";
export * from "./slash-commands";
@@ -20,4 +19,6 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./flat-list";
export * from "./utility";
export * from "./drop-cursor";

View File

@@ -82,28 +82,28 @@ export const CustomKeymap = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("ordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
const joinableNodes = [
newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST],
newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST],
newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST],
];
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
// new Plugin({
// key: new PluginKey("ordered-list-merging"),
// appendTransaction(transactions, oldState, newState) {
// // Create a new transaction.
// const newTr = newState.tr;
//
// const joinableNodes = [
// newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST],
// newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST],
// newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST],
// ];
//
// let joined = false;
// for (const transaction of transactions) {
// const anotherJoin = autoJoin(transaction, newTr, joinableNodes);
// joined = anotherJoin || joined;
// }
// if (joined) {
// return newTr;
// }
// },
// }),
];
},
addKeyboardShortcuts() {

View File

@@ -1,11 +1,14 @@
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as NodeType } from "@tiptap/pm/model";
import { ReactNodeViewRenderer } from "@tiptap/react";
// types
import { TMentionHandler } from "@/types";
// extension config
import { CustomMentionExtensionConfig } from "./extension-config";
// node view
import { MentionNodeView } from "./mention-node-view";
import { MentionNodeView, MentionNodeViewProps } from "./mention-node-view";
// utils
import { EMentionComponentAttributeNames } from "./types";
import { renderMentionsDropdown } from "./utils";
export const CustomMentionExtension = (props: TMentionHandler) => {
@@ -20,7 +23,21 @@ export const CustomMentionExtension = (props: TMentionHandler) => {
},
addNodeView() {
return ReactNodeViewRenderer(MentionNodeView);
return ReactNodeViewRenderer((props) => (
<MentionNodeView {...props} node={props.node as MentionNodeViewProps["node"]} />
));
},
// @ts-expect-error - TODO: fix this
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
const label = node.attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention";
state.write(`@${label}`);
},
},
};
},
}).configure({
suggestion: {

View File

@@ -4,17 +4,18 @@ import { TMentionExtensionOptions } from "./extension-config";
// extension types
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
type Props = NodeViewProps & {
export type MentionNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TMentionComponentAttributes;
};
};
export const MentionNodeView = (props: Props) => {
export const MentionNodeView: React.FC<MentionNodeViewProps> = (props) => {
const {
extension,
node: { attrs },
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
{(extension.options as TMentionExtensionOptions).renderComponent({

View File

@@ -1,7 +1,10 @@
import { Extensions } from "@tiptap/core";
// import BulletList from "@tiptap/extension-bullet-list";
import CharacterCount from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
// import ListItem from "@tiptap/extension-list-item";
// import OrderedList from "@tiptap/extension-ordered-list";
// import TaskItem from "@tiptap/extension-task-item";
// import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
@@ -22,6 +25,7 @@ import {
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
FlatListExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
@@ -41,21 +45,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const extensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
bulletList: false,
orderedList: false,
listItem: false,
code: false,
codeBlock: false,
horizontalRule: false,
@@ -73,6 +65,71 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
dropcursor: false,
gapcursor: false,
}),
FlatListExtension,
// BulletList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-disc pl-7 space-y-2",
// },
// }),
// OrderedList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "list-decimal pl-7 space-y-2",
// },
// }),
// ListItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose space-y-2",
// },
// }),
// TaskList.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// }).configure({
// HTMLAttributes: {
// class: "not-prose pl-2 space-y-2",
// },
// }),
// TaskItem.extend({
// parseHTML() {
// return [];
// },
// addInputRules() {
// return [];
// },
// addKeyboardShortcuts() {
// return {};
// },
// }).configure({
// HTMLAttributes: {
// class: "relative",
// },
// nested: true,
// }),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
@@ -93,17 +150,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomTypographyExtension,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative pointer-events-none",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
@@ -146,6 +192,5 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};

View File

@@ -124,11 +124,18 @@ const SideMenu = (options: SideMenuPluginProps) => {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
}
if (node.classList.contains("prosemirror-flat-list")) {
rect.left -= 5;
rect.top += 6;
}
} else {
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 18;
}
if (node.classList.contains("prosemirror-flat-list")) {
rect.left -= 18;
}
}
if (node.matches(".table-wrapper")) {

View File

@@ -32,6 +32,10 @@ import {
insertImage,
insertCallout,
setText,
toggleFlatTaskList,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatToggleList,
} from "@/helpers/editor-commands";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
@@ -124,7 +128,7 @@ export const getSlashCommandFilteredSections =
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <ListTodo className="size-3.5" />,
command: ({ editor, range }) => toggleTaskList(editor, range),
command: ({ editor, range }) => toggleFlatTaskList(editor, range),
},
{
commandKey: "bulleted-list",
@@ -133,7 +137,16 @@ export const getSlashCommandFilteredSections =
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List className="size-3.5" />,
command: ({ editor, range }) => toggleBulletList(editor, range),
command: ({ editor, range }) => toggleFlatBulletList(editor, range),
},
{
commandKey: "toggle-list",
key: "toggle-list",
title: "Toggle list",
description: "Create a toggle list.",
searchTerms: ["toggle"],
icon: <ListOrdered className="size-3.5" />,
command: ({ editor, range }) => toggleFlatToggleList(editor, range),
},
{
commandKey: "numbered-list",
@@ -142,7 +155,7 @@ export const getSlashCommandFilteredSections =
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered className="size-3.5" />,
command: ({ editor, range }) => toggleOrderedList(editor, range),
command: ({ editor, range }) => toggleFlatOrderedList(editor, range),
},
{
commandKey: "table",

View File

@@ -40,10 +40,6 @@ const Command = Extension.create<SlashCommandOptions>({
return false;
}
if (editor.isActive(CORE_EXTENSIONS.TABLE)) {
return false;
}
return true;
},
},

View File

@@ -113,6 +113,7 @@ export const Table = Node.create({
return ["table", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ["tbody", 0]];
},
//@ts-expect-error todo
addCommands() {
return {
insertTable:
@@ -120,14 +121,28 @@ export const Table = Node.create({
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
if (dispatch) {
const offset = tr.selection.anchor + 1;
const { selection } = tr;
const position = selection.$from.before(selection.$from.depth);
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
// Delete any existing content at the current position if it's an empty paragraph
const nodeAfter = tr.doc.nodeAt(position);
if (nodeAfter && nodeAfter.type.name === "paragraph" && nodeAfter.content.size === 0) {
tr.delete(position, position + nodeAfter.nodeSize);
}
// Insert the table
tr.insert(position, node);
// Find the position of the first cell's content
const resolvedPos = tr.doc.resolve(position + 1);
const firstCell = resolvedPos.nodeAfter;
if (firstCell) {
const cellPos = position + 1;
tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)).scrollIntoView();
}
return true;
}
return true;
},
addColumnBefore:
() =>
@@ -238,7 +253,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("list")) {
return false;
}
}
if (this.editor.commands.goToPreviousCell()) {
return true;
}
return false;
},
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,

View File

@@ -6,7 +6,7 @@ import { findParentNodeOfType } from "@/helpers/common";
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
if (!editor.isActive(CORE_EXTENSIONS.TABLE) || editor.isActive(CORE_EXTENSIONS.LIST)) return false;
try {
// Get the current selection

View File

@@ -6,7 +6,7 @@ import { findParentNodeOfType } from "@/helpers/common";
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
if (!editor.isActive(CORE_EXTENSIONS.TABLE) || editor.isActive(CORE_EXTENSIONS.LIST)) return false;
try {
// Get the current selection

View File

@@ -82,9 +82,9 @@ export const CustomTypographyExtension = Extension.create<TypographyOptions>({
rules.push(laquo(this.options.laquo));
}
if (this.options.raquo !== false) {
rules.push(raquo(this.options.raquo));
}
// if (this.options.raquo !== false) {
// rules.push(raquo(this.options.raquo));
// }
if (this.options.multiplication !== false) {
rules.push(multiplication(this.options.multiplication));

View File

@@ -14,24 +14,55 @@ export const setText = (editor: Editor, range?: Range) => {
export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleBold().run();
};
export const toggleItalic = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleItalic().run();
};
export const toggleFlatOrderedList = (editor: Editor, range?: Range) => {
if (range)
editor.chain().focus().deleteRange(range).createList({
kind: "ordered",
collapsed: false,
});
else editor.chain().focus().createList({ kind: "ordered", collapsed: false });
};
export const toggleFlatBulletList = (editor: Editor, range?: Range) => {
if (range)
editor.chain().focus().deleteRange(range).createList({
kind: "bullet",
collapsed: false,
});
else editor.chain().focus().createList({ kind: "bullet", collapsed: false });
};
export const toggleFlatTaskList = (editor: Editor, range?: Range) => {
if (range)
editor.chain().focus().deleteRange(range).createList({
kind: "task",
collapsed: false,
});
else editor.chain().focus().createList({ kind: "task", collapsed: false });
};
export const toggleFlatToggleList = (editor: Editor, range?: Range) => {
if (range)
editor.chain().focus().deleteRange(range).createList({
kind: "toggle",
collapsed: false,
});
else editor.chain().focus().createList({ kind: "toggle", collapsed: false });
};
export const toggleUnderline = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run();
else editor.chain().focus().toggleUnderline().run();
@@ -67,28 +98,22 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleOrderedList().run();
// if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
// else editor.chain().focus().toggleOrderedList().run();
};
export const toggleBulletList = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleBulletList().run();
// if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
// else editor.chain().focus().toggleBulletList().run();
};
export const toggleTaskList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run();
// if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
// else editor.chain().focus().toggleTaskList().run();
};
export const toggleStrike = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleStrike().run();
};
@@ -109,9 +134,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 = ({

View File

@@ -13,46 +13,35 @@ export const insertEmptyParagraphAtNodeBoundaries: (
({ editor }) => {
try {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
const { $from } = selection;
let targetNode: ProseMirrorNode | null = null;
let targetNodePos: number | null = null;
const node = doc.nodeAt($from.pos);
const pos = $from.pos;
// Check if the selection itself is the target node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === nodeType) {
targetNode = node;
targetNodePos = pos;
return false; // Stop iterating once the target node is found
}
return true;
});
if (targetNode === null || targetNodePos === null) return false;
if (!node || node.type.name !== nodeType) return false;
const docSize = doc.content.size; // Get the size of the document
switch (direction) {
case "up": {
const insertPosUp = targetNodePos;
const insertPosUp = pos;
// Ensure the insert position is within the document boundaries
if (insertPosUp < 0 || insertPosUp > docSize) return false;
// Check if we're exactly at the start of the document
if (insertPosUp === 0) {
// If at the very start of the document, insert a new paragraph at the start
editor.chain().insertContentAt(insertPosUp, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph
} else {
// Otherwise, check the node immediately before the target node
// Check the node immediately before the target node
const prevNode = doc.nodeAt(insertPosUp - 1);
if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
// If the previous node is a paragraph, move the cursor there
editor
.chain()
.setTextSelection(insertPosUp - 1)
.run();
const startOfParagraphPos = insertPosUp - prevNode.nodeSize;
editor.chain().setTextSelection(startOfParagraphPos).run();
} else {
return false; // If the previous node is not a paragraph, do not proceed
}
@@ -61,7 +50,7 @@ export const insertEmptyParagraphAtNodeBoundaries: (
}
case "down": {
const insertPosDown = targetNodePos + (targetNode as ProseMirrorNode).nodeSize;
const insertPosDown = pos + (node as ProseMirrorNode).nodeSize;
// Ensure the insert position is within the document boundaries
if (insertPosDown < 0 || insertPosDown > docSize) return false;
@@ -81,7 +70,16 @@ export const insertEmptyParagraphAtNodeBoundaries: (
.setTextSelection(insertPosDown + 1)
.run(); // Set the cursor to the new paragraph
} else {
return false; // If the next node is not a paragraph, do not proceed
// Check the node immediately after the target node
const nextNode = doc.nodeAt(insertPosDown);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is a paragraph, move the cursor to the end of it
const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else {
return false; // If the next node is not a paragraph, do not proceed
}
}
break;
}

View File

@@ -13,9 +13,7 @@ import {
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
// editor schemas
// @ts-expect-error tiptap types are incorrect
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
// @ts-expect-error tiptap types are incorrect
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
/**
@@ -57,7 +55,6 @@ export const convertBase64StringToBinaryData = (document: string): ArrayBuffer =
*/
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
// @ts-expect-error tiptap types are incorrect
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
@@ -73,7 +70,6 @@ export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: strin
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
// @ts-expect-error tiptap types are incorrect
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
@@ -102,7 +98,6 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
// convert to HTML
// @ts-expect-error tiptap types are incorrect
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);
return {
@@ -132,7 +127,6 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
// @ts-expect-error tiptap types are incorrect
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {

View File

@@ -1,6 +1,6 @@
import { DOMSerializer } from "@tiptap/pm/model";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import { useImperativeHandle, useEffect, useState } from "react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
@@ -9,6 +9,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreEditorExtensions } from "@/extensions";
import { migrateDocJSON } from "@/extensions/flat-list/core";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
@@ -80,6 +81,46 @@ export const useEditor = (props: TEditorHookProps) => {
[editable]
);
const [hasMigrated, setHasMigrated] = useState(false);
useEffect(() => {
if (
editor &&
(!hasMigrated || editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || editor.isActive(CORE_EXTENSIONS.TASK_ITEM))
) {
const newJSON = migrateDocJSON(editor.getJSON() as any);
if (newJSON) {
// Create a new transaction
const transaction = editor.state.tr;
try {
const node = editor.state.schema.nodeFromJSON(newJSON);
transaction.replaceWith(0, editor.state.doc.content.size, node);
transaction.setMeta("addToHistory", false);
editor.view.dispatch(transaction);
setHasMigrated(true);
// focus user on the current position
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
console.error("Error during migration:", error);
}
}
}
}, [
editor?.getJSON(),
editor?.isActive(CORE_EXTENSIONS.LIST_ITEM),
editor?.isActive(CORE_EXTENSIONS.TASK_ITEM),
hasMigrated,
editor,
]);
// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet

View File

@@ -0,0 +1,202 @@
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({
name: "markdownClipboardNew",
addOptions() {
return {
transformPastedText: false,
transformCopiedText: false,
};
},
addProseMirrorPlugins() {
return [
new Plugin({
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 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;
},
},
}),
];
},
});
function parseHTMLToMarkdown(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// 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) {
return node.textContent || "";
}
// Element node
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// Handle prosemirror-flat-list
if (element.classList.contains("prosemirror-flat-list")) {
const listKind = element.getAttribute("data-list-kind");
if (!listKind) return element.outerHTML;
// Calculate nesting level
let level = 0;
let parent = element.parentElement;
while (parent) {
if (parent.classList.contains("list-content")) level++;
parent = parent.parentElement;
}
// Reset sequence if level decreases
if (level < currentLevel) {
sequences[level] = 0;
}
currentLevel = level;
// 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;
}
}
// 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("");
// 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;
}
// Empty element, return as is
return element.outerHTML;
}
// Any other node type, return as is
return "";
}
return processNode(doc.documentElement);
}
// 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);
}

View File

@@ -21,6 +21,7 @@ const generalSelectors = [
".image-component",
".image-upload-component",
".editor-callout-component",
".prosemirror-flat-list",
].join(", ");
const maxScrollSpeed = 20;
@@ -65,7 +66,9 @@ const isScrollable = (node: HTMLElement | SVGElement) => {
});
};
const getScrollParent = (node: HTMLElement | SVGElement) => {
const getScrollParent = (node: HTMLElement | SVGElement | null): Element | null => {
if (!node) return null;
if (scrollParentCache.has(node)) {
return scrollParentCache.get(node);
}
@@ -137,32 +140,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
let isDraggedOutsideWindow: "top" | "bottom" | boolean = false;
let isMouseInsideWhileDragging = false;
let currentScrollSpeed = 0;
const handleClick = (event: MouseEvent, view: EditorView) => {
handleNodeSelection(event, view, false, options);
};
const handleDragStart = (event: DragEvent, view: EditorView) => {
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options) ?? {};
if (listTypeFromDragStart) {
listType = listTypeFromDragStart;
}
isDragging = true;
lastClientY = event.clientY;
scroll();
};
const handleDragEnd = <TEvent extends DragEvent | FocusEvent>(event: TEvent, view?: EditorView) => {
event.preventDefault();
isDragging = false;
isMouseInsideWhileDragging = false;
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
view?.dom.classList.remove("dragging");
};
let dragHandleElement: HTMLElement | null = null;
function scroll() {
if (!isDragging) {
@@ -199,7 +177,32 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
scrollAnimationFrame = requestAnimationFrame(scroll);
}
let dragHandleElement: HTMLElement | null = null;
const handleClick = (event: MouseEvent, view: EditorView) => {
handleNodeSelection(event, view, false, options);
};
const handleDragStart = (event: DragEvent, view: EditorView) => {
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options) ?? {};
if (listTypeFromDragStart) {
listType = listTypeFromDragStart;
}
isDragging = true;
lastClientY = event.clientY;
scroll();
};
const handleDragEnd = <TEvent extends DragEvent | FocusEvent>(event: TEvent, view?: EditorView) => {
event.preventDefault();
isDragging = false;
isMouseInsideWhileDragging = false;
if (scrollAnimationFrame) {
cancelAnimationFrame(scrollAnimationFrame);
scrollAnimationFrame = null;
}
view?.dom.classList.remove("dragging");
};
// drag handle view actions
const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
const hideDragHandle = () => {
@@ -383,6 +386,8 @@ const handleNodeSelection = (
if (node.matches("blockquote")) {
draggedNodePos = nodePosAtDOMForBlockQuotes(node, view);
if (draggedNodePos === null || draggedNodePos === undefined) return;
} else if (node.className.includes("prosemirror-flat-list")) {
draggedNodePos -= 1;
} else {
// Resolve the position to get the parent node
const $pos = view.state.doc.resolve(draggedNodePos);

View File

@@ -3,6 +3,142 @@ import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// flat-list serializer
import { ListDOMSerializer } from "../extensions/flat-list/core/utils/list-serializer";
// Helper function to check if content contains flat-list nodes
const containsFlatListNodes = (content: Fragment | Node): boolean => {
let hasListNodes = false;
const checkNode = (node: Node) => {
if (node.type.name === CORE_EXTENSIONS.LIST) {
hasListNodes = true;
return;
}
if (node.content) {
node.content.forEach(checkNode);
}
};
if (content instanceof Fragment) {
content.forEach(checkNode);
} else {
checkNode(content);
}
return hasListNodes;
};
// Convert HTML with flat-lists to markdown
const htmlToMarkdown = (html: string): string => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
let listDepth = 0;
const processNode = (node: ChildNode): string => {
// Text node - return as is
if (node.nodeType === window.Node.TEXT_NODE) {
return node.textContent || "";
}
// Element node
if (node.nodeType === window.Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// Handle native HTML lists (ul/ol) - these come from ListDOMSerializer
if (element.tagName === "UL" || element.tagName === "OL") {
const isOrdered = element.tagName === "OL";
let result = "";
let itemIndex = 1;
const indent = " ".repeat(listDepth);
listDepth++;
Array.from(element.children).forEach((li) => {
if (li.tagName === "LI") {
// Check for checkboxes in task lists
const checkbox = li.querySelector('input[type="checkbox"]');
const isTaskItem = checkbox !== null;
// Check for flat-list attributes
const listKind = li.getAttribute("data-list-kind");
const isChecked = li.hasAttribute("data-list-checked");
const isCollapsed = li.hasAttribute("data-list-collapsed");
const listOrder = li.getAttribute("data-list-order");
if (isTaskItem || listKind === "task") {
// Task list item - use checkbox state or data attribute
const checked = checkbox ? (checkbox as HTMLInputElement).checked : isChecked;
const textContent = li.textContent?.replace(/^\s*/, "").trim() || "";
result += `${indent}- [${checked ? "x" : " "}] ${textContent}\n`;
} else if (listKind === "toggle") {
// Toggle list item - handle collapsed state
const textContent = li.textContent?.replace(/^\s*/, "").trim() || "";
const togglePrefix = isCollapsed ? "- " : "- ";
result += `${indent}${togglePrefix}${textContent}\n`;
} else {
// Regular list item - process children to handle nested content
const childContent = Array.from(li.childNodes)
.map((child) => processNode(child))
.join("")
.trim();
let prefix: string;
if (isOrdered || listKind === "ordered") {
const orderNum = listOrder ? parseInt(listOrder, 10) : itemIndex;
prefix = `${orderNum}. `;
} else {
prefix = "- ";
}
result += `${indent}${prefix}${childContent}\n`;
itemIndex++;
}
}
});
listDepth--;
return result;
}
// Handle other block elements that should preserve line breaks
if (["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6"].includes(element.tagName)) {
const content = Array.from(element.childNodes)
.map((child) => processNode(child))
.join("")
.trim();
return content ? content + "\n" : "";
}
// Handle other elements by processing their children
if (element.childNodes.length > 0) {
return Array.from(element.childNodes)
.map((child) => processNode(child))
.join("");
}
}
return "";
};
return processNode(doc.body).trim();
};
// Serialize content with flat-lists to markdown via HTML
const serializeFlatListsToMarkdown = (content: Fragment, editor: Editor): string => {
// Use ListDOMSerializer to convert to HTML with native list elements
const listSerializer = ListDOMSerializer.fromSchema(editor.schema);
const htmlFragment = listSerializer.serializeFragment(content);
// Create a temporary div to get the HTML string
const tempDiv = document.createElement("div");
tempDiv.appendChild(htmlFragment);
const html = tempDiv.innerHTML;
// Convert HTML to markdown
return htmlToMarkdown(html);
};
export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
new Plugin({
@@ -13,7 +149,14 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
// Check if content contains flat-list nodes
const hasFlatLists = containsFlatListNodes(slice.content);
if (nodeSelect) {
// For complete node selections, check if we have flat-lists
if (hasFlatLists) {
return serializeFlatListsToMarkdown(slice.content, editor);
}
return markdownSerializer.serialize(slice.content);
}
@@ -61,6 +204,10 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
};
if (slice.content.childCount > 1) {
// For multiple children, check if we have flat-lists
if (hasFlatLists) {
return serializeFlatListsToMarkdown(slice.content, editor);
}
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
@@ -73,6 +220,14 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
return currentNode.text;
}
// Check if target node contains flat-lists
if (containsFlatListNodes(targetNode)) {
return serializeFlatListsToMarkdown(
targetNode instanceof Fragment ? targetNode : Fragment.from([targetNode]),
editor
);
}
return markdownSerializer.serialize(targetNode);
}
},

Some files were not shown because too many files have changed in this diff Show More