Skip to content
File

Blob: src/client/components/editor/controllers/details-block.ts

typescript134 lines
1import type { Editor, JSONContent, Range } from "@tiptap/core";
2import { TextSelection, type Transaction } from "@tiptap/pm/state";
3export { DEFAULT_DETAILS_SUMMARY, DETAILS_SUMMARY_PLACEHOLDER } from "@/shared/editor/schema";
4 
5export interface DetailsBlockAttrs {
6 summary?: string;
7 open?: boolean;
8}
9 
10export function createDetailsBlockNode(attrs: DetailsBlockAttrs = {}): JSONContent {
11 const summary = attrs.summary?.trim();
12 
13 return {
14 type: "details",
15 attrs: {
16 open: attrs.open ?? true,
17 },
18 content: [
19 {
20 type: "detailsSummary",
21 content: summary
22 ? [
23 {
24 type: "text",
25 text: summary,
26 },
27 ]
28 : undefined,
29 },
30 {
31 type: "detailsContent",
32 content: [
33 {
34 type: "paragraph",
35 },
36 ],
37 },
38 ],
39 };
40}
41 
42export function insertDetailsBlock(editor: Editor, range: Range, attrs: DetailsBlockAttrs = {}) {
43 const summary = attrs.summary?.trim() ?? "";
44 const summaryStart = range.from + 2;
45 
46 const inserted = editor
47 .chain()
48 .focus(null, { scrollIntoView: false })
49 .deleteRange(range)
50 .insertContent(createDetailsBlockNode(attrs))
51 .setTextSelection({ from: summaryStart, to: summaryStart + summary.length })
52 .run();
53 
54 if (!inserted) return;
55 
56 requestAnimationFrame(() => {
57 if (editor.isDestroyed) return;
58 
59 if (!summary) {
60 focusEmptyDetailsSummary(editor);
61 return;
62 }
63 
64 editor
65 .chain()
66 .focus(null, { scrollIntoView: false })
67 .setTextSelection({ from: summaryStart, to: summaryStart + summary.length })
68 .run();
69 });
70}
71 
72function focusEmptyDetailsSummary(editor: Editor) {
73 const { selection } = editor.state;
74 const { $from } = selection;
75 
76 let detailsDepth = -1;
77 for (let depth = $from.depth; depth > 0; depth--) {
78 if ($from.node(depth).type.name === "details") {
79 detailsDepth = depth;
80 break;
81 }
82 }
83 
84 if (detailsDepth < 0) return;
85 
86 const detailsPos = $from.before(detailsDepth);
87 const summaryPos = detailsPos + 2;
88 const tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, summaryPos));
89 editor.view.dispatch(tr);
90 editor.view.focus();
91}
92 
93export function applyMoveToDetailsContent(tr: Transaction): boolean {
94 const { selection } = tr;
95 const { schema } = tr.doc.type;
96 const { empty, $from } = selection;
97 
98 if (!empty || $from.parent.type !== schema.nodes.detailsSummary) {
99 return false;
100 }
101 
102 let detailsDepth = -1;
103 for (let depth = $from.depth; depth > 0; depth--) {
104 if ($from.node(depth).type === schema.nodes.details) {
105 detailsDepth = depth;
106 break;
107 }
108 }
109 
110 if (detailsDepth < 0) {
111 return false;
112 }
113 
114 const detailsPos = $from.before(detailsDepth);
115 const detailsNode = $from.node(detailsDepth);
116 const detailsSummary = detailsNode.firstChild;
117 const detailsContent = detailsNode.childCount > 1 ? detailsNode.child(1) : null;
118 
119 if (!detailsSummary || !detailsContent || detailsContent.type !== schema.nodes.detailsContent) {
120 return false;
121 }
122 
123 if (detailsNode.attrs.open === false) {
124 tr.setNodeMarkup(detailsPos, undefined, {
125 ...detailsNode.attrs,
126 open: true,
127 });
128 }
129 
130 const contentPos = detailsPos + 1 + detailsSummary.nodeSize;
131 tr.setSelection(TextSelection.near(tr.doc.resolve(contentPos + 1)));
132 return true;
133}