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