]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/ui/defaults/button-definitions.ts
Lexical: Added button icon system
[bookstack] / resources / js / wysiwyg / ui / defaults / button-definitions.ts
1 import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../framework/buttons";
2 import {
3     $createNodeSelection,
4     $createParagraphNode, $getRoot, $getSelection,
5     $isParagraphNode, $isTextNode, $setSelection,
6     BaseSelection, ElementNode, FORMAT_TEXT_COMMAND,
7     LexicalNode,
8     REDO_COMMAND, TextFormatType,
9     UNDO_COMMAND
10 } from "lexical";
11 import {
12     getNodeFromSelection,
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 strikethroughIcon from "@icons/editor/strikethrough.svg"
38 import superscriptIcon from "@icons/editor/superscript.svg"
39 import subscriptIcon from "@icons/editor/subscript.svg"
40 import codeIcon from "@icons/editor/code.svg"
41 import formatClearIcon from "@icons/editor/format-clear.svg"
42 import listBulletIcon from "@icons/editor/list-bullet.svg"
43 import listNumberedIcon from "@icons/editor/list-numbered.svg"
44 import listCheckIcon from "@icons/editor/list-check.svg"
45 import linkIcon from "@icons/editor/link.svg"
46 import imageIcon from "@icons/editor/image.svg"
47 import detailsIcon from "@icons/editor/details.svg"
48 import sourceIcon from "@icons/editor/source-view.svg"
49
50 export const undo: EditorButtonDefinition = {
51     label: 'Undo',
52     icon: undoIcon,
53     action(context: EditorUiContext) {
54         context.editor.dispatchCommand(UNDO_COMMAND, undefined);
55     },
56     isActive(selection: BaseSelection|null): boolean {
57         return false;
58     }
59 }
60
61 export const redo: EditorButtonDefinition = {
62     label: 'Redo',
63     icon: redoIcon,
64     action(context: EditorUiContext) {
65         context.editor.dispatchCommand(REDO_COMMAND, undefined);
66     },
67     isActive(selection: BaseSelection|null): boolean {
68         return false;
69     }
70 }
71
72 function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition {
73     return {
74         label: `${name} Callout`,
75         action(context: EditorUiContext) {
76             toggleSelectionBlockNodeType(
77                 context.editor,
78                 (node) => $isCalloutNodeOfCategory(node, category),
79                 () => $createCalloutNode(category),
80             )
81         },
82         isActive(selection: BaseSelection|null): boolean {
83             return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category));
84         }
85     };
86 }
87
88 export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info');
89 export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
90 export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
91 export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success');
92
93 const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
94       return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag;
95 };
96
97 function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition {
98     return {
99         label: name,
100         action(context: EditorUiContext) {
101             toggleSelectionBlockNodeType(
102                 context.editor,
103                 (node) => isHeaderNodeOfTag(node, tag),
104                 () => $createHeadingNode(tag),
105             )
106         },
107         isActive(selection: BaseSelection|null): boolean {
108             return selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag));
109         }
110     };
111 }
112
113 export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
114 export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
115 export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
116 export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
117
118 export const blockquote: EditorButtonDefinition = {
119     label: 'Blockquote',
120     action(context: EditorUiContext) {
121         toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode);
122     },
123     isActive(selection: BaseSelection|null): boolean {
124         return selectionContainsNodeType(selection, $isQuoteNode);
125     }
126 };
127
128 export const paragraph: EditorButtonDefinition = {
129     label: 'Paragraph',
130     action(context: EditorUiContext) {
131         toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode);
132     },
133     isActive(selection: BaseSelection|null): boolean {
134         return selectionContainsNodeType(selection, $isParagraphNode);
135     }
136 }
137
138 function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
139     return {
140         label: label,
141         icon,
142         action(context: EditorUiContext) {
143             context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
144         },
145         isActive(selection: BaseSelection|null): boolean {
146             return selectionContainsTextFormat(selection, format);
147         }
148     };
149 }
150
151 export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon);
152 export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
153 export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
154 export const textColor: EditorBasicButtonDefinition = {label: 'Text color'};
155 export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color'};
156
157 export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
158 export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
159 export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon);
160 export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon);
161 export const clearFormating: EditorButtonDefinition = {
162     label: 'Clear formatting',
163     icon: formatClearIcon,
164     action(context: EditorUiContext) {
165         context.editor.update(() => {
166             const selection = $getSelection();
167             for (const node of selection?.getNodes() || []) {
168                 if ($isTextNode(node)) {
169                     node.setFormat(0);
170                 }
171             }
172         });
173     },
174     isActive() {
175         return false;
176     }
177 };
178
179 function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
180     return {
181         label,
182         icon,
183         action(context: EditorUiContext) {
184             context.editor.getEditorState().read(() => {
185                 const selection = $getSelection();
186                 if (this.isActive(selection)) {
187                     removeList(context.editor);
188                 } else {
189                     insertList(context.editor, type);
190                 }
191             });
192         },
193         isActive(selection: BaseSelection|null): boolean {
194             return selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
195                 return $isListNode(node) && (node as ListNode).getListType() === type;
196             });
197         }
198     };
199 }
200
201 export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon);
202 export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
203 export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
204
205
206 export const link: EditorButtonDefinition = {
207     label: 'Insert/edit link',
208     icon: linkIcon,
209     action(context: EditorUiContext) {
210         const linkModal = context.manager.createModal('link');
211         context.editor.getEditorState().read(() => {
212             const selection = $getSelection();
213             const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
214
215             let formDefaults = {};
216             if (selectedLink) {
217                 formDefaults = {
218                     url: selectedLink.getURL(),
219                     text: selectedLink.getTextContent(),
220                     title: selectedLink.getTitle(),
221                     target: selectedLink.getTarget(),
222                 }
223
224                 context.editor.update(() => {
225                     const selection = $createNodeSelection();
226                     selection.add(selectedLink.getKey());
227                     $setSelection(selection);
228                 });
229             }
230
231             linkModal.show(formDefaults);
232         });
233     },
234     isActive(selection: BaseSelection|null): boolean {
235         return selectionContainsNodeType(selection, $isLinkNode);
236     }
237 };
238
239 export const image: EditorButtonDefinition = {
240     label: 'Insert/Edit Image',
241     icon: imageIcon,
242     action(context: EditorUiContext) {
243         const imageModal = context.manager.createModal('image');
244         const selection = context.lastSelection;
245         const selectedImage = getNodeFromSelection(selection, $isImageNode) as ImageNode|null;
246
247         context.editor.getEditorState().read(() => {
248             let formDefaults = {};
249             if (selectedImage) {
250                 formDefaults = {
251                     src: selectedImage.getSrc(),
252                     alt: selectedImage.getAltText(),
253                     height: selectedImage.getHeight(),
254                     width: selectedImage.getWidth(),
255                 }
256
257                 context.editor.update(() => {
258                     const selection = $createNodeSelection();
259                     selection.add(selectedImage.getKey());
260                     $setSelection(selection);
261                 });
262             }
263
264             imageModal.show(formDefaults);
265         });
266     },
267     isActive(selection: BaseSelection|null): boolean {
268         return selectionContainsNodeType(selection, $isImageNode);
269     }
270 };
271
272 export const details: EditorButtonDefinition = {
273     label: 'Insert collapsible block',
274     icon: detailsIcon,
275     action(context: EditorUiContext) {
276         context.editor.update(() => {
277             const selection = $getSelection();
278             const detailsNode = $createDetailsNode();
279             const selectionNodes = selection?.getNodes() || [];
280             const topLevels = selectionNodes.map(n => n.getTopLevelElement())
281                 .filter(n => n !== null) as ElementNode[];
282             const uniqueTopLevels = [...new Set(topLevels)];
283
284             if (uniqueTopLevels.length > 0) {
285                 uniqueTopLevels[0].insertAfter(detailsNode);
286             } else {
287                 $getRoot().append(detailsNode);
288             }
289
290             for (const node of uniqueTopLevels) {
291                 detailsNode.append(node);
292             }
293         });
294     },
295     isActive(selection: BaseSelection|null): boolean {
296         return selectionContainsNodeType(selection, $isDetailsNode);
297     }
298 }
299
300 export const source: EditorButtonDefinition = {
301     label: 'Source code',
302     icon: sourceIcon,
303     async action(context: EditorUiContext) {
304         const modal = context.manager.createModal('source');
305         const source = await getEditorContentAsHtml(context.editor);
306         modal.show({source});
307     },
308     isActive() {
309         return false;
310     }
311 };