@eventuras/scribo 0.8.2 → 0.10.7

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.
Files changed (164) hide show
  1. package/dist/App.d.ts +11 -0
  2. package/dist/App.d.ts.map +1 -0
  3. package/dist/MarkdownEditor.d.ts +18 -0
  4. package/dist/MarkdownEditor.d.ts.map +1 -0
  5. package/dist/MarkdownEditor2.js +111 -0
  6. package/dist/MarkdownEditor2.js.map +1 -0
  7. package/dist/MarkdownInput.d.ts +27 -0
  8. package/dist/MarkdownInput.d.ts.map +1 -0
  9. package/dist/MarkdownInput.js +57 -0
  10. package/dist/MarkdownInput.js.map +1 -0
  11. package/dist/callout.js +5 -0
  12. package/dist/context/SharedHistoryContext.d.ts +11 -0
  13. package/dist/context/SharedHistoryContext.d.ts.map +1 -0
  14. package/dist/context/SharedHistoryContext.js +12 -0
  15. package/dist/context/SharedHistoryContext.js.map +1 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/main.d.ts +5 -0
  19. package/dist/main.d.ts.map +1 -0
  20. package/dist/main.js +4 -0
  21. package/dist/nodes/EditorNodes.d.ts +4 -0
  22. package/dist/nodes/EditorNodes.d.ts.map +1 -0
  23. package/dist/nodes/EditorNodes.js +39 -0
  24. package/dist/nodes/EditorNodes.js.map +1 -0
  25. package/dist/nodes/callout/CalloutComponent.d.ts +6 -0
  26. package/dist/nodes/callout/CalloutComponent.d.ts.map +1 -0
  27. package/dist/nodes/callout/CalloutComponent.js +53 -0
  28. package/dist/nodes/callout/CalloutComponent.js.map +1 -0
  29. package/dist/nodes/callout/CalloutNode.d.ts +27 -0
  30. package/dist/nodes/callout/CalloutNode.d.ts.map +1 -0
  31. package/dist/nodes/callout/CalloutNode.js +153 -0
  32. package/dist/nodes/callout/CalloutNode.js.map +1 -0
  33. package/dist/nodes/callout/CalloutTransformer.d.ts +3 -0
  34. package/dist/nodes/callout/CalloutTransformer.d.ts.map +1 -0
  35. package/dist/nodes/callout/CalloutTransformer.js +48 -0
  36. package/dist/nodes/callout/CalloutTransformer.js.map +1 -0
  37. package/dist/nodes/callout/calloutPlugin.d.ts +3 -0
  38. package/dist/nodes/callout/calloutPlugin.d.ts.map +1 -0
  39. package/dist/nodes/callout/calloutPlugin.js +34 -0
  40. package/dist/nodes/callout/calloutPlugin.js.map +1 -0
  41. package/dist/nodes/callout/index.d.ts +6 -0
  42. package/dist/nodes/callout/index.d.ts.map +1 -0
  43. package/dist/nodes/schedule/ScheduleItemComponent.d.ts +9 -0
  44. package/dist/nodes/schedule/ScheduleItemComponent.d.ts.map +1 -0
  45. package/dist/nodes/schedule/ScheduleItemComponent.js +167 -0
  46. package/dist/nodes/schedule/ScheduleItemComponent.js.map +1 -0
  47. package/dist/nodes/schedule/ScheduleItemNode.d.ts +31 -0
  48. package/dist/nodes/schedule/ScheduleItemNode.d.ts.map +1 -0
  49. package/dist/nodes/schedule/ScheduleItemNode.js +94 -0
  50. package/dist/nodes/schedule/ScheduleItemNode.js.map +1 -0
  51. package/dist/nodes/schedule/ScheduleTransformer.d.ts +22 -0
  52. package/dist/nodes/schedule/ScheduleTransformer.d.ts.map +1 -0
  53. package/dist/nodes/schedule/ScheduleTransformer.js +60 -0
  54. package/dist/nodes/schedule/ScheduleTransformer.js.map +1 -0
  55. package/dist/nodes/schedule/index.d.ts +5 -0
  56. package/dist/nodes/schedule/index.d.ts.map +1 -0
  57. package/dist/nodes/schedule/schedulePlugin.d.ts +3 -0
  58. package/dist/nodes/schedule/schedulePlugin.d.ts.map +1 -0
  59. package/dist/nodes/schedule/schedulePlugin.js +33 -0
  60. package/dist/nodes/schedule/schedulePlugin.js.map +1 -0
  61. package/dist/plugins/AutoLinkPlugin.d.ts +3 -0
  62. package/dist/plugins/AutoLinkPlugin.d.ts.map +1 -0
  63. package/dist/plugins/AutoLinkPlugin.js +22 -0
  64. package/dist/plugins/AutoLinkPlugin.js.map +1 -0
  65. package/dist/plugins/FloatingLinkEditorPlugin.d.ts +7 -0
  66. package/dist/plugins/FloatingLinkEditorPlugin.d.ts.map +1 -0
  67. package/dist/plugins/FloatingLinkEditorPlugin2.js +302 -0
  68. package/dist/plugins/FloatingLinkEditorPlugin2.js.map +1 -0
  69. package/dist/plugins/HistoryPlugin.d.ts +26 -0
  70. package/dist/plugins/HistoryPlugin.d.ts.map +1 -0
  71. package/dist/plugins/LinkPlugin.d.ts +3 -0
  72. package/dist/plugins/LinkPlugin.d.ts.map +1 -0
  73. package/dist/plugins/LinkPlugin.js +18 -0
  74. package/dist/plugins/LinkPlugin.js.map +1 -0
  75. package/dist/plugins/ToolbarPlugin.d.ts +7 -0
  76. package/dist/plugins/ToolbarPlugin.d.ts.map +1 -0
  77. package/dist/plugins/ToolbarPlugin.js +372 -0
  78. package/dist/plugins/ToolbarPlugin.js.map +1 -0
  79. package/dist/schedule.js +4 -0
  80. package/dist/scribo.css +1977 -0
  81. package/dist/themes/EditorTheme.d.ts +4 -0
  82. package/dist/themes/EditorTheme.d.ts.map +1 -0
  83. package/dist/themes/EditorTheme2.js +107 -0
  84. package/dist/themes/EditorTheme2.js.map +1 -0
  85. package/dist/types.d.ts +26 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/ui/ContentEditable.d.ts +6 -0
  88. package/dist/ui/ContentEditable.d.ts.map +1 -0
  89. package/dist/ui/ContentEditable2.js +14 -0
  90. package/dist/ui/ContentEditable2.js.map +1 -0
  91. package/dist/ui/DropDown.d.ts +18 -0
  92. package/dist/ui/DropDown.d.ts.map +1 -0
  93. package/dist/ui/DropDown.js +162 -0
  94. package/dist/ui/DropDown.js.map +1 -0
  95. package/dist/ui/Placeholder.d.ts +6 -0
  96. package/dist/ui/Placeholder.d.ts.map +1 -0
  97. package/dist/ui/Placeholder2.js +13 -0
  98. package/dist/ui/Placeholder2.js.map +1 -0
  99. package/dist/utils/environment.d.ts +24 -0
  100. package/dist/utils/environment.d.ts.map +1 -0
  101. package/dist/utils/environment.js +20 -0
  102. package/dist/utils/environment.js.map +1 -0
  103. package/dist/utils/getSelectedNode.d.ts +3 -0
  104. package/dist/utils/getSelectedNode.d.ts.map +1 -0
  105. package/dist/utils/getSelectedNode.js +23 -0
  106. package/dist/utils/getSelectedNode.js.map +1 -0
  107. package/dist/utils/setFloatingElemPosition.d.ts +2 -0
  108. package/dist/utils/setFloatingElemPosition.d.ts.map +1 -0
  109. package/dist/utils/setFloatingElemPositionForLinkEditor.d.ts +2 -0
  110. package/dist/utils/setFloatingElemPositionForLinkEditor.d.ts.map +1 -0
  111. package/dist/utils/setFloatingElemPositionForLinkEditor.js +33 -0
  112. package/dist/utils/setFloatingElemPositionForLinkEditor.js.map +1 -0
  113. package/dist/utils/url.d.ts +11 -0
  114. package/dist/utils/url.d.ts.map +1 -0
  115. package/dist/utils/url.js +33 -0
  116. package/dist/utils/url.js.map +1 -0
  117. package/dist/vite.svg +1 -0
  118. package/package.json +57 -39
  119. package/src/MarkdownEditor.css +130 -27
  120. package/src/MarkdownEditor.tsx +82 -31
  121. package/src/MarkdownInput.tsx +9 -10
  122. package/src/context/SharedHistoryContext.tsx +8 -16
  123. package/src/main.tsx +3 -3
  124. package/src/nodes/EditorNodes.ts +10 -10
  125. package/src/nodes/callout/Callout.css +94 -0
  126. package/src/nodes/callout/CalloutComponent.tsx +61 -0
  127. package/src/nodes/callout/CalloutNode.ts +197 -0
  128. package/src/nodes/callout/CalloutTransformer.ts +87 -0
  129. package/src/nodes/callout/calloutPlugin.ts +37 -0
  130. package/src/nodes/callout/index.ts +5 -0
  131. package/src/nodes/schedule/ScheduleItem.css +140 -0
  132. package/src/nodes/schedule/ScheduleItemComponent.tsx +195 -0
  133. package/src/nodes/schedule/ScheduleItemNode.ts +135 -0
  134. package/src/nodes/schedule/ScheduleTransformer.ts +80 -0
  135. package/src/nodes/schedule/index.ts +4 -0
  136. package/src/nodes/schedule/schedulePlugin.ts +33 -0
  137. package/src/plugins/AutoLinkPlugin.tsx +5 -10
  138. package/src/plugins/FloatingLinkEditorPlugin.tsx +42 -66
  139. package/src/plugins/HistoryPlugin.tsx +20 -30
  140. package/src/plugins/LinkPlugin.tsx +2 -3
  141. package/src/plugins/ToolbarPlugin.tsx +129 -141
  142. package/src/themes/EditorTheme.css +82 -62
  143. package/src/themes/EditorTheme.ts +89 -90
  144. package/src/types.ts +27 -0
  145. package/src/ui/ContentEditable.tsx +5 -5
  146. package/src/ui/DropDown.tsx +23 -41
  147. package/src/ui/Placeholder.tsx +21 -21
  148. package/src/utils/environment.ts +6 -12
  149. package/src/utils/getSelectedNode.ts +3 -5
  150. package/src/utils/setFloatingElemPosition.ts +2 -5
  151. package/src/utils/setFloatingElemPositionForLinkEditor.ts +1 -1
  152. package/src/utils/url.ts +4 -10
  153. package/tsconfig.json +8 -0
  154. package/CHANGELOG.md +0 -59
  155. package/README.md +0 -126
  156. package/RELEASE.md +0 -102
  157. package/eslint.config.js +0 -4
  158. package/src/App.css +0 -35
  159. package/src/App.tsx +0 -42
  160. package/src/index.tsx +0 -13
  161. package/tsconfig.node.json +0 -11
  162. package/vite.config.site.ts +0 -19
  163. package/vite.config.ts +0 -33
  164. /package/{src → dist}/vite-env.d.ts +0 -0
