Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/normalize.ts
1691 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/* Ideas for things to put here that aren't here now:
7
8
- merging adjacent lists, since the roundtrip to markdown does that.
9
10
WARNING: The following warning used to apply. However, we now normalize
11
markdown_to_slate always, so it does not apply: "Before very very very
12
careful before changing anything here!!!
13
It is absolutely critical that the output of markdown_to_slate be normalized
14
according to all the rules here. If you change a rule here, that will
15
likely break this assumption and things will go to hell. Be careful.""
16
17
*/
18
19
import { Editor, Element, Path, Range, Text, Transforms } from "slate";
20
import { isEqual } from "lodash";
21
22
import { getNodeAt } from "./slate-util";
23
import { emptyParagraph } from "./padding";
24
import { isListElement } from "./elements/list";
25
26
interface NormalizeInputs {
27
editor?: Editor;
28
node?: Node;
29
path?: Path;
30
}
31
32
type NormalizeFunction = (NormalizeInputs) => void;
33
34
const NORMALIZERS: NormalizeFunction[] = [];
35
36
export const withNormalize = (editor) => {
37
const { normalizeNode } = editor;
38
39
editor.normalizeNode = (entry) => {
40
const [node, path] = entry;
41
42
for (const f of NORMALIZERS) {
43
//const before = JSON.stringify(editor.children);
44
const before = editor.children;
45
f({ editor, node, path });
46
if (before !== editor.children) {
47
// changed so return; normalize will get called again by
48
// slate until no changes.
49
return;
50
}
51
}
52
53
// No changes above, so fall back to the original `normalizeNode`
54
// to enforce other constraints. Important to not call any normalize
55
// if there were any changes, since they can make the entry invalid!
56
normalizeNode(entry);
57
};
58
59
return editor;
60
};
61
62
// This does get called if you somehow blank the document. It
63
// gets called with path=[], which makes perfect sense. If we
64
// don't put something in, then things immediately break due to
65
// selection assumptions. Slate doesn't do this automatically,
66
// since it doesn't nail down the internal format of a blank document.
67
NORMALIZERS.push(function ensureDocumentNonempty({ editor }) {
68
if (editor.children.length == 0) {
69
Editor.insertNode(editor, emptyParagraph());
70
}
71
});
72
73
// Ensure every list_item is contained in a list.
74
NORMALIZERS.push(function ensureListItemInAList({ editor, node, path }) {
75
if (Element.isElement(node) && node.type === "list_item") {
76
const [parent] = Editor.parent(editor, path);
77
if (!isListElement(parent)) {
78
// invalid document: every list_item should be in a list.
79
Transforms.wrapNodes(editor, { type: "bullet_list" } as Element, {
80
at: path,
81
});
82
}
83
}
84
});
85
86
// Ensure every immediate child of a list is a list_item. Also, ensure
87
// that the children of each list_item are block level elements, since this
88
// makes list manipulation much easier and more consistent.
89
NORMALIZERS.push(function ensureListContainsListItems({ editor, node, path }) {
90
if (
91
Element.isElement(node) &&
92
(node.type === "bullet_list" || node.type == "ordered_list")
93
) {
94
let i = 0;
95
for (const child of node.children) {
96
if (!Element.isElement(child) || child.type != "list_item") {
97
// invalid document: every child of a list should be a list_item
98
Transforms.wrapNodes(editor, { type: "list_item" } as Element, {
99
at: path.concat([i]),
100
mode: "lowest",
101
});
102
return;
103
}
104
if (!Element.isElement(child.children[0])) {
105
// if the the children of the list item are leaves, wrap
106
// them all in a paragraph (for consistency with what our
107
// convertor from markdown does, and also our doc manipulation,
108
// e.g., backspace, assumes this).
109
Transforms.wrapNodes(editor, { type: "paragraph" } as Element, {
110
mode: "lowest",
111
match: (node) => !Element.isElement(node),
112
at: path.concat([i]),
113
});
114
}
115
i += 1;
116
}
117
}
118
});
119
120
/*
121
Trim *all* whitespace from the beginning of blocks whose first child is Text,
122
since markdown doesn't allow for it. (You can use   of course.)
123
*/
124
NORMALIZERS.push(function trimLeadingWhitespace({ editor, node, path }) {
125
if (Element.isElement(node) && Text.isText(node.children[0])) {
126
const firstText = node.children[0].text;
127
if (firstText != null) {
128
// We actually get rid of spaces and tabs, but not ALL whitespace. For example,
129
// if you type "  bar", then autoformat turns that into *two* whitespace
130
// characters, with the   being ascii 160, which counts if we just searched
131
// via .search(/\S|$/), but not if we explicitly only look for space or tab as below.
132
const i = firstText.search(/[^ \t]|$/);
133
if (i > 0) {
134
const p = path.concat([0]);
135
const { selection } = editor;
136
const text = firstText.slice(0, i);
137
editor.apply({ type: "remove_text", offset: 0, path: p, text });
138
if (
139
selection != null &&
140
Range.isCollapsed(selection) &&
141
isEqual(selection.focus.path, p)
142
) {
143
const offset = Math.max(0, selection.focus.offset - i);
144
const focus = { path: p, offset };
145
setTimeout(() =>
146
Transforms.setSelection(editor, { focus, anchor: focus })
147
);
148
}
149
}
150
}
151
}
152
});
153
154
/*
155
If there are two adjacent lists of the same type, merge the second one into
156
the first.
157
*/
158
NORMALIZERS.push(function mergeAdjacentLists({ editor, node, path }) {
159
if (
160
Element.isElement(node) &&
161
(node.type === "bullet_list" || node.type === "ordered_list")
162
) {
163
try {
164
const nextPath = Path.next(path);
165
const nextNode = getNodeAt(editor, nextPath);
166
if (Element.isElement(nextNode) && nextNode.type == node.type) {
167
// We have two adjacent lists of the same type: combine them.
168
// Note that we do NOT take into account tightness when deciding
169
// whether to merge, since in markdown you can't have a non-tight
170
// and tight list of the same type adjacent to each other anyways.
171
Transforms.mergeNodes(editor, { at: nextPath });
172
return;
173
}
174
} catch (_) {} // because prev or next might not be defined
175
176
try {
177
const previousPath = Path.previous(path);
178
const previousNode = getNodeAt(editor, previousPath);
179
if (Element.isElement(previousNode) && previousNode.type == node.type) {
180
Transforms.mergeNodes(editor, { at: path });
181
}
182
} catch (_) {}
183
}
184
});
185
186
// Delete any empty links (with no text content), since you can't see them.
187
// This is a questionable design choice, e.g,. maybe people want to use empty
188
// links as a comment hack, as explained here:
189
// https://stackoverflow.com/questions/4823468/comments-in-markdown
190
// However, those are the footnote style links. The inline ones don't work
191
// anyways as soon as there is a space.
192
NORMALIZERS.push(function removeEmptyLinks({ editor, node, path }) {
193
if (
194
Element.isElement(node) &&
195
node.type === "link" &&
196
node.children.length == 1 &&
197
node.children[0]?.["text"] === ""
198
) {
199
Transforms.removeNodes(editor, { at: path });
200
}
201
});
202
203