Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/format/commands.ts
1698 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
import { delay } from "awaiting";
7
import { isEqual } from "lodash";
8
9
import { redux } from "@cocalc/frontend/app-framework";
10
import { commands } from "@cocalc/frontend/editors/editor-button-bar";
11
import { getLocale } from "@cocalc/frontend/i18n";
12
import { is_array, startswith } from "@cocalc/util/misc";
13
import {
14
BaseRange,
15
Editor,
16
Element,
17
Location,
18
Node,
19
Point,
20
Range,
21
Text,
22
Transforms,
23
} from "slate";
24
import { getMarks } from "../edit-bar/marks";
25
import { SlateEditor } from "../editable-markdown";
26
import { markdown_to_slate } from "../markdown-to-slate";
27
import { emptyParagraph } from "../padding";
28
import { ReactEditor } from "../slate-react";
29
import { removeBlankLines } from "../util";
30
import { insertAIFormula } from "./insert-ai-formula";
31
import { insertImage } from "./insert-image";
32
import { insertLink } from "./insert-link";
33
import { insertSpecialChar } from "./insert-special-char";
34
35
// currentWord:
36
//
37
// Expand collapsed selection to range containing exactly the
38
// current word, even if selection potentially spans multiple
39
// text nodes. If cursor is not *inside* a word (being on edge
40
// is not inside) then returns undefined. Otherwise, returns
41
// the Range containing the current word.
42
//
43
// NOTE: I posted this on the slate Github and there's a discussion
44
// with various varients based on this:
45
// https://github.com/ianstormtaylor/slate/issues/4162
46
function currentWord(editor: SlateEditor): Range | undefined {
47
const selection = getSelection(editor);
48
if (selection == null || !Range.isCollapsed(selection)) {
49
return; // nothing to do -- no current word.
50
}
51
const { focus } = selection;
52
const [node, path] = Editor.node(editor, focus);
53
if (!Text.isText(node)) {
54
// focus must be in a text node.
55
return;
56
}
57
const { offset } = focus;
58
const siblings: any[] = Node.parent(editor, path).children as any;
59
60
// We move to the left from the cursor until leaving the current
61
// word and to the right as well in order to find the
62
// start and end of the current word.
63
let start = { i: path[path.length - 1], offset };
64
let end = { i: path[path.length - 1], offset };
65
if (offset == siblings[start.i]?.text?.length) {
66
// special case when starting at the right hand edge of text node.
67
moveRight(start);
68
moveRight(end);
69
}
70
const start0 = { ...start };
71
const end0 = { ...end };
72
73
function len(node): number {
74
// being careful that there could be some non-text nodes in there, which
75
// we just treat as length 0.
76
return node?.text?.length ?? 0;
77
}
78
79
function charAt(pos: { i: number; offset: number }): string {
80
const c = siblings[pos.i]?.text?.[pos.offset] ?? "";
81
return c;
82
}
83
84
function moveLeft(pos: { i: number; offset: number }): boolean {
85
if (pos.offset == 0) {
86
if ((pos.i = 0)) return false;
87
pos.i -= 1;
88
pos.offset = Math.max(0, len(siblings[pos.i]) - 1);
89
return true;
90
} else {
91
pos.offset -= 1;
92
return true;
93
}
94
return false;
95
}
96
97
function moveRight(pos: { i: number; offset: number }): boolean {
98
if (pos.offset + 1 < len(siblings[pos.i])) {
99
pos.offset += 1;
100
return true;
101
} else {
102
if (pos.i + 1 < siblings.length) {
103
pos.offset = 0;
104
pos.i += 1;
105
return true;
106
} else {
107
if (pos.offset < len(siblings[pos.i])) {
108
pos.offset += 1; // end of the last block.
109
return true;
110
}
111
}
112
}
113
return false;
114
}
115
116
while (charAt(start).match(/\w/) && moveLeft(start)) {}
117
// move right 1.
118
moveRight(start);
119
while (charAt(end).match(/\w/) && moveRight(end)) {}
120
if (isEqual(start, start0) || isEqual(end, end0)) {
121
// if at least one endpoint doesn't change, cursor was not inside a word,
122
// so we do not select.
123
return;
124
}
125
126
const path0 = path.slice(0, path.length - 1);
127
return {
128
anchor: { path: path0.concat([start.i]), offset: start.offset },
129
focus: { path: path0.concat([end.i]), offset: end.offset },
130
};
131
}
132
133
function isMarkActive(editor: Editor, mark: string): boolean {
134
try {
135
return !!Editor.marks(editor)?.[mark];
136
} catch (err) {
137
// see comment in getMarks...
138
console.warn("Editor.marks", err);
139
return false;
140
}
141
}
142
143
function toggleMark(editor: Editor, mark: string): void {
144
if (isMarkActive(editor, mark)) {
145
Editor.removeMark(editor, mark);
146
} else {
147
Editor.addMark(editor, mark, true);
148
}
149
}
150
151
export function formatSelectedText(editor: SlateEditor, mark: string) {
152
const selection = getSelection(editor);
153
if (selection == null) return; // nothing to do.
154
if (Range.isCollapsed(selection)) {
155
// select current word (which may partly span multiple text nodes!)
156
const at = currentWord(editor);
157
if (at != null) {
158
// editor.saveValue(true); // TODO: make snapshot so can undo to before format
159
Transforms.setNodes(
160
editor,
161
{ [mark]: !isAlreadyMarked(editor, mark) ? true : undefined },
162
{ at, split: true, match: (node) => Text.isText(node) },
163
);
164
return;
165
}
166
// No current word.
167
// Set thing so if you start typing it has the given
168
// mark (or doesn't).
169
toggleMark(editor, mark);
170
return;
171
}
172
173
// This formats exactly the current selection or node, even if
174
// selection spans many nodes, etc.
175
Transforms.setNodes(
176
editor,
177
{ [mark]: !isAlreadyMarked(editor, mark) ? true : undefined },
178
{ at: selection, match: (node) => Text.isText(node), split: true },
179
);
180
}
181
182
function unformatSelectedText(
183
editor: SlateEditor,
184
options: { prefix?: string },
185
): void {
186
let at: BaseRange | undefined = getSelection(editor);
187
if (at == null) return; // nothing to do.
188
if (Range.isCollapsed(at)) {
189
at = currentWord(editor);
190
}
191
if (at == null) return;
192
if (options.prefix) {
193
// Remove all formatting of the selected text
194
// that begins with the given prefix.
195
let i = 0;
196
while (i < 100) {
197
i += 1; // paranoid: just in case there is a stupid infinite loop...
198
const mark = findMarkWithPrefix(editor, options.prefix);
199
if (!mark) break;
200
Transforms.setNodes(
201
editor,
202
{ [mark]: false },
203
{ at, match: (node) => Text.isText(node), split: true },
204
);
205
}
206
}
207
}
208
209
// returns true if current selection *starts* with mark.
210
function isAlreadyMarked(editor: Editor, mark: string): boolean {
211
if (!editor.selection) return false;
212
return isFragmentAlreadyMarked(
213
Editor.fragment(editor, editor.selection),
214
mark,
215
);
216
}
217
218
// returns true if fragment *starts* with mark.
219
function isFragmentAlreadyMarked(fragment, mark: string): boolean {
220
if (is_array(fragment)) {
221
fragment = fragment[0];
222
if (fragment == null) return false;
223
}
224
if (Text.isText(fragment) && fragment[mark]) return true;
225
if (fragment.children) {
226
return isFragmentAlreadyMarked(fragment.children, mark);
227
}
228
return false;
229
}
230
231
// returns mark if current selection *starts* with a mark with the given prefix.
232
function findMarkWithPrefix(
233
editor: Editor,
234
prefix: string,
235
): string | undefined {
236
if (!editor.selection) return;
237
return findMarkedFragmentWithPrefix(
238
Editor.fragment(editor, editor.selection),
239
prefix,
240
);
241
}
242
243
// returns mark if fragment *starts* with a mark that starts with prefix
244
function findMarkedFragmentWithPrefix(
245
fragment,
246
prefix: string,
247
): string | undefined {
248
if (is_array(fragment)) {
249
fragment = fragment[0];
250
if (fragment == null) return;
251
}
252
if (Text.isText(fragment)) {
253
for (const mark in fragment) {
254
if (startswith(mark, prefix) && fragment[mark]) {
255
return mark;
256
}
257
}
258
}
259
if (fragment.children) {
260
return findMarkedFragmentWithPrefix(fragment.children, prefix);
261
}
262
return;
263
}
264
265
// TODO: make this part of a focus/last selection plugin.
266
// Is definitely a valid focus point, in that Editor.node will
267
// work on it.
268
export function getFocus(editor: SlateEditor): Point {
269
const focus = editor.selection?.focus ?? editor.lastSelection?.focus;
270
if (focus == null) {
271
return { path: [0, 0], offset: 0 };
272
}
273
try {
274
Editor.node(editor, focus);
275
} catch (_err) {
276
return { path: [0, 0], offset: 0 };
277
}
278
return focus;
279
}
280
281
// Return a definitely valid selection which is most likely
282
// to be the current selection (or what it would be, say if
283
// user recently blurred). Valid means that Editor.node will
284
// work on both ends.
285
export function getSelection(editor: SlateEditor): Range {
286
const selection = editor.selection ?? editor.lastSelection;
287
if (selection == null) {
288
return {
289
focus: { path: [0, 0], offset: 0 },
290
anchor: { path: [0, 0], offset: 0 },
291
};
292
}
293
try {
294
Editor.node(editor, selection.focus);
295
if (!Range.isCollapsed(selection)) {
296
Editor.node(editor, selection.anchor);
297
}
298
} catch (_err) {
299
return {
300
focus: { path: [0, 0], offset: 0 },
301
anchor: { path: [0, 0], offset: 0 },
302
};
303
}
304
return selection;
305
}
306
307
// get range that's the selection collapsed to the focus point.
308
export function getCollapsedSelection(editor: SlateEditor): Range {
309
const focus = getSelection(editor)?.focus;
310
return { focus, anchor: focus };
311
}
312
313
export function setSelectionAndFocus(editor: ReactEditor, selection): void {
314
ReactEditor.focus(editor);
315
Transforms.setSelection(editor, selection);
316
}
317
318
export function restoreSelectionAndFocus(editor: SlateEditor): void {
319
const { selection, lastSelection } = editor;
320
if (selection != null) return;
321
if (lastSelection == null) return;
322
setSelectionAndFocus(editor, lastSelection);
323
}
324
325
export async function formatAction(
326
editor: SlateEditor,
327
cmd: string,
328
args,
329
project_id?: string,
330
) {
331
const isFocused = ReactEditor.isFocused(editor);
332
const { selection, lastSelection } = editor;
333
try {
334
if (
335
cmd === "bold" ||
336
cmd === "italic" ||
337
cmd === "underline" ||
338
cmd === "strikethrough" ||
339
cmd === "code" ||
340
cmd === "sup" ||
341
cmd === "sub"
342
) {
343
formatSelectedText(editor, cmd);
344
return;
345
}
346
347
if (cmd === "color") {
348
// args = #aa00bc (the hex color)
349
unformatSelectedText(editor, { prefix: "color:" });
350
if (args) {
351
formatSelectedText(editor, `color:${args.toLowerCase()}`);
352
} else {
353
for (const mark in getMarks(editor)) {
354
if (mark.startsWith("color:")) {
355
Editor.removeMark(editor, mark);
356
}
357
}
358
}
359
return;
360
}
361
362
if (cmd === "font_family") {
363
unformatSelectedText(editor, { prefix: "font-family:" });
364
formatSelectedText(editor, `font-family:${args}`);
365
return;
366
}
367
368
if (startswith(cmd, "font_size")) {
369
unformatSelectedText(editor, { prefix: "font-size:" });
370
formatSelectedText(editor, `font-size:${args}`);
371
return;
372
}
373
374
if (cmd === "equation") {
375
transformToEquation(editor, false);
376
return;
377
}
378
379
if (cmd === "comment") {
380
transformToComment(editor);
381
return;
382
}
383
384
if (cmd === "display_equation") {
385
transformToEquation(editor, true);
386
return;
387
}
388
389
if (cmd === "quote") {
390
formatQuote(editor);
391
return;
392
}
393
394
if (
395
cmd === "insertunorderedlist" ||
396
cmd === "insertorderedlist" ||
397
cmd === "table" ||
398
cmd === "horizontalRule" ||
399
cmd === "linebreak"
400
) {
401
insertSnippet(editor, cmd);
402
return;
403
}
404
405
if (cmd === "link") {
406
insertLink(editor);
407
return;
408
}
409
410
if (cmd === "image") {
411
insertImage(editor);
412
return;
413
}
414
415
if (cmd === "SpecialChar") {
416
insertSpecialChar(editor);
417
return;
418
}
419
420
if (cmd === "format_code") {
421
insertMarkdown(
422
editor,
423
"\n```\n" + selectionToText(editor).trim() + "\n```\n",
424
);
425
return;
426
}
427
428
if (cmd === "ai_formula") {
429
if (project_id == null) throw new Error("ai_formula requires project_id");
430
const account_store = redux.getStore("account")
431
const locale = getLocale(account_store.get("other_settings"))
432
const formula = await insertAIFormula(project_id, locale);
433
const value = removeDollars(removeBlankLines(formula.trim()));
434
const node: Node = {
435
type: "math_inline",
436
value,
437
isVoid: true,
438
isInline: true,
439
children: [{ text: "" }],
440
};
441
Transforms.insertFragment(editor, [node]);
442
return;
443
}
444
445
if (startswith(cmd, "format_heading_")) {
446
// single digit is fine, since headings only go up to level 6.
447
const level = parseInt(cmd[cmd.length - 1]);
448
formatHeading(editor, level);
449
return;
450
}
451
} finally {
452
if (!isFocused) {
453
ReactEditor.focus(editor);
454
setSelectionAndFocus(editor, selection ?? lastSelection);
455
await delay(1);
456
ReactEditor.focus(editor);
457
setSelectionAndFocus(editor, selection ?? lastSelection);
458
}
459
}
460
461
console.warn("WARNING -- slate.format_action not implemented", {
462
cmd,
463
args,
464
editor,
465
});
466
}
467
468
function insertSnippet(editor: ReactEditor, name: string): boolean {
469
let markdown = commands.md[name]?.wrap?.left;
470
if (name == "insertunorderedlist") {
471
// better for a wysiwyg editor...
472
markdown = "-";
473
} else if (name == "insertorderedlist") {
474
markdown = "1.";
475
} else if (name == "linebreak") {
476
markdown = "<br/>";
477
}
478
if (markdown == null) return false;
479
insertMarkdown(editor, markdown.trim());
480
return true;
481
}
482
483
function insertMarkdown(editor: ReactEditor, markdown: string) {
484
const doc = markdown_to_slate(markdown, true);
485
Transforms.insertNodes(editor, [...doc, emptyParagraph()]);
486
}
487
488
function transformToEquation(editor: Editor, display: boolean): void {
489
let value = selectionToText(editor).trim();
490
if (!value) {
491
value = "x^2"; // placeholder math
492
} else {
493
// eliminate blank lines which break math apart
494
value = removeBlankLines(value);
495
}
496
let node: Node;
497
if (display) {
498
node = {
499
type: "math_block",
500
value,
501
isVoid: true,
502
children: [{ text: "" }],
503
};
504
} else {
505
node = {
506
type: "math_inline",
507
value,
508
isVoid: true,
509
isInline: true,
510
children: [{ text: "" }],
511
};
512
}
513
Transforms.insertFragment(editor, [node]);
514
}
515
516
function transformToComment(editor: Editor): void {
517
const html = "<!--" + selectionToText(editor).trim() + "-->\n\n";
518
const fragment: Node[] = [
519
{
520
type: "html_block",
521
html,
522
isVoid: true,
523
isInline: false,
524
children: [{ text: "" }],
525
},
526
];
527
Transforms.insertFragment(editor, fragment);
528
}
529
530
// TODO: This is very buggy and can't work in general, e.g., because
531
// of virtualization. we use it here usually for small snippets of
532
// visible text, so it tends to be OK. Just temper your expectations!
533
export function selectionToText(editor: Editor): string {
534
if (!editor.selection) {
535
// no selection so nothing to do.
536
return "";
537
}
538
// This is just directly using DOM API, not slatejs, so
539
// could run into a subtle problem e.g., due to windowing.
540
// However, that's very unlikely given our application.
541
return window.getSelection()?.toString() ?? "";
542
}
543
544
// Setting heading at a given point to a certain level.
545
// level = 0 -- not a heading
546
// levels = 1 to 6 -- normal headings.
547
// The code below is complicated, because there are numerous subtle
548
// cases that can arise and we have to both create and remove
549
// being a heading.
550
export function formatHeading(editor, level: number): void {
551
const at = getCollapsedSelection(editor);
552
const options = {
553
match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),
554
mode: "highest" as "highest",
555
at,
556
};
557
const fragment = Editor.fragment(editor, at);
558
const type = fragment[0]?.["type"];
559
if (type != "heading" && type != "paragraph") {
560
// Markdown doesn't let most things be in headers.
561
// Technically markdown allows for headers as entries in other
562
// things like lists, but we're not supporting this here, since
563
// that just seems really annoying.
564
return;
565
}
566
try {
567
if (type == "heading") {
568
// mutate the type to what's request
569
if (level == 0) {
570
if (Editor.isBlock(editor, fragment[0]["children"]?.[0])) {
571
// descendant of heading is a block, so we can just unwrap,
572
// which we *can't* do if it were an inline node (e.g., text).
573
Transforms.unwrapNodes(editor, {
574
match: (node) => node["type"] == "heading",
575
mode: "highest",
576
at,
577
});
578
return;
579
}
580
// change header to paragraph
581
Transforms.setNodes(
582
editor,
583
{ type: "paragraph", level: undefined } as Partial<Element>,
584
options,
585
);
586
} else {
587
// change header level
588
Transforms.setNodes(editor, { level } as Partial<Element>, options);
589
}
590
return;
591
}
592
if (level == 0) return; // paragraph mode -- no heading.
593
Transforms.setNodes(
594
editor,
595
{ type: "heading", level } as Partial<Element>,
596
options,
597
);
598
} finally {
599
setSelectionAndFocus(editor, at);
600
}
601
}
602
603
function matchingNodes(editor, options): Element[] {
604
const v: Element[] = [];
605
for (const x of Editor.nodes(editor, options)) {
606
const elt = x[0];
607
if (Element.isElement(elt)) {
608
// **this specifically excludes including the entire editor
609
// as a matching node**
610
v.push(elt);
611
}
612
}
613
return v;
614
}
615
616
function containingBlocks(editor: Editor, at: Location): Element[] {
617
return matchingNodes(editor, {
618
at,
619
mode: "lowest",
620
match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),
621
});
622
}
623
624
function isExactlyInBlocksOfType(
625
editor: Editor,
626
at: Location,
627
type: string,
628
): boolean {
629
// Get the blocks of the given type containing at:
630
const blocksOfType = matchingNodes(editor, {
631
at,
632
mode: "lowest",
633
match: (node) => node["type"] == type,
634
});
635
if (blocksOfType.length == 0) {
636
return false;
637
}
638
// The content in at *might* be exactly contained
639
// in blocks of the given type. To decide, first
640
// get the blocks containing at:
641
let blocks: Element[] = containingBlocks(editor, at);
642
643
// This is complicated, of course mainly due
644
// to multiple blocks.
645
for (const blockOfType of blocksOfType) {
646
const { children } = blockOfType;
647
if (!isEqual(children, blocks.slice(0, children.length))) {
648
return false;
649
} else {
650
blocks = blocks.slice(children.length);
651
}
652
}
653
return true;
654
}
655
656
// Toggle whether or not the selection is quoted.
657
function formatQuote(editor): void {
658
const at = getSelection(editor);
659
660
// The selected text *might* be exactly contained
661
// in a blockquote (or multiple of them). If so, we remove it.
662
// If not we wrap everything in a new block quote.
663
if (isExactlyInBlocksOfType(editor, at, "blockquote")) {
664
// Unquote the selected text (just removes ones level of quoting).
665
Transforms.unwrapNodes(editor, {
666
match: (node) => node["type"] == "blockquote",
667
mode: "lowest",
668
at,
669
});
670
} else {
671
// Quote the blocks containing the selection.
672
Transforms.wrapNodes(editor, { type: "blockquote" } as Element, {
673
at,
674
match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),
675
mode: "lowest",
676
});
677
}
678
}
679
680
// Get rid of starting and ending $..$ or $$..$$ dollar signs
681
function removeDollars(formula: string): string {
682
if (formula.startsWith("$") && formula.endsWith("$")) {
683
return formula.substring(1, formula.length - 1);
684
}
685
686
if (formula.startsWith("$$") && formula.endsWith("$$")) {
687
return formula.substring(2, formula.length - 2);
688
}
689
690
return formula;
691
}
692
693