@@ -2,27 +2,26 @@ import {
2
2
  $convertFromMarkdownString,
3
3
  $convertToMarkdownString,
4
4
  TRANSFORMERS,
5
- } from "@lexical/markdown";
6
- import {
7
- $getRoot,
8
- } from 'lexical';
9
- import { LexicalComposer } from "@lexical/react/LexicalComposer";
10
- import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
11
- import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
12
- import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
13
- import { EditorState } from "lexical";
14
- import React, { useState } from "react";
15
-
16
- import EditorNodes from "./nodes/EditorNodes";
17
- import ToolbarPlugin from "./plugins/ToolbarPlugin";
18
- import EditorTheme from "./themes/EditorTheme";
19
- import ContentEditable from "./ui/ContentEditable";
20
- import Placeholder from "./ui/Placeholder";
5
+ } from '@lexical/markdown';
6
+ import { LexicalComposer } from '@lexical/react/LexicalComposer';
7
+ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
21
8
  import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
22
- import LinkPlugin from "./plugins/LinkPlugin";
23
- import FloatingLinkEditorPlugin from "./plugins/FloatingLinkEditorPlugin";
24
- import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
9
+ import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
10
+ import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
11
+ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
12
+ import { $createParagraphNode, $createTextNode, $getRoot, type EditorState } from 'lexical';
13
+ import type React from 'react';
14
+ import { useMemo, useState } from 'react';
25
15
  import { useSharedHistoryContext } from './context/SharedHistoryContext';
