mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
25 Commits
chore-ln-h
...
feat/flat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5752adb42 | ||
|
|
7231b943be | ||
|
|
17b53c888f | ||
|
|
f4061c4ad6 | ||
|
|
3b7003815b | ||
|
|
2c499cf436 | ||
|
|
24f46bd098 | ||
|
|
5e6e12d5e2 | ||
|
|
2d9a2ba9c7 | ||
|
|
c81f02d879 | ||
|
|
9773a1feed | ||
|
|
88b8e764e3 | ||
|
|
523b0a3eaf | ||
|
|
daf14f17c7 | ||
|
|
6ce6533de5 | ||
|
|
95a063d564 | ||
|
|
2a1c08f203 | ||
|
|
cefc1e99b1 | ||
|
|
15668299b4 | ||
|
|
594ae81c31 | ||
|
|
8a41b523d6 | ||
|
|
fb6cdb7f86 | ||
|
|
ce99563a0d | ||
|
|
1b9b59799e | ||
|
|
a83903280c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -85,3 +85,5 @@ deploy/selfhost/plane-app/
|
||||
## Storybook
|
||||
*storybook.log
|
||||
output.css
|
||||
|
||||
dev-server
|
||||
|
||||
BIN
apiserver/dump.rdb
Normal file
BIN
apiserver/dump.rdb
Normal file
Binary file not shown.
@@ -16,16 +16,16 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@hocuspocus/extension-database": "^2.13.7",
|
||||
"@hocuspocus/extension-logger": "^2.13.7",
|
||||
"@hocuspocus/extension-redis": "^2.13.7",
|
||||
"@hocuspocus/server": "^2.13.7",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@sentry/node": "^8.28.0",
|
||||
"@sentry/profiling-node": "^8.28.0",
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/html": "^2.3.0",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/html": "^2.9.1",
|
||||
"axios": "^1.7.2",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
@@ -39,14 +39,14 @@
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"uuid": "^10.0.0",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-prosemirror": "^1.2.12",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.14"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.6",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-env": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Database } from "@hocuspocus/extension-database";
|
||||
import { Extension } from "@hocuspocus/server";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
|
||||
// core helpers and utilities
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
// Core helpers and utilities
|
||||
import { logger } from "@/core/helpers/logger.js";
|
||||
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
|
||||
// core libraries
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
new Logger({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
manualLogger.info(message);
|
||||
logger.info(message);
|
||||
},
|
||||
}),
|
||||
new Database({
|
||||
@@ -40,7 +41,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
| undefined;
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
let fetchedData = null;
|
||||
if (documentType === "project_page") {
|
||||
@@ -57,9 +58,16 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fetchedData) {
|
||||
reject("Data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(fetchedData);
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in fetching document", error);
|
||||
logger.error("Error in fetching document", error);
|
||||
reject("Error in fetching document" + JSON.stringify(error));
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -92,7 +100,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
manualLogger.error("Error in updating document:", error);
|
||||
logger.error("Error in updating document:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -114,7 +122,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
manualLogger.warn(
|
||||
logger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error,
|
||||
);
|
||||
@@ -123,18 +131,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
|
||||
redisClient.on("ready", () => {
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
manualLogger.info("Redis Client connected ✅");
|
||||
logger.info("Redis Client connected ✅");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
manualLogger.warn(
|
||||
logger.warn(
|
||||
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
manualLogger.warn(
|
||||
logger.warn(
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ErrorRequestHandler } from "express";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
import { logger } from "@/core/helpers/logger.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Log the error
|
||||
manualLogger.error(err);
|
||||
logger.error(err);
|
||||
|
||||
// Set the response status
|
||||
res.status(err.status || 500);
|
||||
|
||||
@@ -18,7 +18,7 @@ const hooks = {
|
||||
},
|
||||
};
|
||||
|
||||
export const logger = pinoHttp({
|
||||
export const coreLogger = pinoHttp({
|
||||
level: "info",
|
||||
transport: transport,
|
||||
hooks: hooks,
|
||||
@@ -35,4 +35,4 @@ export const logger = pinoHttp({
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger = logger.logger;
|
||||
export const logger = coreLogger.logger;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs"
|
||||
import {
|
||||
prosemirrorJSONToYDoc,
|
||||
yXmlFragmentToProseMirrorRootNode,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@plane/editor/lib";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
...CoreEditorExtensionsWithoutProps,
|
||||
...DocumentEditorExtensionsWithoutProps,
|
||||
];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array,
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema
|
||||
documentEditorSchema,
|
||||
).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
|
||||
contentBinary: Uint8Array
|
||||
export const getBinaryDataFromHTMLString = (
|
||||
descriptionHTML: string,
|
||||
): {
|
||||
contentBinary: Uint8Array;
|
||||
} => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(
|
||||
descriptionHTML ?? "<p></p>",
|
||||
DOCUMENT_EDITOR_EXTENSIONS
|
||||
DOCUMENT_EDITOR_EXTENSIONS,
|
||||
);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(
|
||||
documentEditorSchema,
|
||||
contentJSON,
|
||||
"default"
|
||||
"default",
|
||||
);
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return {
|
||||
contentBinary: encodedData
|
||||
}
|
||||
}
|
||||
contentBinary: encodedData,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// services
|
||||
import { UserService } from "@/core/services/user.service.js";
|
||||
// core helpers
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
import { logger } from "@/core/helpers/logger.js";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -17,7 +17,7 @@ export const handleAuthentication = async (props: Props) => {
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
manualLogger.error("Failed to fetch current user:", error);
|
||||
logger.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== userId) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
import { logger } from "../helpers/logger.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
export const updatePageDescription = async (
|
||||
@@ -41,7 +41,7 @@ export const updatePageDescription = async (
|
||||
cookie,
|
||||
);
|
||||
} catch (error) {
|
||||
manualLogger.error("Update error:", error);
|
||||
logger.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -66,7 +66,7 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
);
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
manualLogger.error(
|
||||
logger.error(
|
||||
"Error while transforming from HTML to Uint8Array",
|
||||
error,
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
manualLogger.error("Fetch error:", error);
|
||||
logger.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,10 @@ import cors from "cors";
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
|
||||
// helpers
|
||||
import { logger, manualLogger } from "@/core/helpers/logger.js";
|
||||
import {
|
||||
coreLogger as LoggerMiddleware,
|
||||
logger,
|
||||
} from "@/core/helpers/logger.js";
|
||||
import { errorHandler } from "@/core/helpers/error-handler.js";
|
||||
|
||||
const app = express();
|
||||
@@ -33,7 +36,7 @@ app.use(
|
||||
);
|
||||
|
||||
// Logging middleware
|
||||
app.use(logger);
|
||||
app.use(LoggerMiddleware);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json());
|
||||
@@ -45,7 +48,7 @@ app.use(cors());
|
||||
const router = express.Router();
|
||||
|
||||
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
logger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -57,7 +60,7 @@ router.ws("/collaboration", (ws, req) => {
|
||||
try {
|
||||
HocusPocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
manualLogger.error("WebSocket connection error:", err);
|
||||
logger.error("WebSocket connection error:", err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
@@ -73,46 +76,44 @@ Sentry.setupExpressErrorHandler(app);
|
||||
app.use(errorHandler);
|
||||
|
||||
const liveServer = app.listen(app.get("port"), () => {
|
||||
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
|
||||
logger.info(`Plane Live server has started at port ${app.get("port")}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
manualLogger.info("Starting graceful shutdown...");
|
||||
logger.info("Starting graceful shutdown...");
|
||||
|
||||
try {
|
||||
// Close the HocusPocus server WebSocket connections
|
||||
await HocusPocusServer.destroy();
|
||||
manualLogger.info(
|
||||
"HocusPocus server WebSocket connections closed gracefully.",
|
||||
);
|
||||
logger.info("HocusPocus server WebSocket connections closed gracefully.");
|
||||
|
||||
// Close the Express server
|
||||
liveServer.close(() => {
|
||||
manualLogger.info("Express server closed gracefully.");
|
||||
logger.info("Express server closed gracefully.");
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (err) {
|
||||
manualLogger.error("Error during shutdown:", err);
|
||||
logger.error("Error during shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Forcefully shut down after 10 seconds if not closed
|
||||
setTimeout(() => {
|
||||
manualLogger.error("Forcing shutdown...");
|
||||
logger.error("Forcing shutdown...");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", (err: any) => {
|
||||
manualLogger.info("Unhandled Rejection: ", err);
|
||||
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
logger.info("Unhandled Rejection: ", err);
|
||||
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", (err: any) => {
|
||||
manualLogger.info("Uncaught Exception: ", err);
|
||||
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
logger.info("Uncaught Exception: ", err);
|
||||
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
@@ -38,23 +38,23 @@
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-character-count": "^2.6.5",
|
||||
"@tiptap/extension-collaboration": "^2.3.2",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-list-item": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.3.0",
|
||||
"@tiptap/extension-task-item": "^2.1.13",
|
||||
"@tiptap/extension-task-list": "^2.1.13",
|
||||
"@tiptap/extension-text-align": "^2.8.0",
|
||||
"@tiptap/extension-text-style": "^2.7.1",
|
||||
"@tiptap/extension-underline": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
"@tiptap/starter-kit": "^2.1.13",
|
||||
"@tiptap/suggestion": "^2.0.13",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/extension-blockquote": "^2.9.1",
|
||||
"@tiptap/extension-character-count": "^2.9.1",
|
||||
"@tiptap/extension-collaboration": "^2.9.1",
|
||||
"@tiptap/extension-image": "^2.9.1",
|
||||
"@tiptap/extension-list-item": "^2.9.1",
|
||||
"@tiptap/extension-mention": "^2.9.1",
|
||||
"@tiptap/extension-placeholder": "^2.9.1",
|
||||
"@tiptap/extension-task-item": "^2.9.1",
|
||||
"@tiptap/extension-task-list": "^2.9.1",
|
||||
"@tiptap/extension-text-style": "^2.9.1",
|
||||
"@tiptap/extension-underline": "^2.9.1",
|
||||
"@tiptap/extension-text-align": "^2.9.1",
|
||||
"@tiptap/pm": "^2.9.1",
|
||||
"@tiptap/react": "^2.9.1",
|
||||
"@tiptap/starter-kit": "^2.9.1",
|
||||
"@tiptap/suggestion": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"highlight.js": "^11.8.0",
|
||||
@@ -63,16 +63,17 @@
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-flat-list": "^0.5.4",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-prosemirror": "^1.2.12",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleFlatBulletList,
|
||||
toggleFlatOrderedList,
|
||||
toggleFlatTaskList,
|
||||
toggleFlatToggleList,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
@@ -153,27 +157,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("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
isActive: () => editor?.isActive("list", { type: "bullet" }),
|
||||
command: () => toggleFlatBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
isActive: () => editor?.isActive("list", { type: "ordered" }),
|
||||
command: () => toggleFlatOrderedList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
isActive: () => editor?.isActive("list", { type: "task" }),
|
||||
command: () => toggleFlatTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
|
||||
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
|
||||
key: "toggle-list",
|
||||
name: "Toggle list",
|
||||
isActive: () => editor?.isActive("list", { type: "toggle" }),
|
||||
command: () => toggleFlatToggleList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
@@ -265,6 +277,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
||||
HorizontalRuleItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
ToggleListItem(editor),
|
||||
TextAlignItem(editor),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
@@ -150,6 +150,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
// exit node on triple enter
|
||||
Enter: ({ editor }) => {
|
||||
try {
|
||||
console.log("ran in code block");
|
||||
if (!this.options.exitOnTripleEnter) {
|
||||
return false;
|
||||
}
|
||||
@@ -183,8 +184,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on arrow down
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
@@ -205,7 +204,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
// if the code block is directly on the root level, then just
|
||||
// find the next node at same depth (basically the root and code block are at the same level)
|
||||
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
|
||||
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
|
||||
|
||||
const after = $from.after(parentDepth);
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
@@ -214,12 +218,15 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return false;
|
||||
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 code block:", error);
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,7 +91,12 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
// if the code block is directly on the root level, then just
|
||||
// find the next node at same depth (basically the root and code block are at the same level)
|
||||
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
|
||||
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
|
||||
|
||||
const after = $from.after(parentDepth);
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { FlatListExtension } from "./flat-list/flat-list";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
|
||||
@@ -91,6 +92,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtensionConfig,
|
||||
CustomColorExtension,
|
||||
FlatListExtension,
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
|
||||
@@ -88,41 +88,41 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
|
||||
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;
|
||||
},
|
||||
// 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;
|
||||
// },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
234
packages/editor/src/core/extensions/drop-cursor.ts
Normal file
234
packages/editor/src/core/extensions/drop-cursor.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Plugin, EditorState } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { dropPoint } from "prosemirror-transform";
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function dropCursor(options: DropCursorOptions = {}): Plugin {
|
||||
return new Plugin({
|
||||
view(editorView) {
|
||||
return new DropCursorView(editorView, {
|
||||
...options,
|
||||
// Add custom behavior for list nodes
|
||||
disableDropCursor: (view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => {
|
||||
console.log("adf");
|
||||
if (!pos) return true;
|
||||
|
||||
const $pos = view.state.doc.resolve(pos.pos);
|
||||
const parentNode = $pos.parent;
|
||||
|
||||
// If we're between two list items, only show cursor at the list boundary
|
||||
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
|
||||
const nodeBefore = $pos.nodeBefore;
|
||||
const nodeAfter = $pos.nodeAfter;
|
||||
|
||||
// Only show cursor at list boundaries
|
||||
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
|
||||
// Only allow cursor at the exact position between lists
|
||||
return $pos.pos !== pos.pos;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// Create a plugin that, when added to a ProseMirror instance,
|
||||
/// causes a decoration to show up at the drop position when something
|
||||
/// is dragged over the editor.
|
||||
///
|
||||
/// Nodes may add a `disableDropCursor` property to their spec to
|
||||
/// control the showing of a drop cursor inside them. This may be a
|
||||
/// boolean or a function, which will be called with a view and a
|
||||
/// position, and should return a boolean.
|
||||
// export function dropCursor(options: DropCursorOptions = {}): Plugin {
|
||||
// return new Plugin({
|
||||
// view(editorView) {
|
||||
// return new DropCursorView(editorView, options);
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
class DropCursorView {
|
||||
width: number;
|
||||
color: string | undefined;
|
||||
class: string | undefined;
|
||||
cursorPos: number | null = null;
|
||||
element: HTMLElement | null = null;
|
||||
timeout: number = -1;
|
||||
handlers: { name: string; handler: (event: Event) => void }[];
|
||||
|
||||
constructor(
|
||||
readonly editorView: EditorView,
|
||||
options: DropCursorOptions
|
||||
) {
|
||||
this.width = options.width ?? 1;
|
||||
this.color = options.color === false ? undefined : options.color || "black";
|
||||
this.class = options.class;
|
||||
|
||||
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
|
||||
let 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) {
|
||||
if (pos == this.cursorPos) return;
|
||||
this.cursorPos = pos;
|
||||
if (pos == null) {
|
||||
this.element!.parentNode!.removeChild(this.element!);
|
||||
this.element = null;
|
||||
} else {
|
||||
this.updateOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
updateOverlay() {
|
||||
let $pos = this.editorView.state.doc.resolve(this.cursorPos!);
|
||||
let isBlock = !$pos.parent.inlineContent,
|
||||
rect;
|
||||
let editorDOM = this.editorView.dom,
|
||||
editorRect = editorDOM.getBoundingClientRect();
|
||||
let scaleX = editorRect.width / editorDOM.offsetWidth,
|
||||
scaleY = editorRect.height / editorDOM.offsetHeight;
|
||||
if (isBlock) {
|
||||
let before = $pos.nodeBefore,
|
||||
after = $pos.nodeAfter;
|
||||
if (before || after) {
|
||||
let node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
|
||||
if (node) {
|
||||
let 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;
|
||||
let halfWidth = (this.width / 2) * scaleY;
|
||||
rect = { left: nodeRect.left, right: nodeRect.right, top: top - halfWidth, bottom: top + halfWidth };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rect) {
|
||||
let coords = this.editorView.coordsAtPos(this.cursorPos!);
|
||||
let halfWidth = (this.width / 2) * scaleX;
|
||||
rect = { left: coords.left - halfWidth, right: coords.left + halfWidth, top: coords.top, bottom: coords.bottom };
|
||||
}
|
||||
|
||||
let 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, parentTop;
|
||||
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
|
||||
parentLeft = -pageXOffset;
|
||||
parentTop = -pageYOffset;
|
||||
} else {
|
||||
let rect = parent.getBoundingClientRect();
|
||||
let parentScaleX = rect.width / parent.offsetWidth,
|
||||
parentScaleY = rect.height / parent.offsetHeight;
|
||||
parentLeft = rect.left - parent.scrollLeft * parentScaleX;
|
||||
parentTop = rect.top - parent.scrollTop * parentScaleY;
|
||||
}
|
||||
this.element.style.left = (rect.left - parentLeft) / scaleX + "px";
|
||||
this.element.style.top = (rect.top - parentTop) / scaleY + "px";
|
||||
this.element.style.width = (rect.right - rect.left) / scaleX + "px";
|
||||
this.element.style.height = (rect.bottom - rect.top) / scaleY + "px";
|
||||
}
|
||||
|
||||
scheduleRemoval(timeout: number) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => this.setCursor(null), timeout);
|
||||
}
|
||||
|
||||
dragover(event: DragEvent) {
|
||||
if (!this.editorView.editable) return;
|
||||
let pos = this.editorView.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
|
||||
let node = pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
|
||||
let disableDropCursor = node && node.type.spec.disableDropCursor;
|
||||
let disabled =
|
||||
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
|
||||
|
||||
if (!pos) return true;
|
||||
|
||||
const $pos = this.editorView.state.doc.resolve(pos.pos);
|
||||
const parentNode = $pos.parent;
|
||||
|
||||
// If we're between two list items, only show cursor at the list boundary
|
||||
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
|
||||
const nodeBefore = $pos.nodeBefore;
|
||||
const nodeAfter = $pos.nodeAfter;
|
||||
|
||||
console.log(nodeBefore.type.name, nodeAfter.type.name);
|
||||
// Only show cursor at list boundaries
|
||||
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
|
||||
// Only allow cursor at the exact position between lists
|
||||
return $pos.pos !== pos.pos;
|
||||
}
|
||||
}
|
||||
|
||||
// return false;
|
||||
|
||||
if (pos && !disabled) {
|
||||
let target: number | null = pos.pos;
|
||||
if (this.editorView.dragging && this.editorView.dragging.slice) {
|
||||
let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
|
||||
if (point != null) target = point;
|
||||
}
|
||||
this.setCursor(target);
|
||||
this.scheduleRemoval(5000);
|
||||
}
|
||||
}
|
||||
|
||||
dragend() {
|
||||
this.scheduleRemoval(20);
|
||||
}
|
||||
|
||||
drop() {
|
||||
this.scheduleRemoval(20);
|
||||
}
|
||||
|
||||
dragleave(event: DragEvent) {
|
||||
if (event.target == this.editorView.dom || !this.editorView.dom.contains((event as any).relatedTarget))
|
||||
this.setCursor(null);
|
||||
}
|
||||
}
|
||||
|
||||
export const dropCursorExtension = (options: DropCursorOptions) =>
|
||||
Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [dropCursor(options)];
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
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";
|
||||
import BulletList from "@tiptap/extension-bullet-list";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -34,8 +33,14 @@ import {
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
import { FlatListExtension } from "./flat-list/flat-list";
|
||||
import { multipleSelectionExtension } from "./selections/multipleSelections";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import ListItem from "@tiptap/extension-list-item";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -54,30 +59,48 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
// dropcursor: false,
|
||||
dropcursor: {
|
||||
class: "text-custom-text-300",
|
||||
},
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
BulletList.extend({
|
||||
addInputRules() {
|
||||
return [];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
}),
|
||||
OrderedList.extend({
|
||||
addInputRules() {
|
||||
return [];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
}),
|
||||
ListItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension(),
|
||||
CustomHorizontalRule.configure({
|
||||
@@ -86,7 +109,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
@@ -166,6 +188,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
CustomColorExtension,
|
||||
FlatListExtension,
|
||||
multipleSelectionExtension,
|
||||
// FlatHeadingListExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { type TaggedProsemirrorNode } from 'jest-remirror'
|
||||
import { type Node as ProsemirrorNode } from 'prosemirror-model'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createDedentListCommand } from './dedent-list'
|
||||
|
||||
describe('dedentList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const markdown = t.markdown
|
||||
|
||||
it('can dedent a list node to outer list', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
- B<cursor>1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B<cursor>1
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- - <cursor>B1
|
||||
- A1
|
||||
`,
|
||||
markdown`
|
||||
- B1
|
||||
- A1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can dedent a paragraph node to outer list', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1a
|
||||
|
||||
B1b<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1a
|
||||
|
||||
B1b<cursor>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can unwrap a list node', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
A1<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1<cursor>
|
||||
- A2
|
||||
`,
|
||||
markdown`
|
||||
A1<cursor>
|
||||
|
||||
- A2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
- A2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
A2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can unwrap multiple list nodes', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1<start>
|
||||
- A2<end>
|
||||
`,
|
||||
markdown`
|
||||
A1
|
||||
|
||||
A2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
- A2<start>
|
||||
- A3<end>
|
||||
- A4
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
A2
|
||||
|
||||
A3
|
||||
|
||||
- A4
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep siblings after the lifted items at the same position', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<start>
|
||||
|
||||
- B3
|
||||
|
||||
- C1<end>
|
||||
|
||||
B3
|
||||
|
||||
- B4
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<start>
|
||||
|
||||
- B3
|
||||
|
||||
- C1<end>
|
||||
|
||||
B3
|
||||
|
||||
- B4
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<cursor>
|
||||
|
||||
A1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<cursor>
|
||||
|
||||
A1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can only dedent selected part when the selection across multiple depth of a nested lists', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1<start>
|
||||
|
||||
- B3<end>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1<start>
|
||||
|
||||
- B3<end>
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1<start>
|
||||
|
||||
- B3<end>
|
||||
|
||||
- C2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1<start>
|
||||
|
||||
- B3<end>
|
||||
|
||||
- - C2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can wrap unselected paragraphs with a list node if necessary', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<start>
|
||||
|
||||
- B3<end>
|
||||
|
||||
B3
|
||||
|
||||
B3
|
||||
|
||||
- B4
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2<start>
|
||||
|
||||
- B3<end>
|
||||
|
||||
- B3
|
||||
|
||||
B3
|
||||
|
||||
- B4
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep the indentation of sub list', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1<cursor>
|
||||
|
||||
- C1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1<cursor>
|
||||
|
||||
- - C1
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1<cursor>
|
||||
|
||||
- B1
|
||||
`,
|
||||
markdown`
|
||||
A1<cursor>
|
||||
|
||||
- - B1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('do nothing when not inside a list', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
Hello<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
Hello<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can dedent a nested list item', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- - B1<cursor>
|
||||
|
||||
B1
|
||||
|
||||
A1
|
||||
`,
|
||||
markdown`
|
||||
- B1
|
||||
|
||||
- B1
|
||||
|
||||
A1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can dedent a blockquote inside a list', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- > A1
|
||||
>
|
||||
> A2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- > A1
|
||||
|
||||
A2<cursor>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can accept custom positions', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand({ from: 13, to: 17 }),
|
||||
t.doc(
|
||||
/*0*/
|
||||
t.bulletList(/*1*/ t.p('A1') /*5*/),
|
||||
/*6*/
|
||||
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
|
||||
/*12*/
|
||||
t.bulletList(/*13*/ t.p('A3') /*17*/),
|
||||
/*18*/
|
||||
),
|
||||
t.doc(
|
||||
//
|
||||
t.bulletList(t.p('A1')),
|
||||
t.bulletList(t.p('A2')),
|
||||
t.p('A3'),
|
||||
),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand({ from: 10, to: 14 }),
|
||||
t.doc(
|
||||
/*0*/
|
||||
t.bulletList(/*1*/ t.p('A1') /*5*/),
|
||||
/*6*/
|
||||
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
|
||||
/*12*/
|
||||
t.bulletList(/*13*/ t.p('A3') /*17*/),
|
||||
/*18*/
|
||||
),
|
||||
t.doc(
|
||||
//
|
||||
t.bulletList(t.p('A1')),
|
||||
t.p('A2'),
|
||||
t.p('A3'),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can handle some complex nested lists', () => {
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- <start>B2
|
||||
- A2
|
||||
- B3
|
||||
- C1<end>
|
||||
- D1
|
||||
- B4
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- <start>B2
|
||||
|
||||
A2
|
||||
|
||||
- B3
|
||||
- C1<end>
|
||||
- - D1
|
||||
- B4
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createDedentListCommand(),
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1
|
||||
|
||||
- D1
|
||||
|
||||
D1<start>
|
||||
|
||||
- A2
|
||||
|
||||
- B3
|
||||
|
||||
- C2
|
||||
|
||||
C2
|
||||
|
||||
- D2<end>
|
||||
|
||||
C2
|
||||
|
||||
- C3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2
|
||||
|
||||
- C1
|
||||
|
||||
- D1
|
||||
|
||||
D1<start>
|
||||
|
||||
A2
|
||||
|
||||
- B3
|
||||
|
||||
- C2
|
||||
|
||||
C2
|
||||
|
||||
- D2<end>
|
||||
|
||||
C2
|
||||
|
||||
- C3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('only needs one step for some of the most comment indent action', () => {
|
||||
const countSteps = (
|
||||
doc: TaggedProsemirrorNode,
|
||||
expected: TaggedProsemirrorNode,
|
||||
) => {
|
||||
t.add(doc)
|
||||
const state = t.view.state
|
||||
const command = createDedentListCommand()
|
||||
let count = -1
|
||||
let actual: ProsemirrorNode | null = null
|
||||
command(state, (tr) => {
|
||||
count = tr.steps.length
|
||||
actual = tr.doc
|
||||
})
|
||||
expect(actual).not.equal(null)
|
||||
expect(actual).toEqualRemirrorDocument(expected)
|
||||
return count
|
||||
}
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
- A1
|
||||
- B1<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B1<cursor>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
- A1
|
||||
- A2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
A2<cursor>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
# heading
|
||||
|
||||
- A1<cursor>
|
||||
`,
|
||||
markdown`
|
||||
# heading
|
||||
|
||||
A1<cursor>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
# heading
|
||||
|
||||
- - A1<cursor>
|
||||
`,
|
||||
markdown`
|
||||
# heading
|
||||
|
||||
- A1<cursor>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Fragment, NodeRange, Slice } from "prosemirror-model";
|
||||
import { type Command, type Transaction } from "prosemirror-state";
|
||||
import { ReplaceAroundStep } from "prosemirror-transform";
|
||||
|
||||
import { withVisibleSelection } from "./set-safe-selection";
|
||||
import { findListsRange, isListNode, isListsRange, getListType } from "prosemirror-flat-list";
|
||||
import { atStartBlockBoundary, atEndBlockBoundary } from "../utils/block-boundary";
|
||||
import { mapPos } from "../utils/map-pos";
|
||||
import { safeLift } from "../utils/safe-lift";
|
||||
import { zoomInRange } from "../utils/zoom-in-range";
|
||||
|
||||
/**
|
||||
* @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(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,19 @@
|
||||
import {
|
||||
chainCommands,
|
||||
createParagraphNear,
|
||||
newlineInCode,
|
||||
splitBlock,
|
||||
} from 'prosemirror-commands'
|
||||
import { type Command } from 'prosemirror-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,787 @@
|
||||
import { type TaggedProsemirrorNode } from 'jest-remirror'
|
||||
import { type Node as ProsemirrorNode } from 'prosemirror-model'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createIndentListCommand } from './indent-list'
|
||||
|
||||
describe('indentList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const markdown = t.markdown
|
||||
|
||||
const indentList = createIndentListCommand()
|
||||
|
||||
it('can indent a list node and append it to the previous list node', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A<cursor>2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A<cursor>2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- ## A<cursor>2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- ## A<cursor>2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- > ## A<cursor>2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- > ## A<cursor>2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can indent multiple list nodes and append them to the previous list node', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2<start>
|
||||
- A3<end>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A2<start>
|
||||
- A3<end>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should not wrap a paragraph with a new list node when it will bring a new visual bullet', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2a
|
||||
|
||||
A2b<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2a
|
||||
|
||||
A2b<cursor>
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2a
|
||||
|
||||
A2b<cursor>
|
||||
|
||||
A2c
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2a
|
||||
|
||||
A2b<cursor>
|
||||
|
||||
A2c
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can indent a paragraph and append it to the previous sibling list node', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2a
|
||||
|
||||
- B1
|
||||
|
||||
A2b<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2a
|
||||
|
||||
- B1
|
||||
|
||||
A2b<cursor>
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2a
|
||||
|
||||
- B1
|
||||
|
||||
A2b<cursor>
|
||||
|
||||
- B2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2a
|
||||
|
||||
- B1
|
||||
|
||||
A2b<cursor>
|
||||
|
||||
- B2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can only indent selected part when the selection across multiple depth of a nested lists', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1a
|
||||
|
||||
- B1a
|
||||
|
||||
- C1
|
||||
|
||||
B1b<start>
|
||||
|
||||
A1b<end>
|
||||
`,
|
||||
markdown`
|
||||
- A1a
|
||||
|
||||
- B1a
|
||||
|
||||
- C1
|
||||
|
||||
B1b<start>
|
||||
|
||||
A1b<end>
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1a
|
||||
|
||||
- B1a
|
||||
|
||||
- C1
|
||||
|
||||
B1b<start>
|
||||
|
||||
- B2
|
||||
|
||||
- B3
|
||||
|
||||
- B4
|
||||
|
||||
A1b<end>
|
||||
`,
|
||||
markdown`
|
||||
- A1a
|
||||
|
||||
- B1a
|
||||
|
||||
- C1
|
||||
|
||||
B1b<start>
|
||||
|
||||
- B2
|
||||
|
||||
- B3
|
||||
|
||||
- B4
|
||||
|
||||
A1b<end>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can indent multiple list nodes and append them to the previous list node', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A<start>2
|
||||
- A<end>3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A<start>2
|
||||
- A<end>3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can add ambitious indentations', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- B<cursor>2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- - B<cursor>2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can split the list when necessary', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B<cursor>2a
|
||||
|
||||
B2b
|
||||
|
||||
B2c
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- - B<cursor>2a
|
||||
|
||||
- B2b
|
||||
|
||||
B2c
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep attributes', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- [ ] A1
|
||||
- [x] A<cursor>2
|
||||
`,
|
||||
markdown`
|
||||
- [ ] A1
|
||||
- [x] A<cursor>2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
1. A1
|
||||
|
||||
2. A<cursor>2
|
||||
|
||||
- B1
|
||||
`,
|
||||
markdown`
|
||||
1. A1
|
||||
|
||||
1. A<cursor>2
|
||||
|
||||
- B1
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- [x] A1
|
||||
- B1
|
||||
- [x] A<cursor>2
|
||||
1. B2
|
||||
`,
|
||||
markdown`
|
||||
- [x] A1
|
||||
- B1
|
||||
- [x] A<cursor>2
|
||||
1. B2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep the indentation of sub list nodes', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- A2
|
||||
- A3<cursor>
|
||||
- B1
|
||||
- B2
|
||||
- B3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A2
|
||||
- A3<cursor>
|
||||
- B1
|
||||
- B2
|
||||
- B3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can move all collapsed content', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
t.doc(
|
||||
t.bulletList(t.p('A1')),
|
||||
t.bulletList(t.p('A2')),
|
||||
t.collapsedToggleList(
|
||||
t.p('A3<cursor>'),
|
||||
t.bulletList(t.p('B1')),
|
||||
t.bulletList(t.p('B2')),
|
||||
t.bulletList(t.p('B3')),
|
||||
),
|
||||
),
|
||||
t.doc(
|
||||
t.bulletList(t.p('A1')),
|
||||
t.bulletList(
|
||||
t.p('A2'),
|
||||
t.collapsedToggleList(
|
||||
t.p('A3<cursor>'),
|
||||
t.bulletList(t.p('B1')),
|
||||
t.bulletList(t.p('B2')),
|
||||
t.bulletList(t.p('B3')),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can expand a collapsed list node if something is indent into it', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
t.p('A1'),
|
||||
t.bulletList(t.p('B1')),
|
||||
t.bulletList(t.p('B2')),
|
||||
t.bulletList(t.p('B3')),
|
||||
),
|
||||
t.p('<cursor>'),
|
||||
),
|
||||
t.doc(
|
||||
t.expandedToggleList(
|
||||
t.p('A1'),
|
||||
t.bulletList(t.p('B1')),
|
||||
t.bulletList(t.p('B2')),
|
||||
t.bulletList(t.p('B3')),
|
||||
t.p('<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep the indentation of sub list nodes when moving multiple list', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- <start>A2
|
||||
- A3<end>
|
||||
- B1
|
||||
- B2
|
||||
- B3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- <start>A2
|
||||
- A3<end>
|
||||
- B1
|
||||
- B2
|
||||
- B3
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- A2<start>
|
||||
- B2<end>
|
||||
- C1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- A2<start>
|
||||
- B2<end>
|
||||
- C1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep the indentation of siblings around the indented item', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2<cursor>
|
||||
|
||||
A2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2<cursor>
|
||||
|
||||
A2
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2<cursor>
|
||||
|
||||
A2
|
||||
|
||||
- B1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2<cursor>
|
||||
|
||||
A2
|
||||
|
||||
- B1
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2
|
||||
|
||||
- B1
|
||||
|
||||
A2<cursor>
|
||||
|
||||
A2
|
||||
|
||||
- B1
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- A2
|
||||
|
||||
- B1
|
||||
|
||||
A2<cursor>
|
||||
|
||||
A2
|
||||
|
||||
- B1
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
A1
|
||||
|
||||
- <cursor>A2
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
A1
|
||||
|
||||
- <cursor>A2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can indent a paragraph that not inside a list node', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
P1<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
P1<cursor>
|
||||
`,
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
P1<start>
|
||||
|
||||
P2<end>
|
||||
|
||||
P3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
P1<start>
|
||||
|
||||
P2<end>
|
||||
|
||||
P3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can accept custom positions', () => {
|
||||
t.applyCommand(
|
||||
createIndentListCommand({ from: 13, to: 17 }),
|
||||
t.doc(
|
||||
/*0*/
|
||||
t.bulletList(/*1*/ t.p('A1') /*5*/),
|
||||
/*6*/
|
||||
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
|
||||
/*12*/
|
||||
t.bulletList(/*13*/ t.p('A3') /*17*/),
|
||||
/*18*/
|
||||
),
|
||||
t.doc(
|
||||
t.bulletList(t.p('A1')),
|
||||
t.bulletList(t.p('A2'), t.bulletList(t.p('A3'))),
|
||||
),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createIndentListCommand({ from: 10, to: 17 }),
|
||||
t.doc(
|
||||
/*0*/
|
||||
t.bulletList(/*1*/ t.p('A1') /*5*/),
|
||||
/*6*/
|
||||
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
|
||||
/*12*/
|
||||
t.bulletList(/*13*/ t.p('A3') /*17*/),
|
||||
/*18*/
|
||||
),
|
||||
t.doc(
|
||||
t.bulletList(
|
||||
t.p('A1'),
|
||||
t.bulletList(t.p('A2')),
|
||||
t.bulletList(t.p('A3')),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can handle some complex nested lists', () => {
|
||||
t.applyCommand(
|
||||
indentList,
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- <start>B2
|
||||
- A2
|
||||
- B3
|
||||
- C1<end>
|
||||
- D1
|
||||
- B4
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B1
|
||||
- <start>B2
|
||||
- A2
|
||||
- B3
|
||||
- C1<end>
|
||||
- D1
|
||||
- B4
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('only needs one step for some of the most comment indent action', () => {
|
||||
const countSteps = (
|
||||
doc: TaggedProsemirrorNode,
|
||||
expected: TaggedProsemirrorNode,
|
||||
) => {
|
||||
t.add(doc)
|
||||
const state = t.view.state
|
||||
const command = createIndentListCommand()
|
||||
let count = -1
|
||||
let actual: ProsemirrorNode | null = null
|
||||
command(state, (tr) => {
|
||||
count = tr.steps.length
|
||||
actual = tr.doc
|
||||
})
|
||||
expect(actual).not.equal(null)
|
||||
expect(actual).toEqualRemirrorDocument(expected)
|
||||
return count
|
||||
}
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
- A1
|
||||
- A2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A2<cursor>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
- A1
|
||||
- [ ] A2<cursor>
|
||||
- [x] A3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- [ ] A2<cursor>
|
||||
- [x] A3
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
1. A1
|
||||
2. <start>A2
|
||||
3. A3<end>
|
||||
4. A4
|
||||
`,
|
||||
markdown`
|
||||
1. A1
|
||||
1. <start>A2
|
||||
2. A3<end>
|
||||
2. A4
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
1. A1
|
||||
- B1
|
||||
- <start>B2
|
||||
- B3
|
||||
- B4<end>
|
||||
`,
|
||||
markdown`
|
||||
1. A1
|
||||
- B1
|
||||
- <start>B2
|
||||
- B3
|
||||
- B4<end>
|
||||
`,
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
// For more complex (and less common) cases, more steps is acceptable
|
||||
expect(
|
||||
countSteps(
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- C1
|
||||
|
||||
- D1
|
||||
|
||||
D1b
|
||||
|
||||
- <start>D2
|
||||
|
||||
C1b
|
||||
|
||||
C1c
|
||||
|
||||
- B2
|
||||
|
||||
- C2
|
||||
|
||||
- A2
|
||||
|
||||
- A3
|
||||
|
||||
- B3
|
||||
|
||||
B3b
|
||||
|
||||
- B4<end>
|
||||
|
||||
A3b
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- C1
|
||||
|
||||
- D1
|
||||
|
||||
D1b
|
||||
|
||||
- <start>D2
|
||||
|
||||
C1b
|
||||
|
||||
C1c
|
||||
|
||||
- B2
|
||||
|
||||
- C2
|
||||
|
||||
- A2
|
||||
|
||||
- A3
|
||||
|
||||
- B3
|
||||
|
||||
B3b
|
||||
|
||||
- B4<end>
|
||||
|
||||
A3b
|
||||
`,
|
||||
),
|
||||
).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Fragment, type NodeRange, Slice } from "prosemirror-model";
|
||||
import { type Command, type Transaction } from "prosemirror-state";
|
||||
import { ReplaceAroundStep } from "prosemirror-transform";
|
||||
|
||||
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";
|
||||
import { ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
/**
|
||||
* @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 "prosemirror-model";
|
||||
import { type Command, TextSelection } from "prosemirror-state";
|
||||
|
||||
import { atTextblockStart } from "../utils/at-textblock-start";
|
||||
import { isListNode } from "../utils/is-list-node";
|
||||
|
||||
import { joinTextblocksAround } from "./join-textblocks-around";
|
||||
import { ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
/**
|
||||
* 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,76 @@
|
||||
import { NodeRange, type ResolvedPos } from 'prosemirror-model'
|
||||
import {
|
||||
type Command,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from 'prosemirror-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 { 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 'prosemirror-model'
|
||||
import { TextSelection, type Transaction } from 'prosemirror-state'
|
||||
import { replaceStep, ReplaceStep } from 'prosemirror-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,208 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { backspaceCommand } from './keymap'
|
||||
|
||||
describe('Keymap', () => {
|
||||
const t = setupTestingEditor()
|
||||
const markdown = t.markdown
|
||||
|
||||
describe('Backspace', () => {
|
||||
it('should delete the empty paragraph between two list nodes', () => {
|
||||
t.applyCommand(
|
||||
backspaceCommand,
|
||||
t.doc(
|
||||
t.bulletList(t.p('A1')),
|
||||
t.p('<cursor>'),
|
||||
t.bulletList(t.p('A2')),
|
||||
),
|
||||
t.doc(t.bulletList(t.p('A1')), t.bulletList(t.p('A2'))),
|
||||
)
|
||||
})
|
||||
|
||||
it('can handle nested list', () => {
|
||||
const doc1 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- <cursor>B2
|
||||
`
|
||||
const doc2 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
<cursor>B2
|
||||
`
|
||||
const doc3 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
<cursor>B2
|
||||
`
|
||||
const doc4 = markdown`
|
||||
- A1
|
||||
|
||||
- B1<cursor>B2
|
||||
`
|
||||
|
||||
t.add(doc1)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc3)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc4)
|
||||
})
|
||||
|
||||
it('can handle nested list with multiple children', () => {
|
||||
const doc1 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- <cursor>B2a
|
||||
|
||||
B2b
|
||||
|
||||
B2c
|
||||
`
|
||||
const doc2 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
<cursor>B2a
|
||||
|
||||
B2b
|
||||
|
||||
B2c
|
||||
`
|
||||
const doc3 = markdown`
|
||||
- A1
|
||||
|
||||
- B1<cursor>B2a
|
||||
|
||||
B2b
|
||||
|
||||
B2c
|
||||
`
|
||||
|
||||
t.add(doc1)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc3)
|
||||
})
|
||||
|
||||
it('can handle cursor in the middle child', () => {
|
||||
const doc1 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2a
|
||||
|
||||
<cursor>B2b
|
||||
|
||||
B2c
|
||||
`
|
||||
const doc2 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2a<cursor>B2b
|
||||
|
||||
B2c
|
||||
`
|
||||
t.add(doc1)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
})
|
||||
|
||||
it('can handle cursor in the last child', () => {
|
||||
const doc1 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2a
|
||||
|
||||
B2b
|
||||
|
||||
<cursor>B2c
|
||||
`
|
||||
const doc2 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2a
|
||||
|
||||
B2b
|
||||
|
||||
<cursor>B2c
|
||||
`
|
||||
const doc3 = markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- B2a
|
||||
|
||||
B2b
|
||||
|
||||
<cursor>B2c
|
||||
`
|
||||
t.add(doc1)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
t.editor.press('Backspace')
|
||||
expect(t.editor.state).toEqualRemirrorState(doc3)
|
||||
})
|
||||
|
||||
it('can skip collapsed content', () => {
|
||||
t.applyCommand(
|
||||
backspaceCommand,
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
//
|
||||
t.p('A1'),
|
||||
t.bulletList(t.p('B1')),
|
||||
),
|
||||
t.p('<cursor>A2'),
|
||||
),
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
//
|
||||
t.p('A1<cursor>A2'),
|
||||
t.bulletList(t.p('B1')),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
backspaceCommand,
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
//
|
||||
t.p('A1'),
|
||||
t.bulletList(t.p('B1')),
|
||||
),
|
||||
t.blockquote(t.p('<cursor>A2')),
|
||||
),
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
//
|
||||
t.p('A1<cursor>A2'),
|
||||
t.bulletList(t.p('B1')),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
chainCommands,
|
||||
deleteSelection,
|
||||
joinTextblockBackward,
|
||||
joinTextblockForward,
|
||||
selectNodeBackward,
|
||||
selectNodeForward,
|
||||
} from 'prosemirror-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,84 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createMoveListCommand } from './move-list'
|
||||
|
||||
describe('moveList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const markdown = t.markdown
|
||||
const moveUp = createMoveListCommand('up')
|
||||
const moveDown = createMoveListCommand('down')
|
||||
|
||||
it('can move up list nodes', () => {
|
||||
t.applyCommand(
|
||||
moveUp,
|
||||
markdown`
|
||||
- A1
|
||||
- A2<start>
|
||||
- A3<end>
|
||||
`,
|
||||
markdown`
|
||||
- A2<start>
|
||||
- A3<end>
|
||||
- A1
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can move up and dedent list nodes to parent list', () => {
|
||||
t.applyCommand(
|
||||
moveUp,
|
||||
markdown`
|
||||
- A1
|
||||
- A2
|
||||
- B1<start>
|
||||
- B2<end>
|
||||
- B3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- B1<start>
|
||||
- B2<end>
|
||||
- A2
|
||||
- B3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can move down list nodes', () => {
|
||||
t.applyCommand(
|
||||
moveDown,
|
||||
markdown`
|
||||
- A1<start>
|
||||
- A2<end>
|
||||
- A3
|
||||
`,
|
||||
markdown`
|
||||
- A3
|
||||
- A1<start>
|
||||
- A2<end>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can move down and dedent list nodes to parent list', () => {
|
||||
t.applyCommand(
|
||||
moveDown,
|
||||
markdown`
|
||||
- A1
|
||||
- A2
|
||||
- B1<start>
|
||||
- B2<end>
|
||||
- A3
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
- A2
|
||||
- A3
|
||||
- B1<start>
|
||||
- B2<end>
|
||||
`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type Command, type Transaction } from 'prosemirror-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,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
describe('protectCollapsed', () => {
|
||||
const { add, doc, p, editor, collapsedToggleList, expandedToggleList } =
|
||||
setupTestingEditor()
|
||||
|
||||
it('can skip collapsed content', () => {
|
||||
// Cursor in the last paragraph of the item
|
||||
add(
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('1<start>23'),
|
||||
p('456'),
|
||||
),
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('4<end>56'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
expandedToggleList(
|
||||
//
|
||||
p('1<start>23'),
|
||||
p('456'),
|
||||
),
|
||||
expandedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('4<end>56'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Command } from 'prosemirror-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,154 @@
|
||||
import { type Command } from 'prosemirror-state'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { setSafeSelection } from './set-safe-selection'
|
||||
|
||||
describe('setSafeSelection', () => {
|
||||
const {
|
||||
doc,
|
||||
p,
|
||||
collapsedToggleList,
|
||||
expandedToggleList,
|
||||
bulletList,
|
||||
applyCommand,
|
||||
} = setupTestingEditor()
|
||||
|
||||
const command: Command = (state, dispatch) => {
|
||||
dispatch?.(setSafeSelection(state.tr))
|
||||
return true
|
||||
}
|
||||
|
||||
it('can move cursor outside of collapsed content', () => {
|
||||
applyCommand(
|
||||
command,
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('45<cursor>6'),
|
||||
),
|
||||
),
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can move cursor outside of collapsed and deep sub list', () => {
|
||||
applyCommand(
|
||||
command,
|
||||
doc(
|
||||
bulletList(
|
||||
bulletList(
|
||||
bulletList(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('45<cursor>6'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
doc(
|
||||
bulletList(
|
||||
bulletList(
|
||||
bulletList(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not change if the cursor is visible ', () => {
|
||||
applyCommand(
|
||||
command,
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('12<cursor>3'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('12<cursor>3'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can handle from position', () => {
|
||||
applyCommand(
|
||||
command,
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('45<start>6'),
|
||||
),
|
||||
expandedToggleList(
|
||||
//
|
||||
p('12<end>3'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
expandedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can handle to position', () => {
|
||||
applyCommand(
|
||||
command,
|
||||
doc(
|
||||
expandedToggleList(
|
||||
//
|
||||
p('1<start>23'),
|
||||
p('456'),
|
||||
),
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('4<end>56'),
|
||||
),
|
||||
),
|
||||
doc(
|
||||
expandedToggleList(
|
||||
//
|
||||
p('123'),
|
||||
p('456'),
|
||||
),
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('123<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type ResolvedPos } from 'prosemirror-model'
|
||||
import {
|
||||
type Selection,
|
||||
TextSelection,
|
||||
type Transaction,
|
||||
} from 'prosemirror-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,516 @@
|
||||
import { NodeSelection } from 'prosemirror-state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { enterCommand } from './keymap'
|
||||
|
||||
describe('splitList', () => {
|
||||
const {
|
||||
add,
|
||||
doc,
|
||||
p,
|
||||
bulletList,
|
||||
blockquote,
|
||||
editor,
|
||||
markdown,
|
||||
applyCommand,
|
||||
collapsedToggleList,
|
||||
expandedToggleList,
|
||||
checkedTaskList,
|
||||
uncheckedTaskList,
|
||||
} = setupTestingEditor()
|
||||
|
||||
it('can split non-empty item', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123
|
||||
- 234<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
- 234
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123
|
||||
- 23<cursor>4
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
- 23
|
||||
- <cursor>4
|
||||
`,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 1<cursor>23
|
||||
- 234
|
||||
`,
|
||||
markdown`
|
||||
- 1
|
||||
- <cursor>23
|
||||
- 234
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can split non-empty sub item', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123
|
||||
- 456<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
- 456
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can delete empty item', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
|
||||
<cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123
|
||||
- <cursor>
|
||||
- 456
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
|
||||
<cursor>
|
||||
|
||||
- 456
|
||||
`,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- <cursor>
|
||||
- 123
|
||||
`,
|
||||
markdown`
|
||||
<cursor>
|
||||
|
||||
- 123
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can dedent the last empty sub item', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
markdown`
|
||||
- A1
|
||||
|
||||
- B1
|
||||
|
||||
- <cursor>
|
||||
|
||||
paragraph
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can delete selected text', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- <start>123<end>
|
||||
- 456
|
||||
`,
|
||||
markdown`
|
||||
-
|
||||
- <cusror>
|
||||
- 456
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can set attributes correctly', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
doc(
|
||||
checkedTaskList(p('<cursor>A1')),
|
||||
uncheckedTaskList(p('A2')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
doc(
|
||||
uncheckedTaskList(p('')),
|
||||
checkedTaskList(p('<cursor>A1')),
|
||||
uncheckedTaskList(p('A2')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
doc(
|
||||
uncheckedTaskList(p('A1')),
|
||||
checkedTaskList(p('A2<cursor>')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
doc(
|
||||
uncheckedTaskList(p('A1')),
|
||||
checkedTaskList(p('A2')),
|
||||
uncheckedTaskList(p('<cursor>')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
doc(
|
||||
uncheckedTaskList(p('A1')),
|
||||
checkedTaskList(p('A<cursor>2')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
doc(
|
||||
uncheckedTaskList(p('A1')),
|
||||
checkedTaskList(p('A')),
|
||||
uncheckedTaskList(p('<cursor>2')),
|
||||
uncheckedTaskList(p('A3')),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the item when the cursor is in the first paragraph of the item', () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- 123<cursor>
|
||||
|
||||
456
|
||||
|
||||
789
|
||||
`,
|
||||
markdown`
|
||||
- 123
|
||||
|
||||
- <cursor>
|
||||
|
||||
456
|
||||
|
||||
789
|
||||
`,
|
||||
)
|
||||
|
||||
// Nested list item
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
- Parent
|
||||
|
||||
- 123<cursor>
|
||||
|
||||
456
|
||||
|
||||
789
|
||||
`,
|
||||
markdown`
|
||||
- Parent
|
||||
|
||||
- 123
|
||||
|
||||
- <cursor>
|
||||
|
||||
456
|
||||
|
||||
789
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can create new paragraph when the caret is not inside the first child of the list', () => {
|
||||
// Cursor in the last paragraph of the item
|
||||
add(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('456<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('456'),
|
||||
p('<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Cursor in the middle paragraph of the item
|
||||
add(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('456<cursor>'),
|
||||
p('789'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('456'),
|
||||
p('<cursor>'),
|
||||
p('789'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Cursor in the last paragraph of the item (nested list item)
|
||||
add(
|
||||
doc(
|
||||
bulletList(
|
||||
p('parent'),
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('<cursor>456'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
bulletList(
|
||||
p('parent'),
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p(''),
|
||||
p('<cursor>456'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
add(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p(''),
|
||||
p('<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
add(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p('<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
bulletList(
|
||||
//
|
||||
p('123'),
|
||||
p(''),
|
||||
p('<cursor>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can skip collapsed content', () => {
|
||||
// Cursor in the last paragraph of the item
|
||||
add(
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('1<start>23<end>'),
|
||||
p('456'),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
collapsedToggleList(
|
||||
//
|
||||
p('1'),
|
||||
p('456'),
|
||||
),
|
||||
expandedToggleList(
|
||||
//
|
||||
p('<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it("won't effect non-list document", () => {
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
# h1
|
||||
|
||||
1<cursor>23
|
||||
`,
|
||||
null,
|
||||
)
|
||||
|
||||
applyCommand(
|
||||
enterCommand,
|
||||
markdown`
|
||||
# h1
|
||||
|
||||
123
|
||||
|
||||
> 4<cursor>56
|
||||
`,
|
||||
null,
|
||||
)
|
||||
|
||||
add(
|
||||
doc(
|
||||
blockquote(
|
||||
p('123'),
|
||||
blockquote(
|
||||
//
|
||||
p('4<cursor>56'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
editor.press('Enter')
|
||||
expect(editor.state).toEqualRemirrorState(
|
||||
doc(
|
||||
blockquote(
|
||||
p('123'),
|
||||
blockquote(
|
||||
//
|
||||
p('4'),
|
||||
p('<cursor>56'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can split list node for a block node selection', () => {
|
||||
add(markdown`
|
||||
# h1
|
||||
|
||||
1. ***
|
||||
`)
|
||||
|
||||
let hrPos = -1
|
||||
editor.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'horizontalRule') {
|
||||
hrPos = pos
|
||||
}
|
||||
})
|
||||
|
||||
expect(hrPos > -1).toBe(true)
|
||||
const nodeSelection = NodeSelection.create(editor.state.doc, hrPos)
|
||||
editor.view.dispatch(editor.view.state.tr.setSelection(nodeSelection))
|
||||
expect(editor.view.state.selection.toJSON()).toMatchInlineSnapshot(`
|
||||
{
|
||||
"anchor": 5,
|
||||
"type": "node",
|
||||
}
|
||||
`)
|
||||
|
||||
editor.press('Enter')
|
||||
|
||||
expect(editor.state).toEqualRemirrorState(markdown`
|
||||
# h1
|
||||
|
||||
1. ***
|
||||
2. <cursor>\n
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { chainCommands } from "@tiptap/pm/commands";
|
||||
import { isTextSelection } from "@tiptap/core";
|
||||
import { canSplit } from "@tiptap/pm/transform";
|
||||
import {
|
||||
type NodeSelection,
|
||||
type Command,
|
||||
type EditorState,
|
||||
Selection,
|
||||
TextSelection,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
|
||||
import { NodeType, Attrs, Mark, Fragment, type Node as ProsemirrorNode, Slice } from "@tiptap/pm/model";
|
||||
|
||||
import { ListAttributes, isListNode } from "prosemirror-flat-list";
|
||||
|
||||
/**
|
||||
* Returns a command that split the current list node.
|
||||
*
|
||||
* @public @group Commands
|
||||
*
|
||||
*/
|
||||
export function createSplitListCommand(): Command {
|
||||
return 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;
|
||||
}
|
||||
console.log("aaya 2");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return doSplitList(state, listNode, dispatch);
|
||||
};
|
||||
|
||||
/**
|
||||
* @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 && $from.index($from.depth - 1) === 0;
|
||||
|
||||
const atEnd = parentOffset == $to.parent.content.size;
|
||||
|
||||
const currentNode = $from.node($from.depth);
|
||||
// // __AUTO_GENERATED_PRINT_VAR_START__
|
||||
// console.log("doSplitList currentNode: %s", currentNode.ty); // __AUTO_GENERATED_PRINT_VAR_END__
|
||||
if (currentNode.type.name !== "paragraph") {
|
||||
console.log("ran fasle");
|
||||
return false;
|
||||
}
|
||||
// is at start and not the second child of a list
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function isBlockNodeSelection(selection: Selection): selection is NodeSelection {
|
||||
const isNodeSelectionBool = isNodeSelection(selection) && selection.node.type.isBlock;
|
||||
return isNodeSelectionBool;
|
||||
}
|
||||
|
||||
export function isNodeSelection(selection: Selection): selection is NodeSelection {
|
||||
return Boolean((selection as NodeSelection).node);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createToggleCollapsedCommand } from './toggle-collapsed'
|
||||
|
||||
describe('toggleCollapsed', () => {
|
||||
const t = setupTestingEditor()
|
||||
|
||||
it('can toggle collapsed attribute', () => {
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand(),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand(),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
})
|
||||
|
||||
it('can set collapsed value', () => {
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand({ collapsed: true }),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand({ collapsed: true }),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand({ collapsed: false }),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand({ collapsed: false }),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
|
||||
)
|
||||
})
|
||||
|
||||
it('can skip non-collapsed node', () => {
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand(),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
|
||||
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
|
||||
)
|
||||
|
||||
t.applyCommand(
|
||||
createToggleCollapsedCommand(),
|
||||
t.doc(t.expandedToggleList(t.p('A1'), t.orderedList(t.p('B1<cursor>')))),
|
||||
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.orderedList(t.p('B1')))),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type Command } from "prosemirror-state";
|
||||
|
||||
import { isListNode } from "../utils/is-list-node";
|
||||
|
||||
import { setSafeSelection } from "./set-safe-selection";
|
||||
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
/**
|
||||
* @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,46 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createToggleListCommand } from './toggle-list'
|
||||
|
||||
describe('toggleList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const { doc, p, orderedList, bulletList, uncheckedTaskList } = t
|
||||
const toggleList = createToggleListCommand({ kind: 'ordered' })
|
||||
|
||||
it('can toggle list', () => {
|
||||
const doc1 = doc(p('P1<cursor>'), p('P2'))
|
||||
const doc2 = doc(orderedList(p('P1<cursor>')), p('P2'))
|
||||
|
||||
t.applyCommand(toggleList, doc1, doc2)
|
||||
t.applyCommand(toggleList, doc2, doc1)
|
||||
})
|
||||
|
||||
it('can toggle list with multiple selected paragraphs', () => {
|
||||
const doc1 = doc(p('P1'), p('<start>P2'), p('P3<end>'), p('P4'))
|
||||
const doc2 = doc(
|
||||
p('P1'),
|
||||
orderedList(p('<start>P2')),
|
||||
orderedList(p('P3<end>')),
|
||||
p('P4'),
|
||||
)
|
||||
|
||||
t.applyCommand(toggleList, doc1, doc2)
|
||||
t.applyCommand(toggleList, doc2, doc1)
|
||||
})
|
||||
|
||||
it('can toggle a list to another kind', () => {
|
||||
const toggleBullet = createToggleListCommand({ kind: 'bullet' })
|
||||
const toggleTask = createToggleListCommand({ kind: 'task' })
|
||||
|
||||
const doc1 = doc(p('P1<cursor>'), p('P2'))
|
||||
const doc2 = doc(uncheckedTaskList(p('P1<cursor>')), p('P2'))
|
||||
const doc3 = doc(bulletList(p('P1<cursor>')), p('P2'))
|
||||
|
||||
t.applyCommand(toggleTask, doc1, doc2)
|
||||
t.applyCommand(toggleBullet, doc2, doc3)
|
||||
t.applyCommand(toggleTask, doc3, doc2)
|
||||
t.applyCommand(toggleTask, doc2, doc1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { chainCommands } from "prosemirror-commands";
|
||||
import { type Command } from "prosemirror-state";
|
||||
|
||||
import { createUnwrapListCommand } from "./unwrap-list";
|
||||
import { createWrapInListCommand } from "./wrap-in-list";
|
||||
import { ListAttributes } from "prosemirror-flat-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,77 @@
|
||||
import { NodeSelection } from 'prosemirror-state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
import { type ListAttributes } from '../types'
|
||||
|
||||
import { createUnwrapListCommand } from './unwrap-list'
|
||||
|
||||
describe('unwrapList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const { doc, p, bulletList, orderedList, checkedTaskList } = t
|
||||
const unwrapList = createUnwrapListCommand()
|
||||
|
||||
it('can unwrap a list node selection', () => {
|
||||
const doc1 = doc(bulletList(p('P1'), p('P2')))
|
||||
const doc2 = doc(p('P1'), p('P2'))
|
||||
|
||||
t.add(doc1)
|
||||
const selection = new NodeSelection(t.view.state.doc.resolve(0))
|
||||
expect(selection.node.type.name).toEqual('list')
|
||||
t.view.dispatch(t.view.state.tr.setSelection(selection))
|
||||
|
||||
expect(t.dispatchCommand(unwrapList)).toEqual(true)
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
})
|
||||
|
||||
it('can unwrap a list node selection in a nested list', () => {
|
||||
const doc1 = doc(orderedList(checkedTaskList(p('P1'), p('P2'))))
|
||||
const doc2 = doc(orderedList(p('P1'), p('P2')))
|
||||
|
||||
t.add(doc1)
|
||||
const selection = new NodeSelection(t.view.state.doc.resolve(1))
|
||||
expect(selection.node.type.name).toEqual('list')
|
||||
expect((selection.node.attrs as ListAttributes).kind).toEqual('task')
|
||||
t.view.dispatch(t.view.state.tr.setSelection(selection))
|
||||
|
||||
expect(t.dispatchCommand(unwrapList)).toEqual(true)
|
||||
expect(t.editor.state).toEqualRemirrorState(doc2)
|
||||
})
|
||||
|
||||
it('can unwrap a paragraph inside a list node', () => {
|
||||
const doc1 = doc(orderedList(p('P<cursor>1')))
|
||||
const doc2 = doc(p('P<cursor>1'))
|
||||
t.applyCommand(unwrapList, doc1, doc2)
|
||||
})
|
||||
|
||||
it('can unwrap all paragraphs inside a list node event only part of them are selected', () => {
|
||||
const doc1 = doc(orderedList(p('P1'), p('P2<cursor>'), p('P3')))
|
||||
const doc2 = doc(p('P1'), p('P2<cursor>'), p('P3'))
|
||||
t.applyCommand(unwrapList, doc1, doc2)
|
||||
})
|
||||
|
||||
it('can unwrap all paragraphs inside a list node', () => {
|
||||
const doc1 = doc(orderedList(p('<start>P1'), p('P2'), p('P3<end>')))
|
||||
const doc2 = doc(p('<start>P1'), p('P2'), p('P3<end>'))
|
||||
t.applyCommand(unwrapList, doc1, doc2)
|
||||
})
|
||||
|
||||
it('can unwrap multiple lists', () => {
|
||||
const doc1 = doc(
|
||||
p('P1'),
|
||||
orderedList(p('P2<start>')),
|
||||
orderedList(p('P3')),
|
||||
orderedList(p('P4<end>'), p('P5')),
|
||||
orderedList(p('P6')),
|
||||
)
|
||||
const doc2 = doc(
|
||||
p('P1'),
|
||||
p('P2<start>'),
|
||||
p('P3'),
|
||||
p('P4<end>'),
|
||||
p('P5'),
|
||||
orderedList(p('P6')),
|
||||
)
|
||||
t.applyCommand(unwrapList, doc1, doc2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type NodeRange } from "prosemirror-model";
|
||||
import { type Command } from "prosemirror-state";
|
||||
|
||||
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";
|
||||
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-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,163 @@
|
||||
import { Selection } from 'prosemirror-state'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { createWrapInListCommand } from './wrap-in-list'
|
||||
|
||||
describe('wrapInList', () => {
|
||||
const t = setupTestingEditor()
|
||||
const markdown = t.markdown
|
||||
|
||||
const wrapInBulletList = createWrapInListCommand({ kind: 'bullet' })
|
||||
const wrapInOrderedList = createWrapInListCommand({ kind: 'ordered' })
|
||||
const wrapInTaskList = createWrapInListCommand({ kind: 'task' })
|
||||
|
||||
it('can wrap a paragraph node to a list node', () => {
|
||||
t.applyCommand(
|
||||
wrapInBulletList,
|
||||
markdown`
|
||||
P1
|
||||
|
||||
P2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
P1
|
||||
|
||||
- P2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can wrap multiple paragraph nodes to list nodes', () => {
|
||||
t.applyCommand(
|
||||
wrapInTaskList,
|
||||
markdown`
|
||||
P1
|
||||
|
||||
P2<start>
|
||||
|
||||
P3<end>
|
||||
`,
|
||||
markdown`
|
||||
P1
|
||||
|
||||
- [ ] P2
|
||||
- [ ] P3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can change the type of an existing list node', () => {
|
||||
t.applyCommand(
|
||||
wrapInOrderedList,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
- P2<cursor>
|
||||
`,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
1. P2
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can change the type of multiple existing list nodes', () => {
|
||||
t.applyCommand(
|
||||
wrapInTaskList,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
- P2<start>
|
||||
|
||||
1. P3<end>
|
||||
`,
|
||||
markdown`
|
||||
- P1
|
||||
- [ ] P2
|
||||
- [ ] P3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can keep the type of a list node with multiple paragraphs', () => {
|
||||
t.applyCommand(
|
||||
wrapInBulletList,
|
||||
markdown`
|
||||
- P1<cursor>
|
||||
|
||||
P2
|
||||
|
||||
P3
|
||||
|
||||
P4
|
||||
`,
|
||||
markdown`
|
||||
- P1<cursor>
|
||||
|
||||
P2
|
||||
|
||||
P3
|
||||
|
||||
P4
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can wrap a paragraph inside a list node to a sub-list node', () => {
|
||||
t.applyCommand(
|
||||
wrapInBulletList,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
P2<cursor>
|
||||
|
||||
P3
|
||||
`,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
- P2<cursor>
|
||||
|
||||
P3
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('can wrap multiple paragraphs inside a list node to a sub-list node', () => {
|
||||
t.applyCommand(
|
||||
wrapInBulletList,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
P2<start>
|
||||
|
||||
P3<end>
|
||||
`,
|
||||
markdown`
|
||||
- P1
|
||||
|
||||
- P2<start>
|
||||
|
||||
- P3<end>
|
||||
`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle block node without content', () => {
|
||||
const doc1 = t.doc(/*0*/ t.p() /*2*/, t.horizontalRule() /*3*/)
|
||||
const doc2 = t.doc(t.p(), t.bulletList(t.horizontalRule()))
|
||||
|
||||
t.add(doc1)
|
||||
const view = t.view
|
||||
const selection = Selection.atEnd(view.state.doc)
|
||||
expect(selection.from).toBe(2)
|
||||
view.dispatch(view.state.tr.setSelection(selection))
|
||||
|
||||
wrapInBulletList(view.state, view.dispatch.bind(view), view)
|
||||
|
||||
expect(view.state.doc).toEqualRemirrorDocument(doc2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NodeRange } from "prosemirror-model";
|
||||
import { type Command } from "prosemirror-state";
|
||||
import { findWrapping } from "prosemirror-transform";
|
||||
|
||||
import { getListType } from "../utils/get-list-type";
|
||||
import { isListNode } from "../utils/is-list-node";
|
||||
import { setNodeAttributes } from "../utils/set-node-attributes";
|
||||
import { ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
/**
|
||||
* 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,153 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import {
|
||||
createListSpec,
|
||||
createListPlugins,
|
||||
listKeymap,
|
||||
listInputRules,
|
||||
ListAttributes,
|
||||
createWrapInListCommand,
|
||||
DedentListOptions,
|
||||
IndentListOptions,
|
||||
createIndentListCommand,
|
||||
createDedentListCommand,
|
||||
enterWithoutLift,
|
||||
} from "prosemirror-flat-list";
|
||||
import { keymap } from "@tiptap/pm/keymap";
|
||||
import { inputRules } from "@tiptap/pm/inputrules";
|
||||
import { createSplitListCommand } from "./commands/split-list";
|
||||
|
||||
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];
|
||||
},
|
||||
});
|
||||
112
packages/editor/src/core/extensions/flat-list/flat-list.ts
Normal file
112
packages/editor/src/core/extensions/flat-list/flat-list.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import {
|
||||
createListSpec,
|
||||
createListPlugins,
|
||||
listKeymap,
|
||||
listInputRules,
|
||||
ListAttributes,
|
||||
createWrapInListCommand,
|
||||
DedentListOptions,
|
||||
IndentListOptions,
|
||||
createIndentListCommand,
|
||||
createDedentListCommand,
|
||||
} from "prosemirror-flat-list";
|
||||
import { keymap } from "@tiptap/pm/keymap";
|
||||
import { inputRules } from "@tiptap/pm/inputrules";
|
||||
import { createSplitListCommand } from "./commands/split-list";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
flatListComponent: {
|
||||
createList: (attrs: ListAttributes) => ReturnType;
|
||||
indentList: (attrs: IndentListOptions) => ReturnType;
|
||||
dedentList: (attrs: DedentListOptions) => ReturnType;
|
||||
splitList: () => ReturnType;
|
||||
// unwrapList: (attrs: UnwrapListOptions) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { attrs, parseDOM, toDOM, content, group, definingForContent, definingAsContext } = createListSpec();
|
||||
const listKeymapPlugin = keymap(listKeymap);
|
||||
const listInputRulePlugin = inputRules({ rules: listInputRules });
|
||||
|
||||
export const FlatListExtension = Node.create({
|
||||
name: "list",
|
||||
content,
|
||||
group,
|
||||
definingForContent,
|
||||
definingAsContext,
|
||||
disableDropCursor: true,
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
if (editor.isActive(this.name)) {
|
||||
// return editor.chain().focus().indentList({ from: $from.pos });
|
||||
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)) {
|
||||
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];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ListAttributes, ListKind, ProsemirrorNodeJSON } from "prosemirror-flat-list";
|
||||
|
||||
function migrateNodes(nodes: ProsemirrorNodeJSON[]): [ProsemirrorNodeJSON[], boolean] {
|
||||
const content: ProsemirrorNodeJSON[] = [];
|
||||
let updated = false;
|
||||
|
||||
for (const node of nodes) {
|
||||
console.log("node", node.type);
|
||||
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,175 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ListDOMSerializer > can serialize list nodes into <ol> 1`] = `
|
||||
<DocumentFragment>
|
||||
<ol>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
A
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
B
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`ListDOMSerializer > can serialize list nodes into <ul> 1`] = `
|
||||
<DocumentFragment>
|
||||
<ul>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
A
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
B
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`ListDOMSerializer > can serialize list nodes with different types into a single <ul> 1`] = `
|
||||
<DocumentFragment>
|
||||
<ul>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
A
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="task"
|
||||
>
|
||||
<p>
|
||||
B
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="toggle"
|
||||
>
|
||||
<p>
|
||||
C
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<ol>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
D
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<ul>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
D
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="task"
|
||||
>
|
||||
<p>
|
||||
E
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="toggle"
|
||||
>
|
||||
<p>
|
||||
D
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`ListDOMSerializer > can serialize nested list node 1`] = `
|
||||
<DocumentFragment>
|
||||
<ul>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-collapsable=""
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
A
|
||||
</p>
|
||||
<ol>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
B
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
C
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-collapsable=""
|
||||
data-list-kind="bullet"
|
||||
>
|
||||
<p>
|
||||
D
|
||||
</p>
|
||||
<ol>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
E
|
||||
</p>
|
||||
</li>
|
||||
<li
|
||||
class="prosemirror-flat-list"
|
||||
data-list-kind="ordered"
|
||||
>
|
||||
<p>
|
||||
F
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type ResolvedPos } from 'prosemirror-model'
|
||||
import { type EditorState, type TextSelection } from 'prosemirror-state'
|
||||
import { type EditorView } from 'prosemirror-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,17 @@
|
||||
import { type ResolvedPos } from 'prosemirror-model'
|
||||
import { type EditorState, type TextSelection } from 'prosemirror-state'
|
||||
import { type EditorView } from 'prosemirror-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,50 @@
|
||||
import { type Command } from 'prosemirror-state'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { withAutoFixList } from './auto-fix-list'
|
||||
|
||||
describe('autoJoinList', () => {
|
||||
const t = setupTestingEditor()
|
||||
|
||||
it('should join two lists', () => {
|
||||
const command: Command = withAutoFixList((state, dispatch) => {
|
||||
const schema = state.schema
|
||||
dispatch?.(state.tr.replaceWith(8, 9, schema.text('C')))
|
||||
return true
|
||||
})
|
||||
|
||||
t.applyCommand(
|
||||
command,
|
||||
|
||||
t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.p(/*2*/ 'A' /*3*/),
|
||||
/*4*/
|
||||
),
|
||||
/*5*/
|
||||
t.bulletList(
|
||||
/*6*/
|
||||
t.bulletList(
|
||||
/*7*/
|
||||
t.p(/*8*/ 'B' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
t.doc(
|
||||
t.bulletList(
|
||||
t.p('A'),
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('C'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type Transaction } from "prosemirror-state";
|
||||
import { canJoin, canSplit } from "prosemirror-transform";
|
||||
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
|
||||
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,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { atEndBlockBoundary, atStartBlockBoundary } from './block-boundary'
|
||||
|
||||
describe('boundary', () => {
|
||||
const t = setupTestingEditor()
|
||||
|
||||
const doc = t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.p(/*2*/ 'A1' /*4*/),
|
||||
/*5*/
|
||||
t.p(/*6*/ 'A2' /*8*/),
|
||||
/*9*/
|
||||
),
|
||||
/*10*/
|
||||
t.bulletList(
|
||||
/*11*/
|
||||
t.bulletList(
|
||||
/*12*/
|
||||
t.p(/*13*/ 'B1' /*15*/),
|
||||
/*16*/
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it('atStartBoundary', () => {
|
||||
expect(atStartBlockBoundary(doc.resolve(14), 3)).toBe(true)
|
||||
expect(atStartBlockBoundary(doc.resolve(14), 2)).toBe(true)
|
||||
expect(atStartBlockBoundary(doc.resolve(14), 1)).toBe(true)
|
||||
expect(atStartBlockBoundary(doc.resolve(14), 0)).toBe(false)
|
||||
|
||||
expect(atStartBlockBoundary(doc.resolve(8), 2)).toBe(true)
|
||||
expect(atStartBlockBoundary(doc.resolve(8), 1)).toBe(false)
|
||||
expect(atStartBlockBoundary(doc.resolve(8), 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('atEndBoundary', () => {
|
||||
expect(atEndBlockBoundary(doc.resolve(14), 3)).toBe(true)
|
||||
expect(atEndBlockBoundary(doc.resolve(14), 2)).toBe(true)
|
||||
expect(atEndBlockBoundary(doc.resolve(14), 1)).toBe(true)
|
||||
expect(atEndBlockBoundary(doc.resolve(14), 0)).toBe(true)
|
||||
|
||||
expect(atEndBlockBoundary(doc.resolve(6), 2)).toBe(true)
|
||||
expect(atEndBlockBoundary(doc.resolve(6), 1)).toBe(true)
|
||||
expect(atEndBlockBoundary(doc.resolve(6), 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type ResolvedPos } from 'prosemirror-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 "prosemirror-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,10 @@
|
||||
import { type Fragment } from 'prosemirror-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,24 @@
|
||||
import { flatListGroup } from "prosemirror-flat-list";
|
||||
import { type NodeType, type Schema } from "prosemirror-model";
|
||||
|
||||
/** @internal */
|
||||
export function getListType(schema: Schema): NodeType {
|
||||
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,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { inCollapsedList } from './in-collapsed-list'
|
||||
|
||||
describe('inCollapsedList', () => {
|
||||
const t = setupTestingEditor()
|
||||
|
||||
it('returns false in a normal paragraph', () => {
|
||||
t.add(t.doc(t.p('Hello world<cursor>')))
|
||||
expect(inCollapsedList(t.view.state.selection.$from)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true in a collapsed list node', () => {
|
||||
t.add(
|
||||
t.doc(
|
||||
t.collapsedToggleList(
|
||||
t.p('Visible content<cursor>'),
|
||||
t.p('Hidden content'),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(inCollapsedList(t.view.state.selection.$from)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false in a expanded list node', () => {
|
||||
t.add(
|
||||
t.doc(
|
||||
t.expandedToggleList(
|
||||
t.p('Visible content'),
|
||||
t.p('Visible content<cursor>'),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(inCollapsedList(t.view.state.selection.$from)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ResolvedPos } from "prosemirror-model";
|
||||
|
||||
import { isListNode } from "./is-list-node";
|
||||
import { ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
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,9 @@
|
||||
import { type NodeSelection, type Selection } from 'prosemirror-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,9 @@
|
||||
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-list";
|
||||
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 'prosemirror-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 'prosemirror-model'
|
||||
|
||||
import { getListType } from './get-list-type'
|
||||
|
||||
/** @public */
|
||||
export function isListType(type: NodeType): boolean {
|
||||
return getListType(type.schema) === type
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type NodeSelection, type Selection } from 'prosemirror-state'
|
||||
|
||||
export function isNodeSelection(
|
||||
selection: Selection,
|
||||
): selection is NodeSelection {
|
||||
return Boolean((selection as NodeSelection).node)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TextSelection } from 'prosemirror-state'
|
||||
|
||||
export function isTextSelection(value?: unknown): value is TextSelection {
|
||||
return Boolean(value && value instanceof TextSelection)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NodeRange, type ResolvedPos } from 'prosemirror-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,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { ListDOMSerializer } from './list-serializer'
|
||||
|
||||
describe('ListDOMSerializer', () => {
|
||||
const {
|
||||
add,
|
||||
doc,
|
||||
p,
|
||||
bulletList,
|
||||
orderedList,
|
||||
uncheckedTaskList: taskList,
|
||||
expandedToggleList: toggleList,
|
||||
schema,
|
||||
} = setupTestingEditor()
|
||||
|
||||
let editor: ReturnType<typeof add>
|
||||
|
||||
it('can serialize list nodes into <ul>', () => {
|
||||
editor = add(doc(bulletList(p('A')), bulletList(p('B'))))
|
||||
|
||||
const serializer = ListDOMSerializer.fromSchema(schema)
|
||||
|
||||
const serialized = serializer.serializeFragment(editor.state.doc.content)
|
||||
expect(serialized.querySelectorAll('ul').length).toBe(1)
|
||||
expect(serialized.querySelectorAll('ol').length).toBe(0)
|
||||
expect(serialized.querySelectorAll('ul > li').length).toBe(2)
|
||||
expect(serialized).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can serialize list nodes into <ol>', () => {
|
||||
editor = add(doc(orderedList(p('A')), orderedList(p('B'))))
|
||||
|
||||
const serializer = ListDOMSerializer.fromSchema(schema)
|
||||
|
||||
const serialized = serializer.serializeFragment(editor.state.doc.content)
|
||||
|
||||
expect(serialized.querySelectorAll('ul').length).toBe(0)
|
||||
expect(serialized.querySelectorAll('ol').length).toBe(1)
|
||||
expect(serialized.querySelectorAll('ol > li').length).toBe(2)
|
||||
expect(serialized).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can serialize list nodes with different types into a single <ul>', () => {
|
||||
editor = add(
|
||||
doc(
|
||||
bulletList(p('A')),
|
||||
taskList(p('B')),
|
||||
toggleList(p('C')),
|
||||
|
||||
orderedList(p('D')),
|
||||
|
||||
bulletList(p('D')),
|
||||
taskList(p('E')),
|
||||
toggleList(p('D')),
|
||||
),
|
||||
)
|
||||
|
||||
const serializer = ListDOMSerializer.fromSchema(schema)
|
||||
|
||||
const serialized = serializer.serializeFragment(editor.state.doc.content)
|
||||
|
||||
expect(serialized.querySelectorAll('ul').length).toBe(2)
|
||||
expect(serialized.querySelectorAll('ol').length).toBe(1)
|
||||
expect(serialized.querySelectorAll('ul > li').length).toBe(6)
|
||||
expect(serialized.querySelectorAll('ol > li').length).toBe(1)
|
||||
expect(serialized).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can serialize nested list node ', () => {
|
||||
editor = add(
|
||||
doc(
|
||||
bulletList(p('A'), orderedList(p('B')), orderedList(p('C'))),
|
||||
bulletList(p('D'), orderedList(p('E')), orderedList(p('F'))),
|
||||
),
|
||||
)
|
||||
|
||||
const serializer = ListDOMSerializer.fromSchema(schema)
|
||||
|
||||
const serialized = serializer.serializeFragment(editor.state.doc.content)
|
||||
|
||||
expect(serialized.querySelectorAll('ul').length).toBe(1)
|
||||
expect(serialized.querySelectorAll('ol').length).toBe(2)
|
||||
expect(serialized.querySelectorAll('ul > li').length).toBe(2)
|
||||
expect(serialized.querySelectorAll('ol > li').length).toBe(4)
|
||||
expect(serialized).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { listToDOM } from "prosemirror-flat-list";
|
||||
import {
|
||||
type DOMOutputSpec,
|
||||
DOMSerializer,
|
||||
type Fragment,
|
||||
type Node as ProsemirrorNode,
|
||||
type Schema,
|
||||
} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* 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 'prosemirror-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 'prosemirror-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,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { parseInteger } from './parse-integer'
|
||||
|
||||
describe('parseInteger', () => {
|
||||
it('can parse integer string', () => {
|
||||
expect(parseInteger('0')).toBe(0)
|
||||
expect(parseInteger('1')).toBe(1)
|
||||
expect(parseInteger('-10')).toBe(-10)
|
||||
expect(parseInteger('999')).toBe(999)
|
||||
})
|
||||
|
||||
it('can parse float string', () => {
|
||||
expect(parseInteger('1.1')).toBe(1)
|
||||
expect(parseInteger('1.9')).toBe(1)
|
||||
expect(parseInteger('-1.1')).toBe(-1)
|
||||
expect(parseInteger('-1.9')).toBe(-1)
|
||||
expect(parseInteger('-999.9')).toBe(-999)
|
||||
})
|
||||
|
||||
it('can parse non number', () => {
|
||||
expect(parseInteger('Hello')).toBe(null)
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger(true)).toBe(null)
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger(false)).toBe(null)
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger({ object: 'object' })).toBe(null)
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger(Number.NaN)).toBe(null)
|
||||
})
|
||||
|
||||
it('can parse number', () => {
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger(-1)).toBe(-1)
|
||||
// @ts-expect-error: wrong parameter type
|
||||
expect(parseInteger(100.1)).toBe(100)
|
||||
})
|
||||
|
||||
it('can handle null and undefined', () => {
|
||||
expect(parseInteger(null)).toBe(null)
|
||||
expect(parseInteger(undefined)).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -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,15 @@
|
||||
import { type Command, type Transaction } from 'prosemirror-state'
|
||||
|
||||
export function patchCommand(patch: (tr: Transaction) => Transaction) {
|
||||
const withPatch = (command: Command): Command => {
|
||||
const patchedCommand: Command = (state, dispatch, view) => {
|
||||
return command(
|
||||
state,
|
||||
dispatch ? (tr: Transaction) => dispatch(patch(tr)) : undefined,
|
||||
view,
|
||||
)
|
||||
}
|
||||
return patchedCommand
|
||||
}
|
||||
return withPatch
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type NodeRange } from 'prosemirror-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,24 @@
|
||||
import { type NodeRange } from 'prosemirror-model'
|
||||
import { type Transaction } from 'prosemirror-state'
|
||||
import { liftTarget } from 'prosemirror-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,21 @@
|
||||
import { type Transaction } from "prosemirror-state";
|
||||
|
||||
import { isListNode } from "./is-list-node";
|
||||
import { setNodeAttributes } from "./set-node-attributes";
|
||||
import { ListAttributes } from "prosemirror-flat-list";
|
||||
|
||||
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,18 @@
|
||||
import { type Attrs } from 'prosemirror-model'
|
||||
import { type Transaction } from 'prosemirror-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,370 @@
|
||||
import { type TaggedProsemirrorNode } from 'jest-remirror'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { setupTestingEditor } from '../../test/setup-editor'
|
||||
|
||||
import { splitBoundary } from './split-boundary'
|
||||
|
||||
describe('splitBoundary', () => {
|
||||
const t = setupTestingEditor()
|
||||
|
||||
const check = ({
|
||||
before,
|
||||
after,
|
||||
pos,
|
||||
depth,
|
||||
}: {
|
||||
before: TaggedProsemirrorNode
|
||||
after?: TaggedProsemirrorNode
|
||||
pos?: number
|
||||
depth: number
|
||||
}) => {
|
||||
const tr = t.add(before).tr
|
||||
pos = pos ?? tr.selection.$from.pos
|
||||
splitBoundary(tr, pos, depth)
|
||||
expect(tr.doc).toEqualRemirrorDocument(after ?? before)
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
pos: 4,
|
||||
depth: 1,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('A'),
|
||||
t.p('B'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 4,
|
||||
depth: 2,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('A'),
|
||||
),
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('B'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 4,
|
||||
depth: 3,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('A'),
|
||||
),
|
||||
),
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('B'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 3,
|
||||
depth: 1,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 3,
|
||||
depth: 2,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 5,
|
||||
depth: 1,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 5,
|
||||
depth: 2,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
),
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 6,
|
||||
depth: 1,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
),
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 6,
|
||||
depth: 2,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
),
|
||||
),
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 2,
|
||||
depth: 1,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
pos: 2,
|
||||
depth: 2,
|
||||
before: t.doc(
|
||||
/*0*/
|
||||
t.bulletList(
|
||||
/*1*/
|
||||
t.bulletList(
|
||||
/*2*/
|
||||
t.p(/*3*/ 'AB' /*5*/),
|
||||
/*6*/
|
||||
t.p(/*7*/ 'CD' /*9*/),
|
||||
/*10*/
|
||||
),
|
||||
/*11*/
|
||||
),
|
||||
/*12*/
|
||||
),
|
||||
after: t.doc(
|
||||
t.bulletList(
|
||||
t.bulletList(
|
||||
//
|
||||
t.p('AB'),
|
||||
t.p('CD'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
])('can split node %#', check)
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type Transaction } from 'prosemirror-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,26 @@
|
||||
import { Slice } from 'prosemirror-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 'prosemirror-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,89 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
|
||||
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
|
||||
|
||||
let activeCursor: HTMLElement | null;
|
||||
const lastActiveSelection = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
const updateCursorPosition = (event: MouseEvent) => {
|
||||
if (activeCursor) {
|
||||
let newHeight = event.y - lastActiveSelection.top;
|
||||
let newWidth = event.x - lastActiveSelection.left;
|
||||
|
||||
if (newHeight < 0) {
|
||||
activeCursor.style.marginTop = `${newHeight}px`;
|
||||
newHeight *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginTop = "0px";
|
||||
}
|
||||
|
||||
if (newWidth < 0) {
|
||||
activeCursor.style.marginLeft = `${newWidth}px`;
|
||||
newWidth *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginLeft = "0px";
|
||||
}
|
||||
activeCursor.style.height = `${newHeight}px`;
|
||||
activeCursor.style.width = `${newWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeActiveUser = () => {
|
||||
if (activeCursor) {
|
||||
activeCursor.remove();
|
||||
}
|
||||
activeCursor = null;
|
||||
};
|
||||
|
||||
const createMultipleSelectionPlugin = () =>
|
||||
new Plugin({
|
||||
key: MultipleSelectionPluginKey,
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove(view: EditorView<any>, event: MouseEvent): boolean {
|
||||
updateCursorPosition(event);
|
||||
return false;
|
||||
},
|
||||
mouseup(view: EditorView<any>, event: MouseEvent) {
|
||||
removeActiveUser();
|
||||
return false;
|
||||
},
|
||||
mousedown(view: EditorView<any>, event: MouseEvent) {
|
||||
if (event.target !== view.dom) {
|
||||
return false;
|
||||
}
|
||||
if (activeCursor) {
|
||||
activeCursor.remove();
|
||||
return false;
|
||||
}
|
||||
activeCursor = document.createElement("div");
|
||||
activeCursor.className = "multipleSelectionCursor";
|
||||
activeCursor.style.width = "0px";
|
||||
activeCursor.style.height = "0px";
|
||||
activeCursor.style.borderRadius = "2px";
|
||||
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.opacity = "0.5";
|
||||
activeCursor.style.position = "absolute";
|
||||
activeCursor.style.top = `${event.y}px`;
|
||||
activeCursor.style.left = `${event.x}px`;
|
||||
lastActiveSelection.top = event.y;
|
||||
lastActiveSelection.left = event.x;
|
||||
activeCursor.onmousemove = updateCursorPosition;
|
||||
activeCursor.onmouseup = removeActiveUser;
|
||||
document.body.appendChild(activeCursor);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multipleSelection",
|
||||
addProseMirrorPlugins: () => [createMultipleSelectionPlugin()],
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import "../ui/skiff-selection.css";
|
||||
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { EditorState, Plugin, PluginKey, TextSelection, Transaction } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
|
||||
// import { isInCodeblock } from "../codeblock/utils";
|
||||
// import {
|
||||
// LIST_ITEM,
|
||||
// LIST_TASK_ITEM,
|
||||
// TABLE_CELL,
|
||||
// TEXT,
|
||||
// TOGGLE_ITEM_CONTENT,
|
||||
// TOGGLE_ITEM_TITLE,
|
||||
// TOGGLE_LIST_ITEM,
|
||||
// } from "../NodeNames";
|
||||
import { CellSelection } from "@tiptap/pm/tables";
|
||||
|
||||
const DOUBLE_CLICK_THRESH = 50;
|
||||
const TRIPLE_CLICK_THRESH = 500;
|
||||
|
||||
enum CrossNodeSelectionState {
|
||||
/** The current selection is cross-node. */
|
||||
Yes,
|
||||
/**
|
||||
* Since the previous cross-node selection, we haven't returned to a "normal"
|
||||
* text selection, instead the whole node is selected.
|
||||
*/
|
||||
Transitioning,
|
||||
/** The current selection is not cross-node and not transitioning. */
|
||||
No,
|
||||
}
|
||||
|
||||
interface SelectionTrackerState {
|
||||
start: number;
|
||||
end: number;
|
||||
decorations: DecorationSet;
|
||||
/**
|
||||
* keeps track of the anchor when text selection starts,
|
||||
* used to restore the anchor when moving from cross node selection back to single node selection
|
||||
*/
|
||||
originAnchor: number;
|
||||
/**
|
||||
* keeps track of the last time there was a double click,
|
||||
* to prevent double click from selecting only part of text
|
||||
*/
|
||||
lastDoubleClick: Date;
|
||||
/**
|
||||
* keeps track of the last time there was a triple click,
|
||||
* to prevent triple click from selecting only part of text
|
||||
*/
|
||||
lastTripleClick: Date;
|
||||
/**
|
||||
* keeps track of whether the selection is a cross node selection,
|
||||
* to let us know when we change from cross node to normal selection
|
||||
* (hence should restore originAnchor)
|
||||
*/
|
||||
crossNodeSelectionState: CrossNodeSelectionState;
|
||||
}
|
||||
|
||||
const selectionTrackerKey = new PluginKey("selectionTrackerPlugin");
|
||||
|
||||
const SKIPPED_NODES_FOR_DECORATIONS = [
|
||||
TOGGLE_LIST_ITEM,
|
||||
LIST_ITEM,
|
||||
LIST_TASK_ITEM,
|
||||
TOGGLE_ITEM_TITLE,
|
||||
TOGGLE_ITEM_CONTENT,
|
||||
TEXT,
|
||||
];
|
||||
|
||||
const validateNodeForSelectionDecoration = (node: ProsemirrorNode, parent: ProsemirrorNode) => {
|
||||
if (SKIPPED_NODES_FOR_DECORATIONS.includes(node.type.name)) return false;
|
||||
|
||||
if (parent.type.name === TABLE_CELL && parent.childCount === 1) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const SelectionTracker = () =>
|
||||
new Plugin({
|
||||
key: selectionTrackerKey,
|
||||
state: {
|
||||
init() {
|
||||
return {
|
||||
start: null,
|
||||
end: null,
|
||||
decorations: DecorationSet.empty,
|
||||
originAnchor: null,
|
||||
lastDoubleClick: new Date(),
|
||||
lastTripleClick: new Date(),
|
||||
crossNodeSelectionState: CrossNodeSelectionState.No,
|
||||
};
|
||||
},
|
||||
apply(
|
||||
tr: Transaction,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
decorations,
|
||||
originAnchor,
|
||||
lastDoubleClick,
|
||||
lastTripleClick,
|
||||
crossNodeSelectionState,
|
||||
}: SelectionTrackerState,
|
||||
_oldState: EditorState,
|
||||
newState: EditorState
|
||||
) {
|
||||
const { selection: sel } = newState;
|
||||
|
||||
const { anchor, head } = sel;
|
||||
|
||||
// Update lastDoubleClick and lastTripleCLick by meta or prev state
|
||||
const meta = tr.getMeta(selectionTrackerKey);
|
||||
if (meta) {
|
||||
if (meta.lastDoubleClick) {
|
||||
lastDoubleClick = meta.lastDoubleClick;
|
||||
}
|
||||
if (meta.lastTripleClick) {
|
||||
lastTripleClick = meta.lastTripleClick;
|
||||
}
|
||||
}
|
||||
|
||||
if (sel.from === sel.to || sel instanceof CellSelection)
|
||||
return {
|
||||
start: null,
|
||||
end: null,
|
||||
decorations: DecorationSet.empty,
|
||||
originAnchor: anchor,
|
||||
lastDoubleClick,
|
||||
lastTripleClick,
|
||||
crossNodeSelectionState: CrossNodeSelectionState.No,
|
||||
};
|
||||
|
||||
// if its the sames selection, return the old value
|
||||
if (end === head && start === anchor)
|
||||
return {
|
||||
start,
|
||||
head,
|
||||
decorations,
|
||||
originAnchor,
|
||||
lastDoubleClick,
|
||||
lastTripleClick,
|
||||
crossNodeSelectionState,
|
||||
};
|
||||
|
||||
const newDecorations: Decoration[] = [];
|
||||
|
||||
newState.doc.nodesBetween(sel.from, sel.to, (node, pos, parent) => {
|
||||
const backwardsSelection = sel.anchor > sel.head;
|
||||
const nodeInsideSelection =
|
||||
pos >= sel.from - 1 && pos + node.nodeSize <= sel.to + (backwardsSelection ? 1 : 0);
|
||||
|
||||
if (nodeInsideSelection && validateNodeForSelectionDecoration(node, parent)) {
|
||||
newDecorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
class: "selectionAroundNode",
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const newSet = DecorationSet.create(newState.doc, newDecorations);
|
||||
|
||||
// Update crossNodeSelectionState:
|
||||
// - If the current selection has multiple children, it's cross-node (Yes)
|
||||
// - If we were previously in a cross-node selection and are still selecting
|
||||
// an entire node, it's Transitioning. This happens when you drag from a cross-node
|
||||
// selection back to a normal one: there is an in-between state where you are just
|
||||
// selecting one node, but as an entire block.
|
||||
// - Otherwise it's a normal, sub-node selection (No).
|
||||
let newCrossNodeSelectionState = CrossNodeSelectionState.No;
|
||||
if (sel.content().content.childCount > 1) {
|
||||
newCrossNodeSelectionState = CrossNodeSelectionState.Yes;
|
||||
} else if (
|
||||
(crossNodeSelectionState === CrossNodeSelectionState.Yes ||
|
||||
crossNodeSelectionState === CrossNodeSelectionState.Transitioning) &&
|
||||
sel.$from.parentOffset === 0 &&
|
||||
sel.$to.parentOffset === sel.$to.parent.content.size
|
||||
) {
|
||||
newCrossNodeSelectionState = CrossNodeSelectionState.Transitioning;
|
||||
}
|
||||
|
||||
return {
|
||||
start: sel.anchor,
|
||||
end: sel.head,
|
||||
decorations: newSet,
|
||||
originAnchor,
|
||||
lastDoubleClick,
|
||||
lastTripleClick,
|
||||
crossNodeSelectionState: newCrossNodeSelectionState,
|
||||
};
|
||||
},
|
||||
},
|
||||
appendTransaction(transactions: Transaction[], _oldState: EditorState, newState: EditorState) {
|
||||
const { selection: sel } = newState;
|
||||
const {
|
||||
originAnchor,
|
||||
lastDoubleClick,
|
||||
lastTripleClick,
|
||||
crossNodeSelectionState: oldCrossNodeSelectionState,
|
||||
} = selectionTrackerKey.getState(newState) as SelectionTrackerState;
|
||||
|
||||
if (!(sel instanceof TextSelection)) return null;
|
||||
|
||||
const isCrossNodesSelection = sel.content().content.childCount > 1;
|
||||
const selectionChanged = transactions.reduce((changed, tr) => changed || tr.selectionSet, false);
|
||||
const currentTime = new Date().getTime();
|
||||
const wasJustDoubleClicked = currentTime - lastDoubleClick.getTime() < DOUBLE_CLICK_THRESH;
|
||||
const wasJustTripleClicked = currentTime - lastTripleClick.getTime() < TRIPLE_CLICK_THRESH;
|
||||
const isInCodeBlock = isInCodeblock(newState, newState.selection.from);
|
||||
if (isCrossNodesSelection && selectionChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// to avoid breaking double click to select entire word
|
||||
// we check if we just double clicked or triple clicked and then dont change selection
|
||||
if (wasJustDoubleClicked || wasJustTripleClicked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// handle changing from cross node selection back to single node selection,
|
||||
// create a selection from the origin anchor to the current head
|
||||
// this is ignored on codeblock to prevent bug where selection with prosemirror is not synced with codemirror
|
||||
if (
|
||||
selectionChanged &&
|
||||
!isCrossNodesSelection &&
|
||||
(oldCrossNodeSelectionState === CrossNodeSelectionState.Yes ||
|
||||
oldCrossNodeSelectionState === CrossNodeSelectionState.Transitioning) &&
|
||||
sel.anchor !== originAnchor &&
|
||||
sel.anchor !== sel.head &&
|
||||
!isInCodeBlock
|
||||
) {
|
||||
const { tr } = newState;
|
||||
tr.setSelection(TextSelection.create(tr.doc, originAnchor, Math.min(sel.head, tr.doc.nodeSize - 2)));
|
||||
return tr;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const deco = this.spec.key?.getState(state);
|
||||
return deco.decorations;
|
||||
},
|
||||
handleDoubleClick(view) {
|
||||
view.dispatch(view.state.tr.setMeta(selectionTrackerKey, { lastDoubleClick: new Date() }));
|
||||
return false;
|
||||
},
|
||||
handleTripleClick(view) {
|
||||
view.dispatch(view.state.tr.setMeta(selectionTrackerKey, { lastTripleClick: new Date() }));
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default SelectionTracker;
|
||||
@@ -122,11 +122,19 @@ 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;
|
||||
rect.top += 6;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.matches(".table-wrapper")) {
|
||||
|
||||
@@ -111,12 +111,27 @@ export const Table = Node.create({
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
tr.replaceSelectionWith(node)
|
||||
.scrollIntoView()
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
|
||||
if (dispatch) {
|
||||
const { selection } = tr;
|
||||
const position = selection.$from.before(selection.$from.depth);
|
||||
|
||||
// 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 + 2);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -1,109 +1,13 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
TypographyOptions,
|
||||
emDash,
|
||||
ellipsis,
|
||||
leftArrow,
|
||||
rightArrow,
|
||||
copyright,
|
||||
trademark,
|
||||
servicemark,
|
||||
registeredTrademark,
|
||||
oneHalf,
|
||||
plusMinus,
|
||||
notEqual,
|
||||
laquo,
|
||||
raquo,
|
||||
multiplication,
|
||||
superscriptTwo,
|
||||
superscriptThree,
|
||||
oneQuarter,
|
||||
threeQuarters,
|
||||
impliesArrowRight,
|
||||
} from "./inputRules";
|
||||
import { TypographyOptions, TYPOGRAPHY_RULES, createInputRule } from "./input-rules";
|
||||
|
||||
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||
name: "typography",
|
||||
|
||||
addInputRules() {
|
||||
const rules = [];
|
||||
|
||||
if (this.options.emDash !== false) {
|
||||
rules.push(emDash(this.options.emDash));
|
||||
}
|
||||
|
||||
if (this.options.impliesArrowRight !== false) {
|
||||
rules.push(impliesArrowRight(this.options.impliesArrowRight));
|
||||
}
|
||||
|
||||
if (this.options.ellipsis !== false) {
|
||||
rules.push(ellipsis(this.options.ellipsis));
|
||||
}
|
||||
|
||||
if (this.options.leftArrow !== false) {
|
||||
rules.push(leftArrow(this.options.leftArrow));
|
||||
}
|
||||
|
||||
if (this.options.rightArrow !== false) {
|
||||
rules.push(rightArrow(this.options.rightArrow));
|
||||
}
|
||||
|
||||
if (this.options.copyright !== false) {
|
||||
rules.push(copyright(this.options.copyright));
|
||||
}
|
||||
|
||||
if (this.options.trademark !== false) {
|
||||
rules.push(trademark(this.options.trademark));
|
||||
}
|
||||
|
||||
if (this.options.servicemark !== false) {
|
||||
rules.push(servicemark(this.options.servicemark));
|
||||
}
|
||||
|
||||
if (this.options.registeredTrademark !== false) {
|
||||
rules.push(registeredTrademark(this.options.registeredTrademark));
|
||||
}
|
||||
|
||||
if (this.options.oneHalf !== false) {
|
||||
rules.push(oneHalf(this.options.oneHalf));
|
||||
}
|
||||
|
||||
if (this.options.plusMinus !== false) {
|
||||
rules.push(plusMinus(this.options.plusMinus));
|
||||
}
|
||||
|
||||
if (this.options.notEqual !== false) {
|
||||
rules.push(notEqual(this.options.notEqual));
|
||||
}
|
||||
|
||||
if (this.options.laquo !== false) {
|
||||
rules.push(laquo(this.options.laquo));
|
||||
}
|
||||
|
||||
if (this.options.raquo !== false) {
|
||||
rules.push(raquo(this.options.raquo));
|
||||
}
|
||||
|
||||
if (this.options.multiplication !== false) {
|
||||
rules.push(multiplication(this.options.multiplication));
|
||||
}
|
||||
|
||||
if (this.options.superscriptTwo !== false) {
|
||||
rules.push(superscriptTwo(this.options.superscriptTwo));
|
||||
}
|
||||
|
||||
if (this.options.superscriptThree !== false) {
|
||||
rules.push(superscriptThree(this.options.superscriptThree));
|
||||
}
|
||||
|
||||
if (this.options.oneQuarter !== false) {
|
||||
rules.push(oneQuarter(this.options.oneQuarter));
|
||||
}
|
||||
|
||||
if (this.options.threeQuarters !== false) {
|
||||
rules.push(threeQuarters(this.options.threeQuarters));
|
||||
}
|
||||
|
||||
return rules;
|
||||
return Object.keys(TYPOGRAPHY_RULES)
|
||||
.filter((key) => this.options[key] !== false)
|
||||
.map((key) => createInputRule(key as keyof typeof TYPOGRAPHY_RULES, this.options[key]))
|
||||
.filter((rule): rule is NonNullable<ReturnType<typeof createInputRule>> => rule !== null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { textInputRule } from "@tiptap/core";
|
||||
|
||||
export interface TypographyOptions {
|
||||
[key: string]: false | string | undefined;
|
||||
}
|
||||
|
||||
// Define rules configuration
|
||||
export const TYPOGRAPHY_RULES = {
|
||||
emDash: {
|
||||
find: /--$/,
|
||||
replace: "—",
|
||||
},
|
||||
impliesArrowRight: {
|
||||
find: /=>$/,
|
||||
replace: "⇒",
|
||||
},
|
||||
leftArrow: {
|
||||
find: /<-$/,
|
||||
replace: "←",
|
||||
},
|
||||
rightArrow: {
|
||||
find: /->$/,
|
||||
replace: "→",
|
||||
},
|
||||
ellipsis: {
|
||||
find: /\.\.\.$/,
|
||||
replace: "…",
|
||||
},
|
||||
copyright: {
|
||||
find: /\(c\)$/,
|
||||
replace: "©",
|
||||
},
|
||||
trademark: {
|
||||
find: /\(tm\)$/,
|
||||
replace: "™",
|
||||
},
|
||||
servicemark: {
|
||||
find: /\(sm\)$/,
|
||||
replace: "℠",
|
||||
},
|
||||
registeredTrademark: {
|
||||
find: /\(r\)$/,
|
||||
replace: "®",
|
||||
},
|
||||
oneHalf: {
|
||||
find: /(?:^|\s)(1\/2)\s$/,
|
||||
replace: "½",
|
||||
},
|
||||
plusMinus: {
|
||||
find: /\+\/-$/,
|
||||
replace: "±",
|
||||
},
|
||||
notEqual: {
|
||||
find: /!=$/,
|
||||
replace: "≠",
|
||||
},
|
||||
laquo: {
|
||||
find: /<<$/,
|
||||
replace: "«",
|
||||
},
|
||||
multiplication: {
|
||||
find: /\d+\s?([*x])\s?\d+$/,
|
||||
replace: "×",
|
||||
},
|
||||
superscriptTwo: {
|
||||
find: /\^2$/,
|
||||
replace: "²",
|
||||
},
|
||||
superscriptThree: {
|
||||
find: /\^3$/,
|
||||
replace: "³",
|
||||
},
|
||||
oneQuarter: {
|
||||
find: /(?:^|\s)(1\/4)\s$/,
|
||||
replace: "¼",
|
||||
},
|
||||
threeQuarters: {
|
||||
find: /(?:^|\s)(3\/4)\s$/,
|
||||
replace: "¾",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const createInputRule = (key: keyof typeof TYPOGRAPHY_RULES, override: string | false | undefined) => {
|
||||
if (override === false) return null;
|
||||
return textInputRule({
|
||||
find: TYPOGRAPHY_RULES[key].find,
|
||||
replace: override || TYPOGRAPHY_RULES[key].replace,
|
||||
});
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import { textInputRule } from "@tiptap/core";
|
||||
|
||||
export interface TypographyOptions {
|
||||
emDash: false | string;
|
||||
ellipsis: false | string;
|
||||
leftArrow: false | string;
|
||||
rightArrow: false | string;
|
||||
copyright: false | string;
|
||||
trademark: false | string;
|
||||
servicemark: false | string;
|
||||
registeredTrademark: false | string;
|
||||
oneHalf: false | string;
|
||||
plusMinus: false | string;
|
||||
notEqual: false | string;
|
||||
laquo: false | string;
|
||||
raquo: false | string;
|
||||
multiplication: false | string;
|
||||
superscriptTwo: false | string;
|
||||
superscriptThree: false | string;
|
||||
oneQuarter: false | string;
|
||||
threeQuarters: false | string;
|
||||
impliesArrowRight: false | string;
|
||||
}
|
||||
|
||||
export const emDash = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /--$/,
|
||||
replace: override ?? "—",
|
||||
});
|
||||
|
||||
export const impliesArrowRight = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /=>$/,
|
||||
replace: override ?? "⇒",
|
||||
});
|
||||
|
||||
export const leftArrow = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /<-$/,
|
||||
replace: override ?? "←",
|
||||
});
|
||||
|
||||
export const rightArrow = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /->$/,
|
||||
replace: override ?? "→",
|
||||
});
|
||||
|
||||
export const ellipsis = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\.\.\.$/,
|
||||
replace: override ?? "…",
|
||||
});
|
||||
|
||||
export const copyright = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(c\)$/,
|
||||
replace: override ?? "©",
|
||||
});
|
||||
|
||||
export const trademark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(tm\)$/,
|
||||
replace: override ?? "™",
|
||||
});
|
||||
|
||||
export const servicemark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(sm\)$/,
|
||||
replace: override ?? "℠",
|
||||
});
|
||||
|
||||
export const registeredTrademark = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\(r\)$/,
|
||||
replace: override ?? "®",
|
||||
});
|
||||
|
||||
export const oneHalf = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(1\/2)\s$/,
|
||||
replace: override ?? "½",
|
||||
});
|
||||
|
||||
export const plusMinus = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\+\/-$/,
|
||||
replace: override ?? "±",
|
||||
});
|
||||
|
||||
export const notEqual = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /!=$/,
|
||||
replace: override ?? "≠",
|
||||
});
|
||||
|
||||
export const laquo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /<<$/,
|
||||
replace: override ?? "«",
|
||||
});
|
||||
|
||||
export const raquo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: />>$/,
|
||||
replace: override ?? "»",
|
||||
});
|
||||
|
||||
export const multiplication = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\d+\s?([*x])\s?\d+$/,
|
||||
replace: override ?? "×",
|
||||
});
|
||||
|
||||
export const superscriptTwo = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\^2$/,
|
||||
replace: override ?? "²",
|
||||
});
|
||||
|
||||
export const superscriptThree = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /\^3$/,
|
||||
replace: override ?? "³",
|
||||
});
|
||||
|
||||
export const oneQuarter = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(1\/4)\s$/,
|
||||
replace: override ?? "¼",
|
||||
});
|
||||
|
||||
export const threeQuarters = (override?: string) =>
|
||||
textInputRule({
|
||||
find: /(?:^|\s)(3\/4)\s$/,
|
||||
replace: override ?? "¾",
|
||||
});
|
||||
@@ -90,16 +90,68 @@ export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().toggleOrderedList().run();
|
||||
};
|
||||
|
||||
export const toggleFlatOrderedList = (editor: Editor, range?: Range) => {
|
||||
if (range)
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.createList({
|
||||
kind: "ordered",
|
||||
})
|
||||
.run();
|
||||
else editor.chain().focus().createList({ kind: "ordered", collapsed: false });
|
||||
};
|
||||
|
||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
else editor.chain().focus().toggleBulletList().run();
|
||||
};
|
||||
|
||||
export const toggleFlatBulletList = (editor: Editor, range?: Range) => {
|
||||
if (range)
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.createList({
|
||||
kind: "bullet",
|
||||
})
|
||||
.run();
|
||||
else editor.chain().focus().createList({ kind: "bullet", collapsed: false });
|
||||
};
|
||||
|
||||
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
else editor.chain().focus().toggleTaskList().run();
|
||||
};
|
||||
|
||||
export const toggleFlatTaskList = (editor: Editor, range?: Range) => {
|
||||
if (range)
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.createList({
|
||||
kind: "task",
|
||||
})
|
||||
.run();
|
||||
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",
|
||||
})
|
||||
.run();
|
||||
else editor.chain().focus().createList({ kind: "toggle", collapsed: false });
|
||||
};
|
||||
|
||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
|
||||
else editor.chain().focus().toggleStrike().run();
|
||||
@@ -122,8 +174,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const insertImage = ({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TCollaborativeEditorProps } from "@/types";
|
||||
// import { Collaboration } from "@/extensions/collaboration";
|
||||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
const {
|
||||
@@ -102,6 +103,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
provider,
|
||||
// localProvider,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Editor, type JSONContent } from "@tiptap/react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||
@@ -16,15 +17,17 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
TDocumentEventsServer,
|
||||
import {
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TEditorCommands,
|
||||
TFileHandler,
|
||||
TDocumentEventsServer,
|
||||
TExtensions,
|
||||
} from "@/types";
|
||||
import { migrateDocJSON } from "@/extensions/flat-list/migrate-lists";
|
||||
import type { ProsemirrorNodeJSON } from "prosemirror-flat-list";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
editorClassName: string;
|
||||
@@ -46,6 +49,7 @@ export interface CustomEditorProps {
|
||||
autofocus?: boolean;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
provider?: HocuspocusProvider;
|
||||
localProvider?: IndexeddbPersistence;
|
||||
tabIndex?: number;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
@@ -68,18 +72,37 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
// localProvider,
|
||||
tabIndex,
|
||||
value,
|
||||
provider,
|
||||
autofocus = false,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
const [isEditorDisabled, setIsEditorDisabled] = useState(false);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
const editor = useTiptapEditor({
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
enableContentCheck: true,
|
||||
// onContentError: ({ editor, error, disableCollaboration }) => {
|
||||
// // console.log("ran", editor.getJSON());
|
||||
// if (disableCollaboration) disableCollaboration();
|
||||
// // localProvider.clearData();
|
||||
// // localProvider.destroy();
|
||||
//
|
||||
// const emitUpdate = false;
|
||||
//
|
||||
// // Disable further user input
|
||||
// // setIsEditorDisabled(true);
|
||||
// editor.setEditable(false, emitUpdate);
|
||||
// console.log("error", error);
|
||||
// },
|
||||
|
||||
autofocus,
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
@@ -136,6 +159,40 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
}
|
||||
}, [editor, value, id]);
|
||||
|
||||
const [hasMigrated, setHasMigrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && (!hasMigrated || editor.isActive("listItem")) && !isEditorDisabled) {
|
||||
const newJSON = migrateDocJSON(editor.getJSON() as ProsemirrorNodeJSON) as JSONContent;
|
||||
|
||||
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
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
if (currentSavedSelection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
|
||||
editor.commands.setTextSelection(relativePosition);
|
||||
}
|
||||
|
||||
console.log("Migration of old lists completed without adding to history");
|
||||
} catch (error) {
|
||||
console.error("Error during migration:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editor.getJSON(), editor.isActive("listItem"), hasMigrated]);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
|
||||
@@ -44,6 +44,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
".editor-callout-component",
|
||||
".prosemirror-flat-list",
|
||||
].join(", ");
|
||||
|
||||
for (const elem of elements) {
|
||||
@@ -130,7 +131,6 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
// TODO FIX ERROR
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
@@ -138,6 +138,11 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
);
|
||||
}
|
||||
|
||||
if (node.className.includes("prosemirror-flat-list")) {
|
||||
draggedNodePos = draggedNodePos - 1;
|
||||
console.log("draggedNodePos", draggedNodePos);
|
||||
}
|
||||
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
@@ -212,7 +217,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
|
||||
nodePos = calcNodePos(nodePos, view, node);
|
||||
|
||||
// TODO FIX ERROR
|
||||
if (node.className.includes("prosemirror-flat-list")) {
|
||||
nodePos = nodePos - 1;
|
||||
}
|
||||
|
||||
// Use NodeSelection to select the node at the calculated position
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
|
||||
|
||||
@@ -309,7 +317,9 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
|
||||
console.log("droppedNode", droppedNode);
|
||||
if (!droppedNode) return;
|
||||
console.log("droppedNode", droppedNode);
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
let isDroppedInsideList = false;
|
||||
|
||||
@@ -41,7 +41,8 @@ export type TEditorCommands =
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
| "text-align"
|
||||
| "callout";
|
||||
| "callout"
|
||||
| "toggle-list";
|
||||
|
||||
export type TCommandExtraProps = {
|
||||
image: {
|
||||
@@ -125,7 +126,7 @@ export interface IEditorProps {
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
value?: string | null;
|
||||
}
|
||||
export interface ILiteTextEditor extends IEditorProps {
|
||||
extensions?: any[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// styles
|
||||
// import "./styles/tailwind.css";
|
||||
import "prosemirror-flat-list/style.css";
|
||||
import "./styles/variables.css";
|
||||
import "./styles/editor.css";
|
||||
import "./styles/table.css";
|
||||
|
||||
@@ -291,37 +291,6 @@ span:focus .fake-cursor {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* numbered, bulleted and to-do lists spacing */
|
||||
.prose ol:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
|
||||
.prose
|
||||
ul:not([data-type="taskList"]):where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
|
||||
.prose ul[data-type="taskList"]:where(.prose > :first-child) {
|
||||
margin-top: 0.25rem !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.prose ol:not(:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))),
|
||||
.prose
|
||||
ul:not([data-type="taskList"]):not(
|
||||
:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))
|
||||
),
|
||||
.prose ul[data-type="taskList"]:not(:where(.prose > :first-child)) {
|
||||
margin-top: calc(0.25rem + 3px) !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ol ul:not([data-type="taskList"]),
|
||||
ul:not([data-type="taskList"]) ul:not([data-type="taskList"]),
|
||||
ul:not([data-type="taskList"]) ol {
|
||||
margin-top: 0.45rem !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
/* end numbered, bulleted and to-do lists spacing */
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -488,3 +457,182 @@ p + p {
|
||||
background-color: var(--editor-colors-purple-background);
|
||||
}
|
||||
/* end background colors */
|
||||
|
||||
.prosemirror-flat-list {
|
||||
&:not(:first-child) {
|
||||
padding-top: 4px !important;
|
||||
}
|
||||
|
||||
padding-bottom: 4px !important;
|
||||
margin: 0 0 0 24px !important;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
&::before,
|
||||
.list-marker {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px !important;
|
||||
height: 28px !important;
|
||||
aspect-ratio: 1 !important;
|
||||
}
|
||||
|
||||
.list-content {
|
||||
.prosemirror-flat-list:first-child {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.prosemirror-flat-list:last-child {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
& > *:first-child {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-list-kind="ordered"] {
|
||||
list-style: none;
|
||||
|
||||
&::before {
|
||||
font-variant-numeric: tabular-nums;
|
||||
content: counter(prosemirror-flat-list-counter, decimal) ".";
|
||||
}
|
||||
|
||||
/* Second level - lower-alpha */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-alpha) ".";
|
||||
}
|
||||
|
||||
/* Third level - lower-roman */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-roman) ".";
|
||||
}
|
||||
|
||||
/* Start repeat pattern */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, decimal) ".";
|
||||
}
|
||||
|
||||
/* Continue pattern */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-alpha) ".";
|
||||
}
|
||||
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-roman) ".";
|
||||
}
|
||||
|
||||
/* Pattern repeats again */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, decimal) ".";
|
||||
}
|
||||
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-alpha) ".";
|
||||
}
|
||||
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, lower-roman) ".";
|
||||
}
|
||||
}
|
||||
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: counter(prosemirror-flat-list-counter, decimal) ".";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-list-kind="bullet"] {
|
||||
list-style: none;
|
||||
|
||||
&::before {
|
||||
content: "•";
|
||||
}
|
||||
|
||||
/* Second level - circle */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "⚬";
|
||||
}
|
||||
|
||||
/* Third level - square */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "▪";
|
||||
}
|
||||
|
||||
/* Start repeat pattern */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "•";
|
||||
}
|
||||
|
||||
/* Continue pattern */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "⚬";
|
||||
}
|
||||
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "▪";
|
||||
}
|
||||
|
||||
/* Pattern repeats again */
|
||||
& > .list-content > & {
|
||||
&::before {
|
||||
content: "•";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p + .prosemirror-flat-list {
|
||||
&:not(:first-child) {
|
||||
padding-top: 8px !important;
|
||||
|
||||
&::before {
|
||||
top: 6px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-content p + .prosemirror-flat-list {
|
||||
&:not(:first-child) {
|
||||
padding-top: 4px !important;
|
||||
|
||||
&::before {
|
||||
top: 2px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror .issue-embed img {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,34 @@ const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-lis
|
||||
shortcut: ["Cmd", "Shift", "9"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
key: "flat-bulleted-list",
|
||||
name: "Flat bulleted list",
|
||||
icon: List,
|
||||
shortcut: ["Cmd", "Shift", "7"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
key: "flat-numbered-list",
|
||||
name: "Flat numbered list",
|
||||
icon: ListOrdered,
|
||||
shortcut: ["Cmd", "Shift", "8"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
key: "flat-check-list",
|
||||
name: "Flat to-do list",
|
||||
icon: ListTodo,
|
||||
shortcut: ["Cmd", "Shift", "9"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
key: "flat-toggle-list",
|
||||
name: "Flat Toggle list",
|
||||
icon: ListTodo,
|
||||
shortcut: ["Cmd", "Shift", "9"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
];
|
||||
|
||||
const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
|
||||
|
||||
Reference in New Issue
Block a user