@eventuras/scribo 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Modified from the the work licensed under the MIT license below:
3
+ *
4
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ * Source: https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
10
+ */
11
+
12
+ import {
13
+ $createCodeNode,
14
+ $isCodeNode,
15
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP,
16
+ CODE_LANGUAGE_MAP,
17
+ getLanguageFriendlyName,
18
+ } from '@lexical/code';
19
+ import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
20
+ import {
21
+ $isListNode,
22
+ INSERT_ORDERED_LIST_COMMAND,
23
+ INSERT_UNORDERED_LIST_COMMAND,
24
+ ListNode,
25
+ REMOVE_LIST_COMMAND,
26
+ } from "@lexical/list";
27
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
28
+ import {
29
+ $createHeadingNode,
30
+ $createQuoteNode,
31
+ $isHeadingNode,
32
+ HeadingTagType,
33
+ } from "@lexical/rich-text";
34
+ import { $setBlocksType } from "@lexical/selection";
35
+ import {
36
+ $findMatchingParent,
37
+ $getNearestNodeOfType,
38
+ mergeRegister,
39
+ } from "@lexical/utils";
40
+ import {
41
+ $createParagraphNode,
42
+ $getNodeByKey,
43
+ $getSelection,
44
+ $isRangeSelection,
45
+ $isRootOrShadowRoot,
46
+ CAN_REDO_COMMAND,
47
+ CAN_UNDO_COMMAND,
48
+ COMMAND_PRIORITY_CRITICAL,
49
+ COMMAND_PRIORITY_NORMAL,
50
+ FORMAT_TEXT_COMMAND,
51
+ KEY_MODIFIER_COMMAND,
52
+ LexicalEditor,
53
+ NodeKey,
54
+ REDO_COMMAND,
55
+ SELECTION_CHANGE_COMMAND,
56
+ UNDO_COMMAND,
57
+ } from 'lexical';
58
+ import { Dispatch, useCallback, useEffect, useState } from "react";
59
+ import type {JSX} from 'react';
60
+
61
+ import DropDown, { DropDownItem } from "../ui/DropDown";
62
+ import { IS_APPLE } from "../utils/environment";
63
+ import { getSelectedNode } from "../utils/getSelectedNode";
64
+ import { sanitizeUrl } from "../utils/url";
65
+
66
+ const blockTypeToBlockName = {
67
+ paragraph: "Normal",
68
+ h1: "Heading 1",
69
+ h2: "Heading 2",
70
+ h3: "Heading 3",
71
+ h4: "Heading 4",
72
+ bullet: "Bulleted List",
73
+ check: "Check List",
74
+ code: "Code Block",
75
+ number: "Numbered List",
76
+ quote: "Quote",
77
+ };
78
+
79
+ function getCodeLanguageOptions(): [string, string][] {
80
+ const options: [string, string][] = [];
81
+
82
+ for (const [lang, friendlyName] of Object.entries(
83
+ CODE_LANGUAGE_FRIENDLY_NAME_MAP,
84
+ )) {
85
+ options.push([lang, friendlyName]);
86
+ }
87
+
88
+ return options;
89
+ }
90
+
91
+ const CODE_LANGUAGE_OPTIONS = getCodeLanguageOptions();
92
+
93
+ function dropDownActiveClass(active: boolean) {
94
+ if (active) return "active dropdown-item-active";
95
+ else return "";
96
+ }
97
+
98
+ function BlockFormatDropDown({
99
+ editor,
100
+ blockType,
101
+ disabled = false,
102
+ }: {
103
+ blockType: keyof typeof blockTypeToBlockName;
104
+ editor: LexicalEditor;
105
+ disabled?: boolean;
106
+ }): JSX.Element {
107
+ const formatParagraph = () => {
108
+ editor.update(() => {
109
+ const selection = $getSelection();
110
+ if ($isRangeSelection(selection)) {
111
+ $setBlocksType(selection, () => $createParagraphNode());
112
+ }
113
+ });
114
+ };
115
+
116
+ const formatHeading = (headingSize: HeadingTagType) => {
117
+ if (blockType !== headingSize) {
118
+ editor.update(() => {
119
+ const selection = $getSelection();
120
+ if ($isRangeSelection(selection)) {
121
+ $setBlocksType(selection, () => $createHeadingNode(headingSize));
122
+ }
123
+ });
124
+ }
125
+ };
126
+
127
+ const formatBulletList = () => {
128
+ if (blockType !== "bullet") {
129
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
130
+ } else {
131
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
132
+ }
133
+ };
134
+
135
+ const formatNumberedList = () => {
136
+ if (blockType !== "number") {
137
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
138
+ } else {
139
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
140
+ }
141
+ };
142
+
143
+ const formatQuote = () => {
144
+ if (blockType !== 'quote') {
145
+ editor.update(() => {
146
+ const selection = $getSelection();
147
+ if ($isRangeSelection(selection)) {
148
+ $setBlocksType(selection, () => $createQuoteNode());
149
+ }
150
+ });
151
+ }
152
+ };
153
+
154
+ const formatCode = () => {
155
+ if (blockType !== 'code') {
156
+ editor.update(() => {
157
+ let selection = $getSelection();
158
+
159
+ if (selection !== null) {
160
+ if (selection.isCollapsed()) {
161
+ $setBlocksType(selection, () => $createCodeNode());
162
+ } else {
163
+ const textContent = selection.getTextContent();
164
+ const codeNode = $createCodeNode();
165
+ selection.insertNodes([codeNode]);
166
+ selection = $getSelection();
167
+ if ($isRangeSelection(selection)) {
168
+ selection.insertRawText(textContent);
169
+ }
170
+ }
171
+ }
172
+ });
173
+ }
174
+ };
175
+
176
+ return (
177
+ <DropDown
178
+ disabled={disabled}
179
+ buttonClassName="toolbar-item block-controls"
180
+ buttonIconClassName={"icon block-type " + blockType}
181
+ buttonLabel={blockTypeToBlockName[blockType]}
182
+ buttonAriaLabel="Formatting options for text style"
183
+ >
184
+ <DropDownItem
185
+ className={"item " + dropDownActiveClass(blockType === "paragraph")}
186
+ onClick={formatParagraph}
187
+ >
188
+ <i className="icon paragraph" />
189
+ <span className="text">Normal</span>
190
+ </DropDownItem>
191
+ <DropDownItem
192
+ className={"item " + dropDownActiveClass(blockType === "h1")}
193
+ onClick={() => formatHeading("h1")}
194
+ >
195
+ <i className="icon h1" />
196
+ <span className="text">Heading 1</span>
197
+ </DropDownItem>
198
+ <DropDownItem
199
+ className={"item " + dropDownActiveClass(blockType === "h2")}
200
+ onClick={() => formatHeading("h2")}
201
+ >
202
+ <i className="icon h2" />
203
+ <span className="text">Heading 2</span>
204
+ </DropDownItem>
205
+ <DropDownItem
206
+ className={"item " + dropDownActiveClass(blockType === "h3")}
207
+ onClick={() => formatHeading("h3")}
208
+ >
209
+ <i className="icon h3" />
210
+ <span className="text">Heading 3</span>
211
+ </DropDownItem>
212
+ <DropDownItem
213
+ className={"item " + dropDownActiveClass(blockType === "h4")}
214
+ onClick={() => formatHeading("h4")}
215
+ >
216
+ <i className="icon h4" />
217
+ <span className="text">Heading 4</span>
218
+ </DropDownItem>
219
+ <DropDownItem
220
+ className={"item " + dropDownActiveClass(blockType === "bullet")}
221
+ onClick={formatBulletList}
222
+ >
223
+ <i className="icon bullet-list" />
224
+ <span className="text">Bullet List</span>
225
+ </DropDownItem>
226
+ <DropDownItem
227
+ className={"item " + dropDownActiveClass(blockType === "number")}
228
+ onClick={formatNumberedList}
229
+ >
230
+ <i className="icon numbered-list" />
231
+ <span className="text">Numbered List</span>
232
+ </DropDownItem>
233
+ <DropDownItem
234
+ className={"item " + dropDownActiveClass(blockType === "quote")}
235
+ onClick={formatQuote}
236
+ >
237
+ <i className="icon quote" />
238
+ <span className="text">Quote</span>
239
+ </DropDownItem>
240
+ <DropDownItem
241
+ className={'item ' + dropDownActiveClass(blockType === 'code')}
242
+ onClick={formatCode}>
243
+ <i className="icon code" />
244
+ <span className="text">Code Block</span>
245
+ </DropDownItem>
246
+ </DropDown>
247
+ );
248
+ }
249
+
250
+ function Divider(): JSX.Element {
251
+ return <div className="divider" />;
252
+ }
253
+
254
+ export default function ToolbarPlugin({
255
+ setIsLinkEditMode,
256
+ }: {
257
+ setIsLinkEditMode: Dispatch<boolean>;
258
+ }): JSX.Element {
259
+ const [editor] = useLexicalComposerContext();
260
+ const [activeEditor, setActiveEditor] = useState(editor);
261
+ const [blockType, setBlockType] =
262
+ useState<keyof typeof blockTypeToBlockName>("paragraph");
263
+ const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(
264
+ null,
265
+ );
266
+ const [isCode, setIsCode] = useState(false);
267
+ const [isLink, setIsLink] = useState(false);
268
+ const [isBold, setIsBold] = useState(false);
269
+ const [isItalic, setIsItalic] = useState(false);
270
+ const [canUndo, setCanUndo] = useState(false);
271
+ const [canRedo, setCanRedo] = useState(false);
272
+ const [codeLanguage, setCodeLanguage] = useState<string>("");
273
+ const [isEditable, setIsEditable] = useState(() => editor.isEditable());
274
+
275
+ const $updateToolbar = useCallback(() => {
276
+ const selection = $getSelection();
277
+ if ($isRangeSelection(selection)) {
278
+ const anchorNode = selection.anchor.getNode();
279
+ let element =
280
+ anchorNode.getKey() === "root"
281
+ ? anchorNode
282
+ : $findMatchingParent(anchorNode, (e) => {
283
+ const parent = e.getParent();
284
+ return parent !== null && $isRootOrShadowRoot(parent);
285
+ });
286
+
287
+ if (element === null) {
288
+ element = anchorNode.getTopLevelElementOrThrow();
289
+ }
290
+
291
+ const elementKey = element.getKey();
292
+ const elementDOM = activeEditor.getElementByKey(elementKey);
293
+
294
+ // Update text format
295
+ setIsCode(selection.hasFormat("code"));
296
+ setIsBold(selection.hasFormat("bold"));
297
+ setIsItalic(selection.hasFormat("italic"));
298
+
299
+ // Update links
300
+ const node = getSelectedNode(selection);
301
+ const parent = node.getParent();
302
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
303
+ setIsLink(true);
304
+ } else {
305
+ setIsLink(false);
306
+ }
307
+
308
+ if (elementDOM !== null) {
309
+ setSelectedElementKey(elementKey);
310
+ if ($isListNode(element)) {
311
+ const parentList = $getNearestNodeOfType<ListNode>(
312
+ anchorNode,
313
+ ListNode,
314
+ );
315
+ const type = parentList
316
+ ? parentList.getListType()
317
+ : element.getListType();
318
+ setBlockType(type);
319
+ } else {
320
+ const type = $isHeadingNode(element)
321
+ ? element.getTag()
322
+ : element.getType();
323
+ if (type in blockTypeToBlockName) {
324
+ setBlockType(type as keyof typeof blockTypeToBlockName);
325
+ }
326
+ if ($isCodeNode(element)) {
327
+ const language =
328
+ element.getLanguage() as keyof typeof CODE_LANGUAGE_MAP;
329
+ setCodeLanguage(
330
+ language ? CODE_LANGUAGE_MAP[language] || language : '',
331
+ );
332
+ return;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }, [activeEditor]);
338
+
339
+ useEffect(() => {
340
+ return editor.registerCommand(
341
+ SELECTION_CHANGE_COMMAND,
342
+ (_payload, newEditor) => {
343
+ $updateToolbar();
344
+ setActiveEditor(newEditor);
345
+ return false;
346
+ },
347
+ COMMAND_PRIORITY_CRITICAL,
348
+ );
349
+ }, [editor, $updateToolbar]);
350
+
351
+ useEffect(() => {
352
+ return mergeRegister(
353
+ editor.registerEditableListener((editable) => {
354
+ setIsEditable(editable);
355
+ }),
356
+ activeEditor.registerUpdateListener(({ editorState }) => {
357
+ editorState.read(() => {
358
+ $updateToolbar();
359
+ });
360
+ }),
361
+ activeEditor.registerCommand<boolean>(
362
+ CAN_UNDO_COMMAND,
363
+ (payload) => {
364
+ setCanUndo(payload);
365
+ return false;
366
+ },
367
+ COMMAND_PRIORITY_CRITICAL,
368
+ ),
369
+ activeEditor.registerCommand<boolean>(
370
+ CAN_REDO_COMMAND,
371
+ (payload) => {
372
+ setCanRedo(payload);
373
+ return false;
374
+ },
375
+ COMMAND_PRIORITY_CRITICAL,
376
+ ),
377
+ );
378
+ }, [$updateToolbar, activeEditor, editor]);
379
+
380
+ useEffect(() => {
381
+ return activeEditor.registerCommand(
382
+ KEY_MODIFIER_COMMAND,
383
+ (payload) => {
384
+ const event: KeyboardEvent = payload;
385
+ const { code, ctrlKey, metaKey } = event;
386
+
387
+ if (code === "KeyK" && (ctrlKey || metaKey)) {
388
+ event.preventDefault();
389
+ if (!isLink) {
390
+ setIsLinkEditMode(true);
391
+ } else {
392
+ setIsLinkEditMode(false);
393
+ }
394
+ return activeEditor.dispatchCommand(
395
+ TOGGLE_LINK_COMMAND,
396
+ sanitizeUrl("https://"),
397
+ );
398
+ }
399
+ return false;
400
+ },
401
+ COMMAND_PRIORITY_NORMAL,
402
+ );
403
+ }, [activeEditor, isLink, setIsLinkEditMode]);
404
+
405
+ const insertLink = useCallback(() => {
406
+ if (!isLink) {
407
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl("https://"));
408
+ } else {
409
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
410
+ }
411
+ }, [editor, isLink]);
412
+
413
+ const onCodeLanguageSelect = useCallback(
414
+ (value: string) => {
415
+ activeEditor.update(() => {
416
+ if (selectedElementKey !== null) {
417
+ const node = $getNodeByKey(selectedElementKey);
418
+ if ($isCodeNode(node)) {
419
+ node.setLanguage(value);
420
+ }
421
+ }
422
+ });
423
+ },
424
+ [activeEditor, selectedElementKey],
425
+ );
426
+ return (
427
+ <div className="toolbar">
428
+ <button
429
+ disabled={!canUndo || !isEditable}
430
+ onClick={() => {
431
+ activeEditor.dispatchCommand(UNDO_COMMAND, undefined);
432
+ }}
433
+ title={IS_APPLE ? "Undo (⌘Z)" : "Undo (Ctrl+Z)"}
434
+ type="button"
435
+ className="toolbar-item spaced"
436
+ aria-label="Undo"
437
+ >
438
+ <i className="format undo" />
439
+ </button>
440
+ <button
441
+ disabled={!canRedo || !isEditable}
442
+ onClick={() => {
443
+ activeEditor.dispatchCommand(REDO_COMMAND, undefined);
444
+ }}
445
+ title={IS_APPLE ? "Redo (⌘Y)" : "Redo (Ctrl+Y)"}
446
+ type="button"
447
+ className="toolbar-item"
448
+ aria-label="Redo"
449
+ >
450
+ <i className="format redo" />
451
+ </button>
452
+ <Divider />
453
+ {blockType in blockTypeToBlockName && activeEditor === editor && (
454
+ <>
455
+ <BlockFormatDropDown
456
+ disabled={!isEditable}
457
+ blockType={blockType}
458
+ editor={editor}
459
+ />
460
+ <Divider />
461
+ </>
462
+ )}
463
+ {blockType === "code" ? (
464
+ <DropDown
465
+ disabled={!isEditable}
466
+ buttonClassName="toolbar-item code-language"
467
+ buttonLabel={getLanguageFriendlyName(codeLanguage)}
468
+ buttonAriaLabel="Select language"
469
+ >
470
+ {CODE_LANGUAGE_OPTIONS.map(([value, name]) => {
471
+ return (
472
+ <DropDownItem
473
+ className={`item ${dropDownActiveClass(
474
+ value === codeLanguage,
475
+ )}`}
476
+ onClick={() => onCodeLanguageSelect(value)}
477
+ key={value}
478
+ >
479
+ <span className="text">{name}</span>
480
+ </DropDownItem>
481
+ );
482
+ })}
483
+ </DropDown>
484
+ ) : (
485
+ <>
486
+ <button
487
+ disabled={!isEditable}
488
+ onClick={() => {
489
+ activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
490
+ }}
491
+ className={"toolbar-item spaced " + (isBold ? "active" : "")}
492
+ title={IS_APPLE ? "Bold (⌘B)" : "Bold (Ctrl+B)"}
493
+ type="button"
494
+ aria-label={`Format text as bold. Shortcut: ${
495
+ IS_APPLE ? "⌘B" : "Ctrl+B"
496
+ }`}
497
+ >
498
+ <i className="format bold" />
499
+ </button>
500
+ <button
501
+ disabled={!isEditable}
502
+ onClick={() => {
503
+ activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
504
+ }}
505
+ className={"toolbar-item spaced " + (isItalic ? "active" : "")}
506
+ title={IS_APPLE ? "Italic (⌘I)" : "Italic (Ctrl+I)"}
507
+ type="button"
508
+ aria-label={`Format text as italics. Shortcut: ${
509
+ IS_APPLE ? "⌘I" : "Ctrl+I"
510
+ }`}
511
+ >
512
+ <i className="format italic" />
513
+ </button>
514
+ <button
515
+ disabled={!isEditable}
516
+ onClick={insertLink}
517
+ className={"toolbar-item spaced " + (isLink ? "active" : "")}
518
+ aria-label="Insert link"
519
+ title="Insert link"
520
+ type="button"
521
+ >
522
+ <i className="format link" />
523
+ </button>
524
+ </>
525
+ )}
526
+ </div>
527
+ );
528
+ }