Compare commits

..
Author SHA1 Message Date
Palanikannan M a5752adb42 chore: remove dev editor 2024-12-03 15:04:31 +05:30
Palanikannan M 7231b943be Merge branch 'preview' into feat/flat-list 2024-12-02 20:58:04 +05:30
Anmol Singh BhatiaandGitHub fe5999ceff fix: intake issue permission (#6136) 2024-12-02 19:49:09 +05:30
rahulrameshaandGitHub da0071256f fix half block dragging (#6135) 2024-12-02 19:30:58 +05:30
M. PalanikannanandGitHub 3c6006d04a [PE-31] feat: Add lock unlock archive restore realtime sync (#5629)
* fix: add lock unlock archive restore realtime sync

* fix: show only after editor loads

* fix: added strong types

* fix: live events fixed

* fix: remove unused vars and logs

* fix: converted objects to enum

* fix: error handling and removing the events in read only mode

* fix: added check to only update if the image aspect ratio is not present already

* fix: imports

* fix: props order

* revert: no need of these changes anymore

* fix: updated type names

* fix: order of things

* fix: fixed types and renamed variables

* fix: better typing for the real time updates

* fix: trying multiplexing our socket connection

* fix: multiplexing socket connection in read only editor as well

* fix: remove single socket logic

* fix: fixing the cleanup deps for the provider and localprovider

* fix: add a better data structure for managing events

* chore: refactored realtime events into hooks

* feat: fetch page meta while focusing tabs

* fix: cycling through items on slash command item in down arrow

* fix: better naming convention for realtime events

* fix: simplified localprovider initialization and cleaning

* fix: types from ui

* fix: abstracted away from exposing the provider directly

* fix: coderabbit suggestions

* regression: pass user in dependency array

* fix: removed page action api calls by the other users the document is synced with

* chore: removed unused imports
2024-12-02 14:26:36 +05:30
Aaryan KhandelwalandGitHub 8c04aa6f51 dev: revamp pages authorization (#6094) 2024-12-02 13:59:01 +05:30
Aaryan KhandelwalandGitHub 9f14167ef5 refactor: editor code splitting (#6102)
* fix: merge conflicts resolved from preview

* fix: space app build errors

* fix: product updates modal

* fix: build errors

* fix: lite text read only editor

* refactor: additional options push logic
2024-12-02 13:51:27 +05:30
Aaryan KhandelwalandGitHub 11bfbe560a fix: checked colored todo list item (#6113) 2024-12-02 13:47:50 +05:30
Aaryan KhandelwalandGitHub fc52936024 fix: escape markdown content for images (#6096) 2024-12-02 13:36:12 +05:30
Vamsi KrishnaandGitHub 5150c661ab reduced the components moved (#6110) 2024-12-02 13:35:40 +05:30
Vamsi KrishnaandGitHub 63bc01f385 [WEB-2774]fix:reordering favorites and favorite folders (#6119)
* fixed re order for favorites

* fixed lint errors

* added reorder

* fixed reorder inside folder

* fixed lint issues

* memoized reorder

* removed unnecessary comments

* seprated duplicate logic to a common file

* removed code comments

* fixed favorite remove while reorder inside folder

* fixed folder remove while reorder inside folder

* fixed-reorder issue

* added last child to drop handled

* fixed orderby function

* removed unncessasary comments
2024-12-02 13:35:09 +05:30
Anmol Singh BhatiaandGitHub 1953d6fe3a [WEB-2762] chore: loader code refactor (#5992)
* chore: loader code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2024-12-02 13:24:01 +05:30
Anmol Singh BhatiaandGitHub 1b9033993d [WEB-2799] chore: global component and code refactor (#6131)
* chore: local storage helper hook added to package

* chore: tabs global component added

* chore: collapsible button improvement

* chore: linear progress indicator improvement

* chore: fill icon set added to package
2024-12-02 13:22:08 +05:30
sriram veeraghanta 75ada1bfac fix: constants package updates 2024-12-01 21:26:35 +05:30
Prateek ShouryaandGitHub d0f9a4d245 chore: add redirection to plane logo in invitations page (#6125) 2024-11-29 20:20:49 +05:30
sriram veeraghantaandGitHub 05894c5b9c Merge pull request #6121 from makeplane/preview
release: v0.24.0
2024-11-29 19:36:12 +05:30
Prateek ShouryaandGitHub 5926c9e8e9 fix: comment images in profile activity page (#6123) 2024-11-29 19:20:31 +05:30
Prateek ShouryaandGitHub 5aeedd1e5a [WEB-2610] fix: workspace redirection from admin app (#6122) 2024-11-29 19:02:13 +05:30
sriram veeraghanta 7725b200f7 fix: changelog redirection 2024-11-29 18:13:29 +05:30
sriram veeraghanta 2c69538617 fix: hypermode text typo changes 2024-11-29 17:47:46 +05:30
Palanikannan M 17b53c888f fix: removed input rules from lists 2024-11-28 08:42:18 +05:30
Palanikannan M f4061c4ad6 Merge branch 'preview' into feat/flat-list 2024-11-27 16:38:55 +05:30
Palanikannan M 3b7003815b Merge branch 'preview' into feat/flat-list 2024-11-26 19:58:04 +05:30
Palanikannan M 2c499cf436 fix: parse html 2024-11-13 20:36:38 +05:30
Palanikannan M 24f46bd098 fix: flat-list 2024-11-13 18:01:07 +05:30
Palanikannan M 5e6e12d5e2 feat: trying out heading lists, nested styles and diving deeper 2024-11-13 09:34:55 +05:30
Palanikannan M 2d9a2ba9c7 Merge branch 'preview' into chore/editor-updates 2024-11-09 15:25:49 +05:30
Palanikannan M c81f02d879 revert: old fetch logic 2024-10-29 13:11:55 +05:30
Palanikannan M 9773a1feed fix: exiting code blocks inside lists 2024-10-28 16:56:57 +05:30
Palanikannan M 88b8e764e3 fix: insert table command for nesting it in lists 2024-10-28 16:56:35 +05:30
Palanikannan M 523b0a3eaf fix: image attr type 2024-10-28 16:56:18 +05:30
Palanikannan M daf14f17c7 fix: table insertion 2024-10-28 16:56:02 +05:30
Palanikannan M 6ce6533de5 fix: styles of ordered lists 2024-10-25 15:39:10 +05:30
Palanikannan M 95a063d564 Merge branch 'preview' into feat/flat-list 2024-10-25 05:41:36 +05:30
Palanikannan M 2a1c08f203 fix: migration script added 2024-10-25 04:58:26 +05:30
Palanikannan M cefc1e99b1 fix: flat lists 2024-10-25 01:49:51 +05:30
Palanikannan M 15668299b4 feat: migration (but with duplication of content and persistence of old lists due to indexed db) 2024-10-22 17:30:49 +05:30
Palanikannan M 594ae81c31 feat: old and new lists coexist 2024-10-22 15:45:06 +05:30
Palanikannan M 8a41b523d6 chore: renamed manual logger and logger middleware across the live server 2024-10-22 15:44:36 +05:30
Palanikannan M fb6cdb7f86 Merge branch 'preview' into feat/flat-list 2024-10-22 13:06:39 +05:30
Palanikannan M ce99563a0d fix: types to insert list 2024-10-22 13:06:37 +05:30
Palanikannan M 1b9b59799e fix: able to render prosemirror flat lists 2024-10-21 20:52:20 +05:30
Palanikannan M a83903280c wip: added flat list 2024-10-21 15:32:34 +05:30
215 changed files with 11671 additions and 2741 deletions
+2
View File
@@ -85,3 +85,5 @@ deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-server
+3 -1
View File
@@ -38,6 +38,8 @@ export const WorkspaceCreateForm = () => {
getValues,
formState: { errors, isSubmitting, isValid },
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
// derived values
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
const handleCreateWorkspace = async (formData: IWorkspace) => {
await workspaceService
@@ -124,7 +126,7 @@ export const WorkspaceCreateForm = () => {
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Set your workspace&apos;s URL</h4>
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
<span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span>
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
<Controller
control={control}
name="slug"
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
// helpers
import { Tooltip } from "@plane/ui";
@@ -20,7 +19,7 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
if (!workspace) return null;
return (
<Link
<a
key={workspaceId}
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
@@ -77,6 +76,6 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
<div className="flex-shrink-0">
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
</div>
</Link>
</a>
);
});
Binary file not shown.
+8 -8
View File
@@ -114,7 +114,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -134,7 +134,7 @@ class PageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -234,7 +234,7 @@ class PageViewSet(BaseViewSet):
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +244,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +255,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -296,7 +296,7 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -323,7 +323,7 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -348,7 +348,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
+9 -9
View File
@@ -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",
+18 -10
View File
@@ -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)",
);
}
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
+25 -14
View File
@@ -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,
};
};
+12
View File
@@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});
+2 -2
View File
@@ -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) {
+4 -4
View File
@@ -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;
}
};
+17 -16
View File
@@ -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();
});
+1 -1
View File
@@ -2,5 +2,5 @@
"name": "@plane/constants",
"version": "0.24.0",
"private": true,
"main": "./index.ts"
"main": "./src/index.ts"
}
+15
View File
@@ -0,0 +1,15 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
// PI Base Url
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./endpoints";
export * from "./issue";
export * from "./workspace";
+76
View File
@@ -0,0 +1,76 @@
export const ORGANIZATION_SIZE = [
"Just myself",
"2-10",
"11-50",
"51-200",
"201-500",
"500+",
];
export const RESTRICTED_URLS = [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
"password",
"flags",
"monitor",
"monitoring",
"ingest",
"plane-pro",
"plane-ultimate",
"enterprise",
"plane-enterprise",
"disco",
"silo",
"chat",
"calendar",
"drive",
"channels",
"upgrade",
"billing",
"sign-in",
"sign-up",
"signin",
"signup",
"config",
"live",
"admin",
"m",
"import",
"importers",
"integrations",
"integration",
"configuration",
"initiatives",
"initiative",
"config",
"workflow",
"workflows",
"epics",
"epic",
"story",
"mobile",
"dashboard",
"desktop",
"onload",
"real-time",
"one",
"pages",
"mobile",
"business",
"pro",
"settings",
"monitor",
"license",
"licenses",
"instances",
"instance",
];
-23
View File
@@ -1,23 +0,0 @@
export const ORGANIZATION_SIZE = [
"Just myself",
"2-10",
"11-50",
"51-200",
"201-500",
"500+",
];
export const RESTRICTED_URLS = [
"404",
"accounts",
"api",
"create-workspace",
"error",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
];
+21 -20
View File
@@ -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": "*",
@@ -0,0 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -0,0 +1,2 @@
export * from "./extensions";
export * from "./read-only-extensions";
@@ -0,0 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
@@ -15,7 +15,13 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
return extensions;
};
@@ -1 +1,3 @@
export * from "./core";
export * from "./document-extensions";
export * from "./slash-commands";
@@ -0,0 +1,14 @@
// extensions
import { TSlashCommandAdditionalOption } from "@/extensions";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
const options: TSlashCommandAdditionalOption[] = [];
return options;
};
@@ -15,6 +15,7 @@ import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/ty
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -37,6 +38,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
@@ -31,6 +32,7 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
}
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -19,6 +19,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
@@ -37,6 +38,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
disabledExtensions,
editorClassName,
enableHistory: true,
extensions,
@@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
fileHandler,
@@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
} = props;
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
fileHandler,
forwardedRef,
@@ -8,12 +8,7 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const {
disabledExtensions,
dragDropEnabled,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => {
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(SlashCommands());
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
@@ -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),
];
};
@@ -0,0 +1,6 @@
export const DocumentCollaborativeEvents = {
lock: { client: "locked", server: "lock" },
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
} as const;
@@ -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,9 @@ 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";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -89,6 +92,8 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
FlatListExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
@@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
@@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
}
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
@@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
setResolvedSrc(url as string);
};
getImageSource();
}, [imageFromFileSystem, node.attrs.src]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
@@ -1,11 +1,9 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// extensions
import { CustomImageNode, ImageAttributes } from "@/extensions/custom-image";
import { CustomImageNode } from "@/extensions/custom-image";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
@@ -126,14 +124,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
// escape markdown for images
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const attrs = node.attrs as ImageAttributes;
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
const imageWidth = state.esc(attrs.width?.toString());
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
state.closeBlock(node);
},
serialize() {},
},
};
},
@@ -1,10 +1,8 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, ImageAttributes, UploadImageExtensionStorage } from "@/extensions/custom-image";
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TFileHandler } from "@/types";
@@ -54,14 +52,9 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
addStorage() {
return {
fileMap: new Map(),
// escape markdown for images
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const attrs = node.attrs as ImageAttributes;
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
const imageWidth = state.esc(attrs.width?.toString());
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
state.closeBlock(node);
},
serialize() {},
},
};
},
@@ -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;
// },
};
},
});
@@ -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,10 +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 {
@@ -23,7 +24,6 @@ import {
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
@@ -32,9 +32,18 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/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[];
enableHistory: boolean;
fileHandler: TFileHandler;
mentionConfig: {
@@ -45,35 +54,53 @@ type TArguments = {
tabIndex?: number;
};
export const CoreEditorExtensions = (args: TArguments) => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
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({
@@ -82,7 +109,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -162,5 +188,11 @@ export const CoreEditorExtensions = (args: TArguments) => {
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];
},
});
@@ -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)
})
})

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