mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
95 Commits
chore-fav_
...
feat/flat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b174e31b | ||
|
|
81e2906cc8 | ||
|
|
917e164d79 | ||
|
|
4f13d0a503 | ||
|
|
594fa82826 | ||
|
|
b52ae00bb9 | ||
|
|
ac0c0717db | ||
|
|
2983be8049 | ||
|
|
ae6cad2ac3 | ||
|
|
17b910aebd | ||
|
|
f1db64827f | ||
|
|
8aa5cbccf3 | ||
|
|
cd146aa608 | ||
|
|
366ed387a3 | ||
|
|
44bd3c6c92 | ||
|
|
35376d4a61 | ||
|
|
bdce78e15c | ||
|
|
d064517462 | ||
|
|
1bdc1e845a | ||
|
|
0eac8a0556 | ||
|
|
291898a63e | ||
|
|
9112b4d381 | ||
|
|
c8ee874a59 | ||
|
|
f8ec83206f | ||
|
|
44296c4ff2 | ||
|
|
c785aac533 | ||
|
|
a7489eb4a3 | ||
|
|
6471ca6ec8 | ||
|
|
e5a5dc4117 | ||
|
|
cf41100003 | ||
|
|
157ba6c34f | ||
|
|
e6b9d155b1 | ||
|
|
d2a5faf54b | ||
|
|
b820a6724e | ||
|
|
1de79f1185 | ||
|
|
ce19996d4f | ||
|
|
c02b5e17ef | ||
|
|
fc6d99d22d | ||
|
|
f949a856ad | ||
|
|
27ba1b9035 | ||
|
|
34a18e74b0 | ||
|
|
2dea7e8feb | ||
|
|
81e1fb6b0b | ||
|
|
ec4f4e229b | ||
|
|
324e41b26d | ||
|
|
cdbce9fbbb | ||
|
|
feab2b218d | ||
|
|
4acc5989dc | ||
|
|
a1b91a58a1 | ||
|
|
5fafdddb1e | ||
|
|
fbf299fbc3 | ||
|
|
4e2a9668a5 | ||
|
|
e934300662 | ||
|
|
133091b580 | ||
|
|
5e2182343e | ||
|
|
9f6daaf0c7 | ||
|
|
e5c4614f58 | ||
|
|
9bca7316f8 | ||
|
|
c48247ecde | ||
|
|
8487bb348d | ||
|
|
61ded9611d | ||
|
|
e094b494f6 | ||
|
|
151dc428e6 | ||
|
|
97f30288e1 | ||
|
|
7536a7886a | ||
|
|
26f344220e | ||
|
|
ab02542691 | ||
|
|
f8d884809c | ||
|
|
278a8141f2 | ||
|
|
81796afad9 | ||
|
|
e2af5b40d4 | ||
|
|
d90bbcd9d0 | ||
|
|
f5e28ddb30 | ||
|
|
5fab502a98 | ||
|
|
bace1a07cf | ||
|
|
c5fde5f5a2 | ||
|
|
c0e40bcbde | ||
|
|
841d6ebe52 | ||
|
|
723ee1d598 | ||
|
|
8d8df45b90 | ||
|
|
0cdee27066 | ||
|
|
f66cb7cbf9 | ||
|
|
893fe6cc93 | ||
|
|
d5a5a247ba | ||
|
|
a530f642ab | ||
|
|
e80d308501 | ||
|
|
c89f4b56d2 | ||
|
|
62d778f5ae | ||
|
|
ecfc30696a | ||
|
|
7147cfd70e | ||
|
|
fc2b3aa443 | ||
|
|
649e43edf0 | ||
|
|
89d2f38127 | ||
|
|
76f0c8b6c1 | ||
|
|
8a12eba836 |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -41,4 +41,5 @@ export enum CORE_EXTENSIONS {
|
||||
UNDERLINE = "underline",
|
||||
UTILITY = "utility",
|
||||
WORK_ITEM_EMBED = "issue-embed-component",
|
||||
LIST = "list",
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
419
packages/editor/src/core/extensions/clipboard-new.ts
Normal file
419
packages/editor/src/core/extensions/clipboard-new.ts
Normal 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;
|
||||
}
|
||||
89
packages/editor/src/core/extensions/clipboard.ts
Normal file
89
packages/editor/src/core/extensions/clipboard.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./list-keymap";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
476
packages/editor/src/core/extensions/drop-cursor.ts
Normal file
476
packages/editor/src/core/extensions/drop-cursor.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
38
packages/editor/src/core/extensions/flat-list/core/index.ts
Normal file
38
packages/editor/src/core/extensions/flat-list/core/index.ts
Normal 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";
|
||||
103
packages/editor/src/core/extensions/flat-list/core/input-rule.ts
Normal file
103
packages/editor/src/core/extensions/flat-list/core/input-rule.ts
Normal 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",
|
||||
}),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
106
packages/editor/src/core/extensions/flat-list/core/style.css
Normal file
106
packages/editor/src/core/extensions/flat-list/core/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/editor/src/core/extensions/flat-list/core/types.ts
Normal file
27
packages/editor/src/core/extensions/flat-list/core/types.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
export function isTextSelection(value?: unknown): value is TextSelection {
|
||||
return Boolean(value && value instanceof TextSelection);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
},
|
||||
});
|
||||
1
packages/editor/src/core/extensions/flat-list/index.ts
Normal file
1
packages/editor/src/core/extensions/flat-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./list-extension";
|
||||
151
packages/editor/src/core/extensions/flat-list/list-extension.ts
Normal file
151
packages/editor/src/core/extensions/flat-list/list-extension.ts
Normal 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;
|
||||
}
|
||||
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,10 +40,6 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive(CORE_EXTENSIONS.TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
202
packages/editor/src/core/plugins/clipboard.ts
Normal file
202
packages/editor/src/core/plugins/clipboard.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user