the browser-facing portion of osu!
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
3
4import { EmbedElement, ParagraphElement } from 'editor';
5import {
6 BeatmapDiscussionReview,
7 DocumentIssueEmbed,
8} from 'interfaces/beatmap-discussion-review';
9import { Editor, Element as SlateElement, Node as SlateNode, Range as SlateRange, Text, Transforms } from 'slate';
10import { parseTimestamp } from 'utils/beatmapset-discussion-helper';
11import { present } from 'utils/string';
12
13export const blockCount = (input: SlateElement[]) => input.length;
14
15export const slateDocumentIsEmpty = (doc: SlateElement[]) => doc.length === 0 || (
16 doc.length === 1 &&
17 doc[0].type === 'paragraph' &&
18 doc[0].children.length === 1 &&
19 doc[0].children[0].text === ''
20);
21
22export const insideEmbed = (editor: Editor) => {
23 const node = getCurrentNode(editor);
24 if (node == null) return false;
25
26 return 'type' in node && node.type === 'embed';
27};
28
29export const insideEmptyNode = (editor: Editor) => {
30 const parent = getCurrentNode(editor);
31 if (parent == null) return false;
32
33 if ('type' in parent) {
34 return Editor.isEmpty(editor, parent);
35 }
36
37 return false;
38};
39
40export const isFormatActive = (editor: Editor, format: 'bold' | 'italic') => {
41 const [match] = Editor.nodes(editor, {
42 match: (node) => Text.isText(node) && node[format] === true,
43 mode: 'all',
44 });
45 return match != null;
46};
47
48const getCurrentNode = (editor: Editor) => {
49 if (editor.selection) {
50 return SlateNode.parent(editor, SlateRange.start(editor.selection).path);
51 }
52};
53
54export const toggleFormat = (editor: Editor, format: 'bold' | 'italic') => {
55 Transforms.setNodes(
56 editor,
57 { [format]: isFormatActive(editor, format) ? null : true },
58 { match: (node) => Text.isText(node), split: true },
59 );
60};
61
62// TODO: check typing
63function serializeEmbed(node: EmbedElement): DocumentIssueEmbed {
64 if (node.discussionId != null) {
65 return {
66 discussion_id: node.discussionId,
67 type: 'embed',
68 };
69 } else {
70 return {
71 beatmap_id: node.beatmapId ?? null,
72 discussion_type: node.discussionType,
73 text: node.children[0].text,
74 timestamp: parseTimestamp(node.timestamp),
75 type: 'embed',
76 };
77 }
78}
79
80// Prevent invalid markdown from being generated by whitespace after/before the opening/closing marks
81function serializeMarkedText(text: string, format: string) {
82 const trimmedText = text.trim();
83 if (trimmedText.length === 0) {
84 return;
85 }
86
87 const formattedText = `${format}${trimmedText}${format}`;
88
89 if (trimmedText === text) {
90 return formattedText;
91 }
92
93 return text.replace(trimmedText, formattedText);
94}
95
96function serializeParagraph(node: ParagraphElement) {
97 return node.children.map((child) => {
98 if (child.text !== '') {
99 const text = child.text.replace(/([*_\\])/g, '\\$1');
100 // simplified logic that forces nested marks to be split;
101 // removing whitespace while preserving the nested marks gets messy.
102 if (child.bold && child.italic) {
103 return serializeMarkedText(text, '***');
104 } else if (child.bold) {
105 return serializeMarkedText(text, '**');
106 } else if (child.italic) {
107 return serializeMarkedText(text, '*');
108 } else {
109 return text;
110 }
111 }
112 }).join('');
113}
114
115export const slateDocumentContainsNewProblem = (input: SlateElement[]) =>
116 input.some((node) => node.type === 'embed' && node.discussionType === 'problem' && node.discussionId == null);
117
118export const serializeSlateDocument = (input: SlateElement[]) => {
119 const review: BeatmapDiscussionReview = [];
120
121 input.forEach((node) => {
122 switch (node.type) {
123 case 'paragraph':
124 review.push({
125 text: serializeParagraph(node),
126 type: 'paragraph',
127 });
128 break;
129
130 case 'embed':
131 review.push(serializeEmbed(node));
132 break;
133 }
134 });
135
136 // strip last block if it's empty (i.e. the placeholder that allows easier insertion at the end of a document)
137 const lastBlock = review[review.length - 1];
138 if (lastBlock.type === 'paragraph' && !present(lastBlock.text)) {
139 review.pop();
140 }
141
142 return JSON.stringify(review);
143};