Skip to content
File

Blob: tests/client/components/editor/lib/block-actions.test.ts

typescript163 lines
1import { afterEach, describe, expect, it, vi } from "vitest";
2import { getSchema } from "@tiptap/core";
3import { EditorState, NodeSelection, TextSelection } from "@tiptap/pm/state";
4import { StarterKit } from "@tiptap/starter-kit";
5import {
6 applyDeleteTopLevelBlock,
7 applyMoveTopLevelBlock,
8 canMoveTopLevelBlock,
9 moveTopLevelBlock,
10} from "@/client/components/editor/lib/block-actions";
11import { DetailsBlockExtensions } from "@/client/components/editor/extensions/details-block";
12import { TopLevelBlockIdentity } from "@/client/components/editor/extensions/top-level-block-identity";
13import { createDetailsNode, createParagraphNode } from "@tests/client/util/editor-fixtures";
14import { createDispatchingTestEditor } from "@tests/client/util/editor-mocks";
15 
16const schema = getSchema([StarterKit.configure({ undoRedo: false }), ...DetailsBlockExtensions, TopLevelBlockIdentity]);
17 
18describe("block action helpers", () => {
19 afterEach(() => {
20 vi.unstubAllGlobals();
21 });
22 
23 it("moves a details block upward as a single top-level block", () => {
24 const doc = schema.nodeFromJSON({
25 type: "doc",
26 content: [
27 createParagraphNode("Intro", "intro"),
28 createDetailsNode({ bid: "details", summary: "Specs" }),
29 createParagraphNode("Outro", "outro"),
30 ],
31 });
32 const tr = EditorState.create({ schema, doc }).tr;
33 
34 expect(applyMoveTopLevelBlock(tr, "details", -1)).toBe(true);
35 expect(tr.doc.toJSON()).toEqual({
36 type: "doc",
37 content: [
38 createDetailsNode({ bid: "details", summary: "Specs" }),
39 createParagraphNode("Intro", "intro"),
40 createParagraphNode("Outro", "outro"),
41 ],
42 });
43 expect(tr.selection).toBeInstanceOf(NodeSelection);
44 expect((tr.selection as NodeSelection).from).toBe(0);
45 });
46 
47 it("moves a code block without changing its language or bid", () => {
48 const doc = schema.nodeFromJSON({
49 type: "doc",
50 content: [
51 createParagraphNode("Intro", "intro"),
52 {
53 type: "codeBlock",
54 attrs: { bid: "code", language: "typescript" },
55 content: [{ type: "text", text: "const count = 1" }],
56 },
57 ],
58 });
59 const tr = EditorState.create({ schema, doc }).tr;
60 
61 expect(applyMoveTopLevelBlock(tr, "code", -1)).toBe(true);
62 expect(tr.doc.toJSON()).toEqual({
63 type: "doc",
64 content: [
65 {
66 type: "codeBlock",
67 attrs: { bid: "code", language: "typescript" },
68 content: [{ type: "text", text: "const count = 1" }],
69 },
70 createParagraphNode("Intro", "intro"),
71 ],
72 });
73 expect(tr.selection).toBeInstanceOf(NodeSelection);
74 });
75 
76 it("replaces the final block with an empty paragraph when deleting it", () => {
77 const doc = schema.nodeFromJSON({
78 type: "doc",
79 content: [createDetailsNode({ bid: "details" })],
80 });
81 const tr = EditorState.create({ schema, doc }).tr;
82 
83 expect(applyDeleteTopLevelBlock(tr, "details")).toBe(true);
84 expect(tr.doc.toJSON()).toEqual({
85 type: "doc",
86 content: [{ type: "paragraph", attrs: { bid: null } }],
87 });
88 expect(tr.selection.from).toBe(1);
89 });
90 
91 it("reports move availability from the live top-level block order", () => {
92 const doc = schema.nodeFromJSON({
93 type: "doc",
94 content: [
95 createParagraphNode("First", "first"),
96 createDetailsNode({ bid: "details" }),
97 createParagraphNode("Last", "last"),
98 ],
99 });
100 
101 expect(canMoveTopLevelBlock(doc, "first", -1)).toBe(false);
102 expect(canMoveTopLevelBlock(doc, "details", -1)).toBe(true);
103 expect(canMoveTopLevelBlock(doc, "details", 1)).toBe(true);
104 expect(canMoveTopLevelBlock(doc, "last", 1)).toBe(false);
105 });
106 
107 it("collapses moved code blocks to a text selection after dispatch", () => {
108 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
109 callback(0);
110 return 1;
111 });
112 
113 const doc = schema.nodeFromJSON({
114 type: "doc",
115 content: [
116 createParagraphNode("Intro", "intro"),
117 {
118 type: "codeBlock",
119 attrs: { bid: "code", language: "typescript" },
120 content: [{ type: "text", text: "const count = 1" }],
121 },
122 ],
123 });
124 const { editor, view } = createDispatchingTestEditor(schema, doc);
125 
126 expect(moveTopLevelBlock(editor, "code", -1)).toBe(true);
127 expect(view.state.doc.toJSON()).toEqual({
128 type: "doc",
129 content: [
130 {
131 type: "codeBlock",
132 attrs: { bid: "code", language: "typescript" },
133 content: [{ type: "text", text: "const count = 1" }],
134 },
135 createParagraphNode("Intro", "intro"),
136 ],
137 });
138 expect(view.state.selection).toBeInstanceOf(TextSelection);
139 expect(view.state.selection.$head.parent.type.name).toBe("codeBlock");
140 expect(view.focus).toHaveBeenCalledTimes(1);
141 expect(view.updateState).toHaveBeenCalledTimes(1);
142 });
143 
144 it("keeps moved non-textblocks as node selections after dispatch", () => {
145 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
146 callback(0);
147 return 1;
148 });
149 
150 const doc = schema.nodeFromJSON({
151 type: "doc",
152 content: [createParagraphNode("Intro", "intro"), createDetailsNode({ bid: "details", summary: "Specs" })],
153 });
154 const { editor, view } = createDispatchingTestEditor(schema, doc);
155 
156 expect(moveTopLevelBlock(editor, "details", -1)).toBe(true);
157 expect(view.state.selection).toBeInstanceOf(NodeSelection);
158 expect(view.state.selection.$head.parent.type.name).toBe("doc");
159 expect(view.focus).toHaveBeenCalledTimes(1);
160 expect(view.updateState).toHaveBeenCalledTimes(1);
161 });
162});