]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/defaults/button-definitions.ts
Lexical: Added list support, started todo
[bookstack] / resources / js / wysiwyg / ui / defaults / button-definitions.ts
1 import {EditorBasicButtonDefinition, EditorButton, EditorButtonDefinition} from "../framework/buttons";
2 import {
3     $createNodeSelection,
4     $createParagraphNode,
5     $createTextNode,
6     $getRoot,
7     $getSelection,
8     $isParagraphNode,
9     $isTextNode,
10     $setSelection,
11     BaseSelection,
12     CAN_REDO_COMMAND,
13     CAN_UNDO_COMMAND,
14     COMMAND_PRIORITY_LOW,
15     ElementFormatType,
16     ElementNode,
17     FORMAT_TEXT_COMMAND,
18     LexicalNode,
19     REDO_COMMAND,
20     TextFormatType,
21     UNDO_COMMAND
22 } from "lexical";
23 import {
24     getBlockElementNodesInSelection,
25     getNodeFromSelection, insertNewBlockNodeAtSelection, selectionContainsElementFormat,
26     selectionContainsNodeType,
27     selectionContainsTextFormat,
28     toggleSelectionBlockNodeType
29 } from "../../helpers";
30 import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
31 import {
32     $createHeadingNode,
33     $createQuoteNode,
34     $isHeadingNode,
35     $isQuoteNode,
36     HeadingNode,
37     HeadingTagType
38 } from "@lexical/rich-text";
39 import {$isLinkNode, LinkNode} from "@lexical/link";
40 import {EditorUiContext} from "../framework/core";
41 import {$isImageNode, ImageNode} from "../../nodes/image";
42 import {$createDetailsNode, $isDetailsNode} from "../../nodes/details";
43 import {getEditorContentAsHtml} from "../../actions";
44 import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list";
45 import undoIcon from "@icons/editor/undo.svg";
46 import redoIcon from "@icons/editor/redo.svg";
47 import boldIcon from "@icons/editor/bold.svg";
48 import italicIcon from "@icons/editor/italic.svg";
49 import underlinedIcon from "@icons/editor/underlined.svg";
50 import textColorIcon from "@icons/editor/text-color.svg";
51 import highlightIcon from "@icons/editor/highlighter.svg";
52 import strikethroughIcon from "@icons/editor/strikethrough.svg";
53 import superscriptIcon from "@icons/editor/superscript.svg";
54 import subscriptIcon from "@icons/editor/subscript.svg";
55 import codeIcon from "@icons/editor/code.svg";
56 import formatClearIcon from "@icons/editor/format-clear.svg";
57 import alignLeftIcon from "@icons/editor/align-left.svg";
58 import alignCenterIcon from "@icons/editor/align-center.svg";
59 import alignRightIcon from "@icons/editor/align-right.svg";
60 import alignJustifyIcon from "@icons/editor/align-justify.svg";
61 import listBulletIcon from "@icons/editor/list-bullet.svg";
62 import listNumberedIcon from "@icons/editor/list-numbered.svg";
63 import listCheckIcon from "@icons/editor/list-check.svg";
64 import linkIcon from "@icons/editor/link.svg";
65 import unlinkIcon from "@icons/editor/unlink.svg";
66 import tableIcon from "@icons/editor/table.svg";
67 import imageIcon from "@icons/editor/image.svg";
68 import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg";
69 import codeBlockIcon from "@icons/editor/code-block.svg";
70 import detailsIcon from "@icons/editor/details.svg";
71 import sourceIcon from "@icons/editor/source-view.svg";
72 import fullscreenIcon from "@icons/editor/fullscreen.svg";
73 import editIcon from "@icons/edit.svg";
74 import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule";
75 import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block";
76
77 export const undo: EditorButtonDefinition = {
78     label: 'Undo',
79     icon: undoIcon,
80     action(context: EditorUiContext) {
81         context.editor.dispatchCommand(UNDO_COMMAND, undefined);
82     },
83     isActive(selection: BaseSelection|null): boolean {
84         return false;
85     },
86     setup(context: EditorUiContext, button: EditorButton) {
87         button.toggleDisabled(true);
88
89         context.editor.registerCommand(CAN_UNDO_COMMAND, (payload: boolean): boolean => {
90             button.toggleDisabled(!payload)
91             return false;
92         }, COMMAND_PRIORITY_LOW);
93     }
94 }
95
96 export const redo: EditorButtonDefinition = {
97     label: 'Redo',
98     icon: redoIcon,
99     action(context: EditorUiContext) {
100         context.editor.dispatchCommand(REDO_COMMAND, undefined);
101     },
102     isActive(selection: BaseSelection|null): boolean {
103         return false;
104     },
105     setup(context: EditorUiContext, button: EditorButton) {
106         button.toggleDisabled(true);
107
108         context.editor.registerCommand(CAN_REDO_COMMAND, (payload: boolean): boolean => {
109             button.toggleDisabled(!payload)
110             return false;
111         }, COMMAND_PRIORITY_LOW);
112     }
113 }
114
115 function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition {
116     return {
117         label: `${name} Callout`,
118         action(context: EditorUiContext) {
119             toggleSelectionBlockNodeType(
120                 context.editor,
121                 (node) => $isCalloutNodeOfCategory(node, category),
122                 () => $createCalloutNode(category),
123             )
124         },
125         isActive(selection: BaseSelection|null): boolean {
126             return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category));
127         }
128     };
129 }
130
131 export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info');
132 export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
133 export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
134 export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success');
135
136 const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
137       return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag;
138 };
139
140 function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition {
141     return {
142         label: name,
143         action(context: EditorUiContext) {
144             toggleSelectionBlockNodeType(
145                 context.editor,
146                 (node) => isHeaderNodeOfTag(node, tag),
147                 () => $createHeadingNode(tag),
148             )
149         },
150         isActive(selection: BaseSelection|null): boolean {
151             return selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag));
152         }
153     };
154 }
155
156 export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
157 export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
158 export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
159 export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
160
161 export const blockquote: EditorButtonDefinition = {
162     label: 'Blockquote',
163     action(context: EditorUiContext) {
164         toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode);
165     },
166     isActive(selection: BaseSelection|null): boolean {
167         return selectionContainsNodeType(selection, $isQuoteNode);
168     }
169 };
170
171 export const paragraph: EditorButtonDefinition = {
172     label: 'Paragraph',
173     action(context: EditorUiContext) {
174         toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode);
175     },
176     isActive(selection: BaseSelection|null): boolean {
177         return selectionContainsNodeType(selection, $isParagraphNode);
178     }
179 }
180
181 function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
182     return {
183         label: label,
184         icon,
185         action(context: EditorUiContext) {
186             context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
187         },
188         isActive(selection: BaseSelection|null): boolean {
189             return selectionContainsTextFormat(selection, format);
190         }
191     };
192 }
193
194 export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon);
195 export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
196 export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
197 export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
198 export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon};
199
200 export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
201 export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
202 export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon);
203 export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon);
204 export const clearFormating: EditorButtonDefinition = {
205     label: 'Clear formatting',
206     icon: formatClearIcon,
207     action(context: EditorUiContext) {
208         context.editor.update(() => {
209             const selection = $getSelection();
210             for (const node of selection?.getNodes() || []) {
211                 if ($isTextNode(node)) {
212                     node.setFormat(0);
213                     node.setStyle('');
214                 }
215             }
216         });
217     },
218     isActive() {
219         return false;
220     }
221 };
222
223 function setAlignmentForSection(alignment: ElementFormatType): void {
224     const selection = $getSelection();
225     const elements = getBlockElementNodesInSelection(selection);
226     for (const node of elements) {
227         node.setFormat(alignment);
228     }
229 }
230
231 export const alignLeft: EditorButtonDefinition = {
232     label: 'Align left',
233     icon: alignLeftIcon,
234     action(context: EditorUiContext) {
235         context.editor.update(() => setAlignmentForSection('left'));
236     },
237     isActive(selection: BaseSelection|null) {
238         return selectionContainsElementFormat(selection, 'left');
239     }
240 };
241
242 export const alignCenter: EditorButtonDefinition = {
243     label: 'Align center',
244     icon: alignCenterIcon,
245     action(context: EditorUiContext) {
246         context.editor.update(() => setAlignmentForSection('center'));
247     },
248     isActive(selection: BaseSelection|null) {
249         return selectionContainsElementFormat(selection, 'center');
250     }
251 };
252
253 export const alignRight: EditorButtonDefinition = {
254     label: 'Align right',
255     icon: alignRightIcon,
256     action(context: EditorUiContext) {
257         context.editor.update(() => setAlignmentForSection('right'));
258     },
259     isActive(selection: BaseSelection|null) {
260         return selectionContainsElementFormat(selection, 'right');
261     }
262 };
263
264 export const alignJustify: EditorButtonDefinition = {
265     label: 'Align justify',
266     icon: alignJustifyIcon,
267     action(context: EditorUiContext) {
268         context.editor.update(() => setAlignmentForSection('justify'));
269     },
270     isActive(selection: BaseSelection|null) {
271         return selectionContainsElementFormat(selection, 'justify');
272     }
273 };
274
275
276 function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
277     return {
278         label,
279         icon,
280         action(context: EditorUiContext) {
281             context.editor.getEditorState().read(() => {
282                 const selection = $getSelection();
283                 if (this.isActive(selection, context)) {
284                     removeList(context.editor);
285                 } else {
286                     insertList(context.editor, type);
287                 }
288             });
289         },
290         isActive(selection: BaseSelection|null): boolean {
291             return selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
292                 return $isListNode(node) && (node as ListNode).getListType() === type;
293             });
294         }
295     };
296 }
297
298 export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon);
299 export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
300 export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
301
302
303 export const link: EditorButtonDefinition = {
304     label: 'Insert/edit link',
305     icon: linkIcon,
306     action(context: EditorUiContext) {
307         const linkModal = context.manager.createModal('link');
308         context.editor.getEditorState().read(() => {
309             const selection = $getSelection();
310             const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
311
312             let formDefaults = {};
313             if (selectedLink) {
314                 formDefaults = {
315                     url: selectedLink.getURL(),
316                     text: selectedLink.getTextContent(),
317                     title: selectedLink.getTitle(),
318                     target: selectedLink.getTarget(),
319                 }
320
321                 context.editor.update(() => {
322                     const selection = $createNodeSelection();
323                     selection.add(selectedLink.getKey());
324                     $setSelection(selection);
325                 });
326             }
327
328             linkModal.show(formDefaults);
329         });
330     },
331     isActive(selection: BaseSelection|null): boolean {
332         return selectionContainsNodeType(selection, $isLinkNode);
333     }
334 };
335
336 export const unlink: EditorButtonDefinition = {
337     label: 'Remove link',
338     icon: unlinkIcon,
339     action(context: EditorUiContext) {
340         context.editor.update(() => {
341             const selection = context.lastSelection;
342             const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
343             const selectionPoints = selection?.getStartEndPoints();
344
345             if (selectedLink) {
346                 const newNode = $createTextNode(selectedLink.getTextContent());
347                 selectedLink.replace(newNode);
348                 if (selectionPoints?.length === 2) {
349                     newNode.select(selectionPoints[0].offset, selectionPoints[1].offset);
350                 } else {
351                     newNode.select();
352                 }
353             }
354         });
355     },
356     isActive(selection: BaseSelection|null): boolean {
357         return false;
358     }
359 };
360
361 export const table: EditorBasicButtonDefinition = {
362     label: 'Table',
363     icon: tableIcon,
364 };
365
366 export const image: EditorButtonDefinition = {
367     label: 'Insert/Edit Image',
368     icon: imageIcon,
369     action(context: EditorUiContext) {
370         const imageModal = context.manager.createModal('image');
371         const selection = context.lastSelection;
372         const selectedImage = getNodeFromSelection(selection, $isImageNode) as ImageNode|null;
373
374         context.editor.getEditorState().read(() => {
375             let formDefaults = {};
376             if (selectedImage) {
377                 formDefaults = {
378                     src: selectedImage.getSrc(),
379                     alt: selectedImage.getAltText(),
380                     height: selectedImage.getHeight(),
381                     width: selectedImage.getWidth(),
382                 }
383
384                 context.editor.update(() => {
385                     const selection = $createNodeSelection();
386                     selection.add(selectedImage.getKey());
387                     $setSelection(selection);
388                 });
389             }
390
391             imageModal.show(formDefaults);
392         });
393     },
394     isActive(selection: BaseSelection|null): boolean {
395         return selectionContainsNodeType(selection, $isImageNode);
396     }
397 };
398
399 export const horizontalRule: EditorButtonDefinition = {
400     label: 'Insert horizontal line',
401     icon: horizontalRuleIcon,
402     action(context: EditorUiContext) {
403         context.editor.update(() => {
404             insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false);
405         });
406     },
407     isActive(selection: BaseSelection|null): boolean {
408         return selectionContainsNodeType(selection, $isHorizontalRuleNode);
409     }
410 };
411
412 export const codeBlock: EditorButtonDefinition = {
413     label: 'Insert code block',
414     icon: codeBlockIcon,
415     action(context: EditorUiContext) {
416         context.editor.getEditorState().read(() => {
417             const selection = $getSelection();
418             const codeBlock = getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null);
419             if (codeBlock === null) {
420                 context.editor.update(() => {
421                     const codeBlock = $createCodeBlockNode();
422                     codeBlock.setCode(selection?.getTextContent() || '');
423                     insertNewBlockNodeAtSelection(codeBlock, true);
424                     $openCodeEditorForNode(context.editor, codeBlock);
425                     codeBlock.selectStart();
426                 });
427             } else {
428                 $openCodeEditorForNode(context.editor, codeBlock);
429             }
430         });
431     },
432     isActive(selection: BaseSelection|null): boolean {
433         return selectionContainsNodeType(selection, $isCodeBlockNode);
434     }
435 };
436
437 export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, {
438     label: 'Edit code block',
439     icon: editIcon,
440 });
441
442 export const details: EditorButtonDefinition = {
443     label: 'Insert collapsible block',
444     icon: detailsIcon,
445     action(context: EditorUiContext) {
446         context.editor.update(() => {
447             const selection = $getSelection();
448             const detailsNode = $createDetailsNode();
449             const selectionNodes = selection?.getNodes() || [];
450             const topLevels = selectionNodes.map(n => n.getTopLevelElement())
451                 .filter(n => n !== null) as ElementNode[];
452             const uniqueTopLevels = [...new Set(topLevels)];
453
454             if (uniqueTopLevels.length > 0) {
455                 uniqueTopLevels[0].insertAfter(detailsNode);
456             } else {
457                 $getRoot().append(detailsNode);
458             }
459
460             for (const node of uniqueTopLevels) {
461                 detailsNode.append(node);
462             }
463         });
464     },
465     isActive(selection: BaseSelection|null): boolean {
466         return selectionContainsNodeType(selection, $isDetailsNode);
467     }
468 }
469
470 export const source: EditorButtonDefinition = {
471     label: 'Source code',
472     icon: sourceIcon,
473     async action(context: EditorUiContext) {
474         const modal = context.manager.createModal('source');
475         const source = await getEditorContentAsHtml(context.editor);
476         modal.show({source});
477     },
478     isActive() {
479         return false;
480     }
481 };
482
483 export const fullscreen: EditorButtonDefinition = {
484     label: 'Fullscreen',
485     icon: fullscreenIcon,
486     async action(context: EditorUiContext, button: EditorButton) {
487         const isFullScreen = context.containerDOM.classList.contains('fullscreen');
488         context.containerDOM.classList.toggle('fullscreen', !isFullScreen);
489         (context.containerDOM.closest('body') as HTMLElement).classList.toggle('editor-is-fullscreen', !isFullScreen);
490         button.setActiveState(!isFullScreen);
491     },
492     isActive(selection, context: EditorUiContext) {
493         return context.containerDOM.classList.contains('fullscreen');
494     }
495 };