16
+ import EditorNodes from './nodes/EditorNodes';
17
+ import AutoLinkPlugin from './plugins/AutoLinkPlugin';
18
+ import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin';
19
+ import LinkPlugin from './plugins/LinkPlugin';
20
+ import ToolbarPlugin from './plugins/ToolbarPlugin';
21
+ import EditorTheme from './themes/EditorTheme';
22
+ import type { ScriboPlugin } from './types';
23
+ import ContentEditable from './ui/ContentEditable';
24
+ import Placeholder from './ui/Placeholder';
26
25
 
27
26
  export type onChangeMisc = {
28
27
  plainText: string;
@@ -30,6 +29,7 @@ export type onChangeMisc = {
30
29
  export interface MarkdownEditorProps {
31
30
  initialMarkdown?: string;
32
31
  className?: string;
32
+ plugins?: ScriboPlugin[];
33
33
  onChange?: (markdown: string, misc: onChangeMisc) => void;
34
34
  onBlur?: () => void;
35
35
  placeholder?: string;
@@ -41,8 +41,9 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = (props) => {
41
41
  const { historyState } = useSharedHistoryContext();
42
42
  const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
43
43
 
44
- const [floatingAnchorElem, setFloatingAnchorElem] =
45
- useState<HTMLDivElement | undefined>(undefined);
44
+ const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | undefined>(
45
+ undefined
46
+ );
46
47
 
47
48
  const onRef = (_floatingAnchorElem: HTMLDivElement) => {
48
49
  if (_floatingAnchorElem !== null) {
@@ -50,33 +51,76 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = (props) => {
50
51
  }
51
52
  };
52
53
 
54
+ // Collect nodes and transformers from plugins
55
+ const pluginNodes = useMemo(
56
+ () => (props.plugins ?? []).flatMap((p) => p.nodes ?? []),
57
+ [props.plugins]
58
+ );
59
+ const allTransformers = useMemo(
60
+ () => [...(props.plugins ?? []).flatMap((p) => p.transformers ?? []), ...TRANSFORMERS],
61
+ [props.plugins]
62
+ );
63
+ const toolbarButtons = useMemo(
64
+ () => (props.plugins ?? []).flatMap((p) => p.toolbarButtons ?? []),
65
+ [props.plugins]
66
+ );
67
+ const editorPlugins = useMemo(
68
+ () => (props.plugins ?? []).flatMap((p) => p.editorPlugins ?? []),
69
+ [props.plugins]
70
+ );
71
+
53
72
  const internalOnChange = (editorState: EditorState) => {
54
73
  editorState.read(() => {
55
- const markdown = $convertToMarkdownString(TRANSFORMERS);
74
+ const markdown = $convertToMarkdownString(allTransformers);
56
75
  const plainText = $getRoot().getTextContent();
57
- props.onChange && props.onChange(markdown, { plainText });
76
+ props.onChange?.(markdown, { plainText });
58
77
  });
59
78
  };
60
79
 
61
80
  const initialConfig = {
62
- namespace: "ScriboMarkdown",
63
- nodes: [...EditorNodes],
81
+ namespace: 'ScriboMarkdown',
82
+ nodes: [...EditorNodes, ...pluginNodes],
64
83
  onError: (error: Error) => {
65
84
  throw error;
66
85
  },
67
86
  theme: EditorTheme,
68
- editorState: () => {
69
- $convertFromMarkdownString(props.initialMarkdown ?? "", TRANSFORMERS);
70
- },
87
+ editorState: props.initialMarkdown
88
+ ? () => {
89
+ // Unescape backslash-escaped markdown characters
90
+ const unescapedMarkdown = (props.initialMarkdown ?? '').replaceAll(
91
+ /\\([*_`[\]()#+-])/g,
92
+ '$1'
93
+ );
94
+ try {
95
+ $convertFromMarkdownString(unescapedMarkdown, allTransformers);
96
+ } catch (err) {
97
+ // Markdown parsing threw (e.g. a transformer bug). Fall back to plain
98
+ // text so the field stays editable and content isn't lost — better
99
+ // than crashing the whole tree and propagating to the page boundary.
100
+ console.error('Scribo: markdown parse failed, falling back to plain text', err);
101
+ const root = $getRoot();
102
+ root.clear();
103
+ const paragraph = $createParagraphNode();
104
+ paragraph.append($createTextNode(unescapedMarkdown));
105
+ root.append(paragraph);
106
+ }
107
+ }
108
+ : undefined,
71
109
  };
72
110
 
73
111
  return (
74
112
  <div className={props.className}>
75
113
  <LexicalComposer initialConfig={initialConfig}>
76
- <div className="editor-shell" onBlur={props.onBlur} data-testid={props['data-testid']} id={props.id}>
77
- <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />
114
+ <div
115
+ className="editor-shell"
116
+ onBlur={props.onBlur}
117
+ data-testid={props['data-testid']}
118
+ id={props.id}
119
+ >
120
+ <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} toolbarButtons={toolbarButtons} />
78
121
  <div className="editor-container rich-text">
79
122
  <HistoryPlugin externalHistoryState={historyState} />
123
+ <MarkdownShortcutPlugin transformers={allTransformers} />
80
124
  <AutoLinkPlugin />
81
125
  <LinkPlugin />
82
126
  <FloatingLinkEditorPlugin
@@ -87,7 +131,11 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = (props) => {
87
131
  <RichTextPlugin
88
132
  contentEditable={
89
133
  <div className="editor-scroller">
90
- <div className="editor" ref={onRef} id={props.id ? `${props.id}-editable` : undefined}>
134
+ <div
135
+ className="editor"
136
+ ref={onRef}
137
+ id={props.id ? `${props.id}-editable` : undefined}
138
+ >
91
139
  <ContentEditable />
92
140
  </div>
93
141
  </div>
@@ -97,6 +145,9 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = (props) => {
97
145
  />
98
146
  </div>
99
147
  {props.onChange && <OnChangePlugin onChange={internalOnChange} />}
148
+ {editorPlugins.map((Plugin) => (
149
+ <Plugin key={Plugin.displayName ?? Plugin.name} />
150
+ ))}
100
151
  </div>
101
152
  </LexicalComposer>
102
153
  </div>
@@ -1,11 +1,13 @@
1
1
  'use client';
2
- import MarkdownEditor from './MarkdownEditor';
3
2
  import { useRef, useState } from 'react';
3
+ import MarkdownEditor from './MarkdownEditor';
4
+ import type { ScriboPlugin } from './types';
4
5
 
5
6
  export type MarkdownInputProps = {
6
7
  id?: string;
7
8
  name: string;
8
9
  maxLength?: number;
10
+ plugins?: ScriboPlugin[];
9
11
  label?: string;
10
12
  placeholder?: string;
11
13
  defaultValue?: string;
@@ -28,7 +30,7 @@ const MarkdownInput = (props: MarkdownInputProps) => {
28
30
  const plainTextRef = useRef('');
29
31
  const id = props.id ?? props.name;
30
32
 
31
- const handleChange = (markdown: string, { plainText }: { plainText: string; }) => {
33
+ const handleChange = (markdown: string, { plainText }: { plainText: string }) => {
32
34
  plainTextRef.current = plainText;
33
35
  setValue(markdown);
34
36
 
@@ -37,9 +39,9 @@ const MarkdownInput = (props: MarkdownInputProps) => {
37
39
  props.onChange(markdown);
38
40
  }
39
41
 
40
- // Validate maxLength if provided
41
- if (props.maxLength && plainText.length >= props.maxLength) {
42
- setError(`Maximum ${props.maxLength} characters allowed`);
42
+ // Validate maxLength against markdown content (what gets stored)
43
+ if (props.maxLength && markdown.length > props.maxLength) {
44
+ setError(`Maximum ${props.maxLength} characters allowed (currently ${markdown.length})`);
43
45
  } else {
44
46
  setError(null);
45
47
  }
@@ -53,14 +55,11 @@ const MarkdownInput = (props: MarkdownInputProps) => {
53
55
  </label>
54
56
  )}
55
57
  {/* Hidden input to store value - controlled by React state */}
56
- <input
57
- type="hidden"
58
- name={props.name}
59
- value={value}
60
- />
58
+ <input type="hidden" name={props.name} value={value} />
61
59
  <MarkdownEditor
62
60
  onChange={handleChange}
63
61
  className={props.editorClassName}
62
+ plugins={props.plugins}
64
63
  initialMarkdown={props.defaultValue}
65
64
  placeholder={props.placeholder}
66
65
  data-testid={props['data-testid']}
@@ -6,12 +6,11 @@
6
6
  *
7
7
  */
8
8
 
9
- import type {HistoryState} from '@lexical/react/LexicalHistoryPlugin';
10
- import type {JSX} from 'react';
11
-
12
- import {createEmptyHistoryState} from '@lexical/react/LexicalHistoryPlugin';
13
- import * as React from 'react';
14
- import {createContext, ReactNode, useContext, useMemo} from 'react';
9
+ import type { HistoryState } from '@lexical/react/LexicalHistoryPlugin';
10
+ import { createEmptyHistoryState } from '@lexical/react/LexicalHistoryPlugin';
11
+ import type * as React from 'react';
12
+ import type { JSX } from 'react';
13
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
15
14
 
16
15
  type ContextShape = {
17
16
  historyState?: HistoryState;
@@ -19,18 +18,11 @@ type ContextShape = {
19
18
 
20
19
  const Context: React.Context<ContextShape> = createContext({});
21
20
 
22
- export const SharedHistoryContext = ({
23
- children,
24
- }: {
25
- children: ReactNode;
26
- }): JSX.Element => {
27
- const historyContext = useMemo(
28
- () => ({historyState: createEmptyHistoryState()}),
29
- [],
30
- );
21
+ export const SharedHistoryContext = ({ children }: { children: ReactNode }): JSX.Element => {
22
+ const historyContext = useMemo(() => ({ historyState: createEmptyHistoryState() }), []);
31
23
  return <Context.Provider value={historyContext}>{children}</Context.Provider>;
32
24
  };
33
25
 
34
26
  export const useSharedHistoryContext = (): ContextShape => {
35
27
  return useContext(Context);
36
- };
28
+ };
package/src/main.tsx CHANGED
@@ -1,6 +1,6 @@
1
-
2
1
  import './MarkdownEditor.css';
3
- import MarkdownEditor from "./MarkdownEditor";
4
- import MarkdownInput from "./MarkdownInput";
2
+ import MarkdownEditor from './MarkdownEditor';
3
+ import MarkdownInput from './MarkdownInput';
5
4
 
5
+ export type { ScriboPlugin, ScriboToolbarButton } from './types';
6
6
  export { MarkdownEditor, MarkdownInput };
@@ -7,16 +7,16 @@
7
7
  * Modified from: https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/nodes/PlaygroundNodes.ts
8
8
  */
9
9
 
10
- import { CodeHighlightNode, CodeNode } from "@lexical/code";
11
- import { HashtagNode } from "@lexical/hashtag";
12
- import { AutoLinkNode, LinkNode } from "@lexical/link";
13
- import { ListItemNode, ListNode } from "@lexical/list";
14
- import { MarkNode } from "@lexical/mark";
15
- import { OverflowNode } from "@lexical/overflow";
16
- import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode";
17
- import { HeadingNode, QuoteNode } from "@lexical/rich-text";
18
- import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
19
- import type { Klass, LexicalNode } from "lexical";
10
+ import { CodeHighlightNode, CodeNode } from '@lexical/code';
11
+ import { HashtagNode } from '@lexical/hashtag';
12
+ import { AutoLinkNode, LinkNode } from '@lexical/link';
13
+ import { ListItemNode, ListNode } from '@lexical/list';
14
+ import { MarkNode } from '@lexical/mark';
15
+ import { OverflowNode } from '@lexical/overflow';
16
+ import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
17
+ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
18
+ import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';
19
+ import type { Klass, LexicalNode } from 'lexical';
20
20
 
21
21
  // Using 'as any' to work around Lexical version conflicts between workspace installs
22
22
  const EditorNodes: Array<Klass<LexicalNode>> = [
@@ -0,0 +1,94 @@
1
+ /* Callout container (ElementNode) */
2
+ .callout-editor {
3
+ position: relative;
4
+ border-left: 4px solid;
5
+ border-radius: 0.375rem;
6
+ padding: 2rem 0.75rem 0.75rem;
7
+ margin: 0.5rem 0;
8
+ min-height: 3rem;
9
+ }
10
+
11
+ .callout-editor--note {
12
+ border-color: #2563eb;
13
+ background: rgba(37, 99, 235, 0.06);
14
+ }
15
+
16
+ .callout-editor--tip {
17
+ border-color: #16a34a;
18
+ background: rgba(22, 163, 74, 0.06);
19
+ }
20
+
21
+ .callout-editor--important {
22
+ border-color: #7c3aed;
23
+ background: rgba(124, 58, 237, 0.06);
24
+ }
25
+
26
+ .callout-editor--warning {
27
+ border-color: #d97706;
28
+ background: rgba(217, 119, 6, 0.06);
29
+ }
30
+
31
+ .callout-editor--caution {
32
+ border-color: #dc2626;
33
+ background: rgba(220, 38, 38, 0.06);
34
+ }
35
+
36
+ /* Toolbar rendered directly inside callout element */
37
+ .callout-toolbar {
38
+ position: absolute;
39
+ top: 0.25rem;
40
+ left: 0.5rem;
41
+ right: 0.5rem;
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 0.25rem;
45
+ user-select: none;
46
+ }
47
+
48
+ .callout-toolbar-icon {
49
+ font-size: 0.875rem;
50
+ line-height: 1;
51
+ }
52
+
53
+ .callout-toolbar-type {
54
+ font: inherit;
55
+ font-weight: 600;
56
+ font-size: 0.75rem;
57
+ border: none;
58
+ background: transparent;
59
+ cursor: pointer;
60
+ padding: 0.125rem 0.25rem;
61
+ border-radius: 0.25rem;
62
+ color: inherit;
63
+ }
64
+
65
+ .callout-toolbar-type:hover {
66
+ background: rgba(0, 0, 0, 0.06);
67
+ }
68
+
69
+ .callout-toolbar-remove {
70
+ margin-left: auto;
71
+ border: 1px solid rgba(0, 0, 0, 0.12);
72
+ background: #f7f7f7;
73
+ cursor: pointer;
74
+ padding: 0 0.375rem;
75
+ color: #595959;
76
+ font-size: 0.875rem;
77
+ line-height: 1.5;
78
+ border-radius: 0.25rem;
79
+ transition:
80
+ color 0.15s,
81
+ background 0.15s,
82
+ border-color 0.15s;
83
+ }
84
+
85
+ .callout-toolbar-remove:hover {
86
+ color: #991b1b;
87
+ background: #f3e4e4;
88
+ border-color: rgba(153, 27, 27, 0.35);
89
+ }
90
+
91
+ /* Content paragraphs inside callout */
92
+ .callout-editor p {
93
+ margin: 0.25rem 0;
94
+ }
@@ -0,0 +1,61 @@
1
+ import './Callout.css';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { $createParagraphNode, $getNodeByKey } from 'lexical';
4
+ import { useEffect } from 'react';
5
+
6
+ import { $isCalloutNode, CALLOUT_TYPES, type CalloutType } from './CalloutNode';
7
+
8
+ /**
9
+ * Plugin that listens for DOM custom events from CalloutNode toolbar
10
+ * and updates the Lexical state accordingly.
11
+ */
12
+ export const CalloutPlugin = () => {
13
+ const [editor] = useLexicalComposerContext();
14
+
15
+ useEffect(() => {
16
+ const rootElement = editor.getRootElement()?.parentElement;
17
+ if (!rootElement) return;
18
+
19
+ const handleTypeChange = (e: Event) => {
20
+ const { nodeKey, calloutType } = (e as CustomEvent).detail;
21
+ if (!nodeKey || !CALLOUT_TYPES.includes(calloutType)) return;
22
+ editor.update(() => {
23
+ const node = $getNodeByKey(nodeKey);
24
+ if ($isCalloutNode(node)) {
25
+ node.setCalloutType(calloutType as CalloutType);
26
+ }
27
+ });
28
+ };
29
+
30
+ const handleRemove = (e: Event) => {
31
+ const { nodeKey } = (e as CustomEvent).detail;
32
+ if (!nodeKey) return;
33
+ editor.update(() => {
34
+ const node = $getNodeByKey(nodeKey);
35
+ if (!$isCalloutNode(node)) return;
36
+
37
+ const children = node.getChildren();
38
+ if (children.length > 0) {
39
+ for (const child of children) {
40
+ node.insertBefore(child);
41
+ }
42
+ node.remove();
43
+ } else {
44
+ const p = $createParagraphNode();
45
+ node.replace(p);
46
+ p.selectEnd();
47
+ }
48
+ });
49
+ };
50
+
51
+ rootElement.addEventListener('callout-type-change', handleTypeChange);
52
+ rootElement.addEventListener('callout-remove', handleRemove);
53
+
54
+ return () => {
55
+ rootElement.removeEventListener('callout-type-change', handleTypeChange);
56
+ rootElement.removeEventListener('callout-remove', handleRemove);
57
+ };
58
+ }, [editor]);
59
+
60
+ return null;
61
+ };
@@ -0,0 +1,197 @@
1
+ import {
2
+ $applyNodeReplacement,
3
+ $createParagraphNode,
4
+ type DOMConversionMap,
5
+ type DOMExportOutput,
6
+ ElementNode,
7
+ type LexicalNode,
8
+ type NodeKey,
9
+ type SerializedElementNode,
10
+ type Spread,
11
+ } from 'lexical';
12
+
13
+ export const CALLOUT_TYPES = ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'] as const;
14
+ export type CalloutType = (typeof CALLOUT_TYPES)[number];
15
+
16
+ const CALLOUT_ICONS: Record<CalloutType, string> = {
17
+ NOTE: 'ℹ️',
18
+ TIP: '💡',
19
+ IMPORTANT: '❗',
20
+ WARNING: '⚠️',
21
+ CAUTION: '🔴',
22
+ };
23
+
24
+ export type SerializedCalloutNode = Spread<{ calloutType: CalloutType }, SerializedElementNode>;
25
+
26
+ export class CalloutNode extends ElementNode {
27
+ __calloutType: CalloutType;
28
+
29
+ static getType(): string {
30
+ return 'callout';
31
+ }
32
+
33
+ static clone(node: CalloutNode): CalloutNode {
34
+ return new CalloutNode(node.__calloutType, node.__key);
35
+ }
36
+
37
+ constructor(calloutType: CalloutType = 'NOTE', key?: NodeKey) {
38
+ super(key);
39
+ this.__calloutType = calloutType;
40
+ }
41
+
42
+ getCalloutType(): CalloutType {
43
+ return this.getLatest().__calloutType;
44
+ }
45
+
46
+ setCalloutType(calloutType: CalloutType): void {
47
+ const self = this.getWritable();
48
+ self.__calloutType = calloutType;
49
+ }
50
+
51
+ // --- DOM ---
52
+
53
+ createDOM(): HTMLElement {
54
+ const wrapper = document.createElement('div');
55
+ wrapper.dataset.calloutKey = this.__key;
56
+ this._applyDOMClass(wrapper);
57
+
58
+ // Non-editable toolbar
59
+ const toolbar = document.createElement('div');
60
+ toolbar.className = 'callout-toolbar';
61
+ toolbar.contentEditable = 'false';
62
+
63
+ const icon = document.createElement('span');
64
+ icon.className = 'callout-toolbar-icon';
65
+ icon.textContent = CALLOUT_ICONS[this.__calloutType];
66
+ toolbar.appendChild(icon);
67
+
68
+ const select = document.createElement('select');
69
+ select.className = 'callout-toolbar-type';
70
+ select.setAttribute('aria-label', 'Callout type');
71
+ for (const type of CALLOUT_TYPES) {
72
+ const option = document.createElement('option');
73
+ option.value = type;
74
+ option.textContent = type.charAt(0) + type.slice(1).toLowerCase();
75
+ if (type === this.__calloutType) option.selected = true;
76
+ select.appendChild(option);
77
+ }
78
+ select.addEventListener('change', () => {
79
+ wrapper.dispatchEvent(
80
+ new CustomEvent('callout-type-change', {
81
+ bubbles: true,
82
+ detail: { nodeKey: wrapper.dataset.calloutKey, calloutType: select.value },
83
+ })
84
+ );
85
+ });
86
+ toolbar.appendChild(select);
87
+
88
+ const removeBtn = document.createElement('button');
89
+ removeBtn.type = 'button';
90
+ removeBtn.className = 'callout-toolbar-remove';
91
+ removeBtn.textContent = '×';
92
+ removeBtn.title = 'Remove callout';
93
+ removeBtn.setAttribute('aria-label', 'Remove callout');
94
+ removeBtn.addEventListener('click', () => {
95
+ wrapper.dispatchEvent(
96
+ new CustomEvent('callout-remove', {
97
+ bubbles: true,
98
+ detail: { nodeKey: wrapper.dataset.calloutKey },
99
+ })
100
+ );
101
+ });
102
+ toolbar.appendChild(removeBtn);
103
+
104
+ wrapper.appendChild(toolbar);
105
+ return wrapper;
106
+ }
107
+
108
+ private _applyDOMClass(dom: HTMLElement): void {
109
+ dom.className = `callout-editor callout-editor--${this.__calloutType.toLowerCase()}`;
110
+ }
111
+
112
+ updateDOM(prevNode: CalloutNode, dom: HTMLElement): boolean {
113
+ if (prevNode.__calloutType !== this.__calloutType) {
114
+ this._applyDOMClass(dom);
115
+ const icon = dom.querySelector('.callout-toolbar-icon');
116
+ if (icon) icon.textContent = CALLOUT_ICONS[this.__calloutType];
117
+ const select = dom.querySelector('.callout-toolbar-type') as HTMLSelectElement | null;
118
+ if (select) select.value = this.__calloutType;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ exportDOM(): DOMExportOutput {
124
+ const el = document.createElement('blockquote');
125
+ el.dataset.calloutType = this.__calloutType;
126
+ el.className = `callout callout--${this.__calloutType.toLowerCase()}`;
127
+ return { element: el };
128
+ }
129
+
130
+ static importDOM(): DOMConversionMap | null {
131
+ return {
132
+ blockquote: (node: HTMLElement) => {
133
+ const type = node.dataset.calloutType;
134
+ if (!type || !CALLOUT_TYPES.includes(type as CalloutType)) return null;
135
+ return {
136
+ conversion: () => ({
137
+ node: $createCalloutNode(type as CalloutType),
138
+ }),
139
+ priority: 1,
140
+ };
141
+ },
142
+ };
143
+ }
144
+
145
+ // --- Serialization ---
146
+
147
+ static importJSON(json: SerializedCalloutNode): CalloutNode {
148
+ return $createCalloutNode(json.calloutType);
149
+ }
150
+
151
+ exportJSON(): SerializedCalloutNode {
152
+ return {
153
+ ...super.exportJSON(),
154
+ type: 'callout',
155
+ calloutType: this.__calloutType,
156
+ };
157
+ }
158
+
159
+ // --- Behavior ---
160
+
161
+ canIndent(): boolean {
162
+ return false;
163
+ }
164
+
165
+ collapseAtStart(): boolean {
166
+ const children = this.getChildren();
167
+ const parent = this.getParent();
168
+ if (parent) {
169
+ for (const child of children) {
170
+ this.insertBefore(child);
171
+ }
172
+ }
173
+ this.remove();
174
+ return true;
175
+ }
176
+
177
+ insertNewAfter(): ElementNode {
178
+ const lastChild = this.getLastChild();
179
+ if (lastChild?.getTextContent() === '') {
180
+ const p = $createParagraphNode();
181
+ this.insertAfter(p);
182
+ lastChild.remove();
183
+ return p;
184
+ }
185
+ const p = $createParagraphNode();
186
+ this.append(p);
187
+ return p;
188
+ }
189
+ }
190
+
191
+ export function $createCalloutNode(calloutType: CalloutType = 'NOTE'): CalloutNode {
192
+ return $applyNodeReplacement(new CalloutNode(calloutType));
193
+ }
194
+
195
+ export function $isCalloutNode(node: LexicalNode | null | undefined): node is CalloutNode {
196
+ return node instanceof CalloutNode;
197
+ }