File
Blob: tests/client/components/editor/lib/block-actions.test.ts
| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { getSchema } from "@tiptap/core"; |
| 3 | import { EditorState, NodeSelection, TextSelection } from "@tiptap/pm/state"; |
| 4 | import { StarterKit } from "@tiptap/starter-kit"; |
| 5 | import { |
| 6 | applyDeleteTopLevelBlock, |
| 7 | applyMoveTopLevelBlock, |
| 8 | canMoveTopLevelBlock, |
| 9 | moveTopLevelBlock, |
| 10 | } from "@/client/components/editor/lib/block-actions"; |
| 11 | import { DetailsBlockExtensions } from "@/client/components/editor/extensions/details-block"; |
| 12 | import { TopLevelBlockIdentity } from "@/client/components/editor/extensions/top-level-block-identity"; |
| 13 | import { createDetailsNode, createParagraphNode } from "@tests/client/util/editor-fixtures"; |
| 14 | import { createDispatchingTestEditor } from "@tests/client/util/editor-mocks"; |
| 15 | |
| 16 | const schema = getSchema([StarterKit.configure({ undoRedo: false }), ...DetailsBlockExtensions, TopLevelBlockIdentity]); |
| 17 | |
| 18 | describe("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 | }); |