205 | // {isEditMode ? (
206 | //
{
211 | // setLinkUrl(event.target.value)
212 | // }}
213 | // onKeyDown={event => {
214 | // if (event.key === 'Enter') {
215 | // event.preventDefault()
216 | // if (lastSelection !== null) {
217 | // if (linkUrl !== '') {
218 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl)
219 | // }
220 | // setEditMode(false)
221 | // }
222 | // } else if (event.key === 'Escape') {
223 | // event.preventDefault()
224 | // setEditMode(false)
225 | // }
226 | // }}
227 | // />
228 | // ) : (
229 | // <>
230 | //
231 | //
232 | // {linkUrl}
233 | //
234 | //
event.preventDefault()}
239 | // onClick={() => {
240 | // setEditMode(true)
241 | // }}
242 | // />
243 | //
244 | // >
245 | // )}
246 | //
247 | // )
248 | // }
249 |
250 | function Select({ onChange, className, options, value }) {
251 | return (
252 |
260 | )
261 | }
262 |
263 | function getSelectedNode(selection) {
264 | const anchor = selection.anchor
265 | const focus = selection.focus
266 | const anchorNode = selection.anchor.getNode()
267 | const focusNode = selection.focus.getNode()
268 | if (anchorNode === focusNode) {
269 | return anchorNode
270 | }
271 | const isBackward = selection.isBackward()
272 | if (isBackward) {
273 | return $isAtNodeEnd(focus) ? anchorNode : focusNode
274 | }
275 | // else {
276 | // return $isAtNodeEnd(anchor) ? focusNode : anchorNode
277 | // }
278 | }
279 |
280 | function BlockOptionsDropdownList({
281 | editor,
282 | blockType,
283 | toolbarRef,
284 | setShowBlockOptionsDropDown,
285 | }) {
286 | const dropDownRef = useRef(null)
287 |
288 | useEffect(() => {
289 | const toolbar = toolbarRef.current
290 | const dropDown = dropDownRef.current
291 |
292 | if (toolbar !== null && dropDown !== null) {
293 | const { top, left } = toolbar.getBoundingClientRect()
294 | dropDown.style.top = `${top + 40}px`
295 | dropDown.style.left = `${left}px`
296 | }
297 | }, [toolbarRef])
298 |
299 | useEffect(() => {
300 | const dropDown = dropDownRef.current
301 | const toolbar = toolbarRef.current
302 |
303 | if (dropDown !== null && toolbar !== null) {
304 | const handle = event => {
305 | const target = event.target
306 |
307 | if (!dropDown.contains(target) && !toolbar.contains(target)) {
308 | setShowBlockOptionsDropDown(false)
309 | }
310 | }
311 | document.addEventListener('click', handle)
312 |
313 | return () => {
314 | document.removeEventListener('click', handle)
315 | }
316 | }
317 | }, [setShowBlockOptionsDropDown, toolbarRef])
318 |
319 | const formatParagraph = () => {
320 | if (blockType !== 'paragraph') {
321 | editor.update(() => {
322 | const selection = $getSelection()
323 |
324 | if ($isRangeSelection(selection)) {
325 | $wrapNodes(selection, () => $createParagraphNode())
326 | }
327 | })
328 | }
329 | setShowBlockOptionsDropDown(false)
330 | }
331 |
332 | const formatLargeHeading = () => {
333 | if (blockType !== 'h1') {
334 | editor.update(() => {
335 | const selection = $getSelection()
336 |
337 | if ($isRangeSelection(selection)) {
338 | $wrapNodes(selection, () => $createHeadingNode('h1'))
339 | }
340 | })
341 | }
342 | setShowBlockOptionsDropDown(false)
343 | }
344 |
345 | const formatMediumHeading = () => {
346 | if (blockType !== 'h2') {
347 | editor.update(() => {
348 | const selection = $getSelection()
349 |
350 | if ($isRangeSelection(selection)) {
351 | $wrapNodes(selection, () => $createHeadingNode('h2'))
352 | }
353 | })
354 | }
355 | setShowBlockOptionsDropDown(false)
356 | }
357 |
358 | const formatSmallHeading = () => {
359 | if (blockType !== 'h3') {
360 | editor.update(() => {
361 | const selection = $getSelection()
362 |
363 | if ($isRangeSelection(selection)) {
364 | $wrapNodes(selection, () => $createHeadingNode('h3'))
365 | }
366 | })
367 | }
368 | setShowBlockOptionsDropDown(false)
369 | }
370 |
371 | const formatBulletList = () => {
372 | if (blockType !== 'ul') {
373 | editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)
374 | } else {
375 | editor.dispatchCommand(REMOVE_LIST_COMMAND)
376 | }
377 | setShowBlockOptionsDropDown(false)
378 | }
379 |
380 | const formatNumberedList = () => {
381 | if (blockType !== 'ol') {
382 | editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)
383 | } else {
384 | editor.dispatchCommand(REMOVE_LIST_COMMAND)
385 | }
386 | setShowBlockOptionsDropDown(false)
387 | }
388 |
389 | const formatQuote = () => {
390 | if (blockType !== 'quote') {
391 | editor.update(() => {
392 | const selection = $getSelection()
393 |
394 | if ($isRangeSelection(selection)) {
395 | $wrapNodes(selection, () => $createQuoteNode())
396 | }
397 | })
398 | }
399 | setShowBlockOptionsDropDown(false)
400 | }
401 |
402 | const formatCode = () => {
403 | if (blockType !== 'code') {
404 | editor.update(() => {
405 | const selection = $getSelection()
406 |
407 | if ($isRangeSelection(selection)) {
408 | $wrapNodes(selection, () => $createCodeNode())
409 | }
410 | })
411 | }
412 | setShowBlockOptionsDropDown(false)
413 | }
414 |
415 | return (
416 |
417 | {/* Paragraph */}
418 |
425 | {/* H1: Large Heading */}
426 |
433 | {/* H2: Medium Heading */}
434 |
441 | {/* H3: Small Heading */}
442 |
449 |
456 |
463 |
470 |
477 |
478 | )
479 | }
480 |
481 | export default function ToolbarPlugin() {
482 | const [editor] = useLexicalComposerContext()
483 | const toolbarRef = useRef(null)
484 | // const [canUndo, setCanUndo] = useState(false)
485 | // const [canRedo, setCanRedo] = useState(false)
486 | const [blockType, setBlockType] = useState('paragraph')
487 | const [selectedElementKey, setSelectedElementKey] = useState(null)
488 | const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] =
489 | useState(false)
490 | const [codeLanguage, setCodeLanguage] = useState('')
491 | const [isRTL, setIsRTL] = useState(false)
492 | // const [isLink, setIsLink] = useState(false)
493 | const [isBold, setIsBold] = useState(false)
494 | const [isItalic, setIsItalic] = useState(false)
495 | const [isUnderline, setIsUnderline] = useState(false)
496 | const [isStrikethrough, setIsStrikethrough] = useState(false)
497 | const [isCode, setIsCode] = useState(false)
498 |
499 | const updateToolbar = useCallback(() => {
500 | const selection = $getSelection()
501 | if ($isRangeSelection(selection)) {
502 | const anchorNode = selection.anchor.getNode()
503 | const element =
504 | anchorNode.getKey() === 'root'
505 | ? anchorNode
506 | : anchorNode.getTopLevelElementOrThrow()
507 | const elementKey = element.getKey()
508 | const elementDOM = editor.getElementByKey(elementKey)
509 | if (elementDOM !== null) {
510 | setSelectedElementKey(elementKey)
511 | if ($isListNode(element)) {
512 | const parentList = $getNearestNodeOfType(anchorNode, ListNode)
513 | const type = parentList ? parentList.getTag() : element.getTag()
514 | setBlockType(type)
515 | } else {
516 | const type = $isHeadingNode(element)
517 | ? element.getTag()
518 | : element.getType()
519 | setBlockType(type)
520 | if ($isCodeNode(element)) {
521 | setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage())
522 | }
523 | }
524 | }
525 | // Update text format
526 | setIsBold(selection.hasFormat('bold'))
527 | setIsItalic(selection.hasFormat('italic'))
528 | setIsUnderline(selection.hasFormat('underline'))
529 | setIsStrikethrough(selection.hasFormat('strikethrough'))
530 | setIsCode(selection.hasFormat('code'))
531 | setIsRTL($isParentElementRTL(selection))
532 |
533 | // Update links
534 | // const node = getSelectedNode(selection)
535 | // const parent = node.getParent()
536 | // if ($isLinkNode(parent) || $isLinkNode(node)) {
537 | // setIsLink(true)
538 | // } else {
539 | // setIsLink(false)
540 | // }
541 | }
542 | }, [editor])
543 |
544 | useEffect(() => {
545 | return mergeRegister(
546 | editor.registerUpdateListener(({ editorState }) => {
547 | editorState.read(() => {
548 | updateToolbar()
549 | })
550 | }),
551 | editor.registerCommand(
552 | SELECTION_CHANGE_COMMAND,
553 | (_payload, newEditor) => {
554 | updateToolbar()
555 | return false
556 | },
557 | LowPriority
558 | )
559 | // editor.registerCommand(
560 | // CAN_UNDO_COMMAND,
561 | // payload => {
562 | // setCanUndo(payload)
563 | // return false
564 | // },
565 | // LowPriority
566 | // ),
567 | // editor.registerCommand(
568 | // CAN_REDO_COMMAND,
569 | // payload => {
570 | // setCanRedo(payload)
571 | // return false
572 | // },
573 | // LowPriority
574 | // )
575 | )
576 | }, [editor, updateToolbar])
577 |
578 | const codeLanguges = useMemo(() => getCodeLanguages(), [])
579 | const onCodeLanguageSelect = useCallback(
580 | e => {
581 | editor.update(() => {
582 | if (selectedElementKey !== null) {
583 | const node = $getNodeByKey(selectedElementKey)
584 | if ($isCodeNode(node)) {
585 | node.setLanguage(e.target.value)
586 | }
587 | }
588 | })
589 | },
590 | [editor, selectedElementKey]
591 | )
592 |
593 | // const insertLink = useCallback(() => {
594 | // if (!isLink) {
595 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
596 | // } else {
597 | // editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
598 | // }
599 | // }, [editor, isLink])
600 |
601 | return (
602 |
603 | {/*
613 |
623 |
*/}
624 | {supportedBlockTypes.has(blockType) && (
625 | <>
626 |
642 | {showBlockOptionsDropDown &&
643 | createPortal(
644 |
,
650 | document.body
651 | )}
652 |
653 | >
654 | )}
655 | {blockType === 'code' ? (
656 | <>
657 |
663 |
664 |
665 |
666 | >
667 | ) : (
668 | <>
669 |
681 |
693 |
705 |
717 |
729 | {/*
736 | {isLink &&
737 | createPortal(
, document.body)}
738 |
*/}
739 | {/*
750 |
761 |
772 |
*/}
783 | >
784 | )}
785 |
786 | )
787 | }
788 |
--------------------------------------------------------------------------------
/src/stories/remindoro.stories.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import type { Meta, StoryObj } from '@storybook/react'
3 |
4 | import Slite, { Toolbar, Editor, type SliteProps } from '../index'
5 |
6 | function SliteWrapper({ initialValue, onChange, readOnly }: SliteProps) {
7 | return (
8 |
9 | {!readOnly && }
10 |
11 |
12 | )
13 | }
14 |
15 | // ref: https://storybook.js.org/docs/react/writing-stories/introduction
16 | const meta: Meta
= {
17 | /* 👇 The title prop is optional.
18 | * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
19 | * to learn how to generate automatic titles
20 | */
21 | title: 'Remindoro',
22 | component: Remindoro,
23 | }
24 |
25 | // test playground story
26 | function Remindoro() {
27 | const [isLive, setLive] = useState(false)
28 | const [content, setContent] = useState('')
29 |
30 | return (
31 |
32 |
33 | {
38 | setLive(event.target.checked)
39 | }}
40 | />
41 |
42 |
43 |
44 | {isLive ? (
45 | setContent(c)}
48 | readOnly={false}
49 | >
50 | {null}
51 |
52 | ) : (
53 |
63 |
64 | )
65 | }
66 |
67 | export default meta
68 | type Story = StoryObj
69 |
70 | export const Default: Story = {
71 | name: 'Remindoro',
72 | args: {},
73 | }
74 |
--------------------------------------------------------------------------------
/src/stories/slite.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react'
2 |
3 | import Slite, { Toolbar, Editor, type SliteProps } from '../index'
4 |
5 | function SliteWrapper({ initialValue, onChange, readOnly }: SliteProps) {
6 | return (
7 |
8 | {!readOnly && }
9 |
10 |
11 | )
12 | }
13 |
14 | // ref: https://storybook.js.org/docs/react/writing-stories/introduction
15 | const meta: Meta = {
16 | /* 👇 The title prop is optional.
17 | * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
18 | * to learn how to generate automatic titles
19 | */
20 | title: 'Slite',
21 | component: SliteWrapper,
22 | }
23 |
24 | export default meta
25 | type Story = StoryObj
26 |
27 | /*
28 | *👇 Render functions are a framework specific feature to allow you control on how the component renders.
29 | * See https://storybook.js.org/docs/react/api/csf
30 | * to learn how to use render functions.
31 | */
32 | export const Default: Story = {
33 | name: 'Default Slite',
34 | args: {
35 | onChange: () => {},
36 | },
37 | }
38 |
39 | const initialValue = `
40 | > porumai ...
41 |
42 |
43 | This is a new line
44 |
45 | amaidhi
46 |
47 |
48 |
49 |
50 |
51 | and a line after a break
52 | `
53 |
54 | export const InitialText: Story = {
55 | name: 'Slite with initial text',
56 | args: {
57 | initialValue: initialValue,
58 | onChange: changed => {
59 | console.log(changed)
60 | },
61 | },
62 | }
63 |
64 | export const ReadOnly: Story = {
65 | name: 'Read Only editor',
66 | args: {
67 | initialValue: 'porumai ... wait and hope ... `readonly` editor',
68 | onChange: () => {},
69 | readOnly: true,
70 | },
71 | }
72 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | h1 {
2 | font-size: 24px;
3 | color: #333;
4 | }
5 |
6 | .ltr {
7 | text-align: left;
8 | }
9 |
10 | .rtl {
11 | text-align: right;
12 | }
13 |
14 | /* START: Code Block 'Light' theme */
15 |
16 | .slite-editor-container.code-light .editor-code {
17 | background-color: rgb(240, 242, 245);
18 | }
19 |
20 | .slite-editor-container.code-light .editor-code:before {
21 | background-color: #eee;
22 | border-right: 1px solid #ccc;
23 | color: #777;
24 | }
25 |
26 | .slite-editor-container.code-light .editor-code:after {
27 | color: rgba(0, 0, 0, 0.5);
28 | }
29 |
30 | .slite-editor-container.code-light .editor-tokenComment {
31 | color: slategray;
32 | }
33 |
34 | .slite-editor-container.code-light .editor-tokenPunctuation {
35 | color: #999;
36 | }
37 |
38 | .slite-editor-container.code-light .editor-tokenProperty {
39 | color: #905;
40 | }
41 |
42 | .slite-editor-container.code-light .editor-tokenSelector {
43 | color: #690;
44 | }
45 |
46 | .slite-editor-container.code-light .editor-tokenOperator {
47 | color: #9a6e3a;
48 | }
49 |
50 | .slite-editor-container.code-light .editor-tokenAttr {
51 | color: #07a;
52 | }
53 |
54 | .slite-editor-container.code-light .editor-tokenVariable {
55 | color: #e90;
56 | }
57 |
58 | .slite-editor-container.code-light .editor-tokenFunction {
59 | color: #dd4a68;
60 | }
61 |
62 | /* END: Code Block 'Light' theme */
63 |
64 | .slite-editor-container {
65 | margin: 20px auto 20px auto;
66 | /* max-width: 600px; */
67 | color: #000;
68 | position: relative;
69 | line-height: 20px;
70 | font-weight: 400;
71 | text-align: left;
72 |
73 | background: #eee;
74 | font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular",
75 | sans-serif;
76 | -webkit-font-smoothing: antialiased;
77 | -moz-osx-font-smoothing: grayscale;
78 | }
79 |
80 | .editor-inner {
81 | background: #fff;
82 | position: relative;
83 | }
84 |
85 | .editor-input {
86 | min-height: 150px;
87 | resize: none;
88 | font-size: 15px;
89 | caret-color: rgb(5, 5, 5);
90 | position: relative;
91 | tab-size: 1;
92 | outline: 0;
93 | padding: 15px 10px;
94 | caret-color: #444;
95 | }
96 |
97 | .editor-placeholder {
98 | color: #999;
99 | overflow: hidden;
100 | position: absolute;
101 | text-overflow: ellipsis;
102 | top: 15px;
103 | left: 10px;
104 | font-size: 15px;
105 | user-select: none;
106 | display: inline-block;
107 | pointer-events: none;
108 | }
109 |
110 | .editor-text-bold {
111 | font-weight: bold;
112 | }
113 |
114 | .editor-text-italic {
115 | font-style: italic;
116 | }
117 |
118 | .editor-text-underline {
119 | text-decoration: underline;
120 | }
121 |
122 | .editor-text-strikethrough {
123 | text-decoration: line-through;
124 | }
125 |
126 | .editor-text-underlineStrikethrough {
127 | text-decoration: underline line-through;
128 | }
129 |
130 | .editor-text-code {
131 | background-color: rgb(240, 242, 245);
132 | padding: 1px 0.25rem;
133 | font-family: Menlo, Consolas, Monaco, monospace;
134 | font-size: 94%;
135 | }
136 |
137 | /* .editor-link {
138 | color: rgb(33, 111, 219);
139 | text-decoration: none;
140 | } */
141 |
142 | .editor-code {
143 | background-color: #1e1e1e;
144 | color: #d4d4d4;
145 | caret-color: #d4d4d4;
146 |
147 | font-family: Menlo, Consolas, Monaco, monospace;
148 | display: block;
149 | padding: 8px 8px 8px 52px;
150 | line-height: 1.53;
151 | font-size: 13px;
152 | margin: 0;
153 | margin-top: 20px;
154 | margin-bottom: 20px;
155 | tab-size: 2;
156 | /* white-space: pre; */
157 | overflow-x: auto;
158 | position: relative;
159 | }
160 |
161 | .editor-code:before {
162 | content: attr(data-gutter);
163 | position: absolute;
164 | background-color: #404040;
165 | left: 0;
166 | top: 0;
167 | border-right: 1px solid #6b6b6b;
168 | padding: 8px;
169 | color: #a6a6a6;
170 | white-space: pre-wrap;
171 | text-align: right;
172 | min-width: 25px;
173 | }
174 | .editor-code:after {
175 | content: attr(data-highlight-language);
176 | top: 0;
177 | right: 3px;
178 | padding: 3px;
179 | font-size: 10px;
180 | text-transform: uppercase;
181 | position: absolute;
182 | color: rgba(255, 255, 255, 0.95);
183 | }
184 |
185 | .editor-tokenComment {
186 | color: #6a9955;
187 | }
188 |
189 | .editor-tokenPunctuation {
190 | color: #569cd6;
191 | }
192 |
193 | .editor-tokenProperty {
194 | color: #9cdcfe;
195 | }
196 |
197 | .editor-tokenSelector {
198 | /* color: #569cd6; */
199 | /* color: #4EC9B0; */
200 | color: #fcf492;
201 | }
202 |
203 | .editor-tokenOperator {
204 | color: #d4d4d4;
205 | }
206 |
207 | .editor-tokenAttr {
208 | color: #9cdcfe;
209 | }
210 |
211 | .editor-tokenVariable {
212 | color: #569cd6;
213 | }
214 |
215 | .editor-tokenFunction {
216 | /* color: #d7ba7d; */
217 | color: #f29d9d;
218 | }
219 |
220 | .editor-paragraph {
221 | margin: 0;
222 | margin-bottom: 20px;
223 | position: relative;
224 | }
225 |
226 | .editor-paragraph:last-child {
227 | margin-bottom: 0;
228 | }
229 |
230 | .editor-heading-h1 {
231 | font-size: 24px;
232 | color: rgb(101, 103, 107);
233 | font-weight: 600;
234 | padding: 0;
235 | }
236 |
237 | .editor-heading-h2 {
238 | font-size: 20px;
239 | color: rgb(101, 103, 107);
240 | font-weight: 600;
241 | padding: 0;
242 | }
243 |
244 | .editor-heading-h3 {
245 | font-size: 16px;
246 | color: rgb(101, 103, 107);
247 | font-weight: 600;
248 | padding: 0;
249 | }
250 |
251 | .editor-quote {
252 | margin: 0;
253 | margin-left: 20px;
254 | margin-top: 20px;
255 | margin-bottom: 20px;
256 | font-size: 15px;
257 | color: rgb(101, 103, 107);
258 | border-left-color: rgb(206, 208, 212);
259 | border-left-width: 4px;
260 | border-left-style: solid;
261 | padding-left: 16px;
262 | }
263 |
264 | .editor-list-ol {
265 | padding: 0;
266 | margin: 0;
267 | margin-left: 16px;
268 | }
269 |
270 | .editor-list-ul {
271 | padding: 0;
272 | margin: 0;
273 | margin-left: 16px;
274 | }
275 |
276 | .editor-listitem {
277 | margin: 8px 32px 8px 32px;
278 | }
279 |
280 | .editor-nested-listitem {
281 | list-style-type: none;
282 | }
283 |
284 | .toolbar {
285 | display: flex;
286 | margin-bottom: 1px;
287 | background: #fff;
288 | padding: 4px;
289 | vertical-align: middle;
290 | }
291 |
292 | .toolbar button.toolbar-item {
293 | border: 0;
294 | display: flex;
295 | background: none;
296 | border-radius: 10px;
297 | padding: 8px;
298 | cursor: pointer;
299 | vertical-align: middle;
300 | }
301 |
302 | .toolbar button.toolbar-item:disabled {
303 | cursor: not-allowed;
304 | }
305 |
306 | .toolbar button.toolbar-item.spaced {
307 | margin-right: 2px;
308 | }
309 |
310 | .toolbar button.toolbar-item i.format {
311 | background-size: contain;
312 | display: inline-block;
313 | height: 18px;
314 | width: 18px;
315 | margin-top: 2px;
316 | vertical-align: -0.25em;
317 | display: flex;
318 | opacity: 0.6;
319 | }
320 |
321 | .toolbar button.toolbar-item:disabled i.format {
322 | opacity: 0.2;
323 | }
324 |
325 | .toolbar button.toolbar-item.active {
326 | background-color: rgba(223, 232, 250, 0.3);
327 | }
328 |
329 | .toolbar button.toolbar-item.active i {
330 | opacity: 1;
331 | }
332 |
333 | .toolbar .toolbar-item:hover:not([disabled]) {
334 | background-color: #eee;
335 | }
336 |
337 | .toolbar .divider {
338 | width: 1px;
339 | background-color: #eee;
340 | margin: 0 4px;
341 | }
342 |
343 | .toolbar select.toolbar-item {
344 | border: 0;
345 | display: flex;
346 | background: none;
347 | border-radius: 10px;
348 | padding: 8px;
349 | vertical-align: middle;
350 | -webkit-appearance: none;
351 | -moz-appearance: none;
352 | width: 70px;
353 | font-size: 14px;
354 | color: #777;
355 | text-overflow: ellipsis;
356 | }
357 |
358 | .toolbar select.code-language {
359 | text-transform: capitalize;
360 | width: 130px;
361 | }
362 |
363 | .toolbar .toolbar-item .text {
364 | display: flex;
365 | line-height: 20px;
366 | width: 200px;
367 | vertical-align: middle;
368 | font-size: 14px;
369 | color: #777;
370 | text-overflow: ellipsis;
371 | width: 70px;
372 | overflow: hidden;
373 | height: 20px;
374 | text-align: left;
375 | }
376 |
377 | .toolbar .toolbar-item .icon {
378 | display: flex;
379 | width: 20px;
380 | height: 20px;
381 | user-select: none;
382 | margin-right: 8px;
383 | line-height: 16px;
384 | background-size: contain;
385 | }
386 |
387 | .toolbar i.chevron-down {
388 | margin-top: 3px;
389 | width: 16px;
390 | height: 16px;
391 | display: flex;
392 | user-select: none;
393 | }
394 |
395 | .toolbar i.chevron-down.inside {
396 | width: 16px;
397 | height: 16px;
398 | display: flex;
399 | margin-left: -25px;
400 | margin-top: 11px;
401 | margin-right: 10px;
402 | pointer-events: none;
403 | }
404 |
405 | i.chevron-down {
406 | background-color: transparent;
407 | background-size: contain;
408 | display: inline-block;
409 | height: 8px;
410 | width: 8px;
411 | }
412 |
413 | .slite-dropdown {
414 | z-index: 5;
415 | display: block;
416 | position: absolute;
417 | box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
418 | inset 0 0 0 1px rgba(255, 255, 255, 0.5);
419 | border-radius: 8px;
420 | min-width: 100px;
421 | min-height: 40px;
422 | background-color: #fff;
423 | }
424 |
425 | .slite-dropdown .item {
426 | margin: 0 8px 0 8px;
427 | padding: 8px;
428 | color: #050505;
429 | cursor: pointer;
430 | line-height: 16px;
431 | font-size: 15px;
432 | display: flex;
433 | align-content: center;
434 | flex-direction: row;
435 | flex-shrink: 0;
436 | justify-content: space-between;
437 | background-color: #fff;
438 | border-radius: 8px;
439 | border: 0;
440 | min-width: 268px;
441 | }
442 |
443 | .slite-dropdown .item .active {
444 | display: flex;
445 | width: 20px;
446 | height: 20px;
447 | background-size: contain;
448 | }
449 |
450 | .slite-dropdown .item:first-child {
451 | margin-top: 8px;
452 | }
453 |
454 | .slite-dropdown .item:last-child {
455 | margin-bottom: 8px;
456 | }
457 |
458 | .slite-dropdown .item:hover {
459 | background-color: #eee;
460 | }
461 |
462 | .slite-dropdown .item .text {
463 | display: flex;
464 | line-height: 20px;
465 | flex-grow: 1;
466 | width: 200px;
467 | }
468 |
469 | .slite-dropdown .item .icon {
470 | display: flex;
471 | width: 20px;
472 | height: 20px;
473 | user-select: none;
474 | margin-right: 12px;
475 | line-height: 16px;
476 | background-size: contain;
477 | }
478 |
--------------------------------------------------------------------------------
/src/themes/DefaultTheme.ts:
--------------------------------------------------------------------------------
1 | const defaultTheme = {
2 | ltr: 'ltr',
3 | rtl: 'rtl',
4 | placeholder: 'editor-placeholder',
5 | paragraph: 'editor-paragraph',
6 | quote: 'editor-quote',
7 | heading: {
8 | h1: 'editor-heading-h1',
9 | h2: 'editor-heading-h2',
10 | h3: 'editor-heading-h3',
11 | h4: 'editor-heading-h4',
12 | h5: 'editor-heading-h5',
13 | },
14 | list: {
15 | nested: {
16 | listitem: 'editor-nested-listitem',
17 | },
18 | ol: 'editor-list-ol',
19 | ul: 'editor-list-ul',
20 | listitem: 'editor-listitem',
21 | },
22 | image: 'editor-image',
23 | link: 'editor-link',
24 | text: {
25 | bold: 'editor-text-bold',
26 | italic: 'editor-text-italic',
27 | overflowed: 'editor-text-overflowed',
28 | hashtag: 'editor-text-hashtag',
29 | underline: 'editor-text-underline',
30 | strikethrough: 'editor-text-strikethrough',
31 | underlineStrikethrough: 'editor-text-underlineStrikethrough',
32 | code: 'editor-text-code',
33 | },
34 | code: 'editor-code',
35 | codeHighlight: {
36 | atrule: 'editor-tokenAttr',
37 | attr: 'editor-tokenAttr',
38 | boolean: 'editor-tokenProperty',
39 | builtin: 'editor-tokenSelector',
40 | cdata: 'editor-tokenComment',
41 | char: 'editor-tokenSelector',
42 | class: 'editor-tokenFunction',
43 | 'class-name': 'editor-tokenFunction',
44 | comment: 'editor-tokenComment',
45 | constant: 'editor-tokenProperty',
46 | deleted: 'editor-tokenProperty',
47 | doctype: 'editor-tokenComment',
48 | entity: 'editor-tokenOperator',
49 | function: 'editor-tokenFunction',
50 | important: 'editor-tokenVariable',
51 | inserted: 'editor-tokenSelector',
52 | keyword: 'editor-tokenAttr',
53 | namespace: 'editor-tokenVariable',
54 | number: 'editor-tokenProperty',
55 | operator: 'editor-tokenOperator',
56 | prolog: 'editor-tokenComment',
57 | property: 'editor-tokenProperty',
58 | punctuation: 'editor-tokenPunctuation',
59 | regex: 'editor-tokenVariable',
60 | selector: 'editor-tokenSelector',
61 | string: 'editor-tokenSelector',
62 | symbol: 'editor-tokenProperty',
63 | tag: 'editor-tokenProperty',
64 | url: 'editor-tokenOperator',
65 | variable: 'editor-tokenVariable',
66 | },
67 | }
68 |
69 | export default defaultTheme
70 |
71 | export const SLITE_EDITOR_CONTAINER_CLASS = 'slite-editor-container'
72 | export const SLITE_DROPDOWN_CLASS = 'slite-dropdown'
73 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | const isBlockQuote = (input: string) => {
2 | return input.startsWith('> ')
3 | }
4 |
5 | const isCodeBlockStart = (input: string) => {
6 | return input.startsWith('```')
7 | }
8 |
9 | const isCodeBlockEnd = (input: string) => {
10 | return input.endsWith('```')
11 | }
12 |
13 | // helper function to insert soft line breaks in markdown to preserve new line/paragraph
14 | // adds a trailing slash -> 'LINE_BREAK_FIX' transformer
15 | function insertSoftLineBreaks(input: string): string {
16 | const NEW_LINE = '\n\n'
17 | const SOFT_BREAK = '\n\r\n'
18 |
19 | const splitted = input.split(NEW_LINE)
20 | const mapped: Array = []
21 |
22 | let isActiveCodeBlock = false
23 |
24 | for (const s of splitted) {
25 | mapped.push(s)
26 |
27 | if (isCodeBlockStart(s)) {
28 | isActiveCodeBlock = true
29 | }
30 | if (isCodeBlockEnd(s)) {
31 | isActiveCodeBlock = false
32 | }
33 |
34 | if (isBlockQuote(s.trim())) {
35 | mapped.push(NEW_LINE)
36 | } else if (isActiveCodeBlock || s.includes('```')) {
37 | mapped.push(NEW_LINE)
38 | } else {
39 | mapped.push(SOFT_BREAK)
40 | }
41 | }
42 |
43 | return mapped.join('')
44 | }
45 |
46 | // remove trailing slash from the exported markdown
47 | function removeSoftLineBreaks(input: string): string {
48 | const DOUBLE_LINE = '\n\n'
49 | const splitted = input.split('\r\n')
50 | // hack to remove extra new lines inserted by lexical
51 | const mapped = splitted.map(s => {
52 | // if there is a double new line in between we will trim into a single new line
53 | if (
54 | !s.startsWith(DOUBLE_LINE) &&
55 | !s.endsWith(DOUBLE_LINE) &&
56 | // and if it has a double line in between text nodes we will convert it
57 | s.includes(DOUBLE_LINE) &&
58 | // we will not touch the lines with code block
59 | !s.includes('```') &&
60 | // we will not touch block quote
61 | !s.includes('> ')
62 | ) {
63 | return s.replaceAll(DOUBLE_LINE, '\n')
64 | }
65 |
66 | return s
67 | })
68 |
69 | return mapped.join('\n')
70 | }
71 |
--------------------------------------------------------------------------------
/test/slite.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import { describe, test } from 'vitest'
4 |
5 | import Slite, { Editor } from '../src/'
6 |
7 | function DefaultSlite() {
8 | return (
9 | {}}>
10 |
11 |
12 | )
13 | }
14 |
15 | describe('Slite', () => {
16 | test('render without crashing', () => {
17 | const div = document.createElement('div')
18 | const root = createRoot(div) // createRoot(container!) if you use TypeScript
19 | root.render()
20 | root.unmount()
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types"],
4 | "compilerOptions": {
5 | "types": ["vitest/globals"],
6 | "module": "esnext",
7 | "lib": ["dom", "esnext"],
8 | "importHelpers": true,
9 | // output .d.ts declaration files for consumers
10 | "declaration": true,
11 | // output .js.map sourcemap files for consumers
12 | "sourceMap": true,
13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
14 | "rootDir": "./src",
15 | // stricter type-checking for stronger correctness. Recommended by TS
16 | "strict": true,
17 | // linter checks for common issues
18 | "noImplicitReturns": true,
19 | "noFallthroughCasesInSwitch": true,
20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | // use Node's module resolution algorithm, instead of the legacy TS one
24 | "moduleResolution": "node",
25 | // transpile JSX to React.createElement
26 | "jsx": "react-jsx",
27 | // interop between ESM and CJS modules. Recommended by TS
28 | "esModuleInterop": true,
29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
30 | "skipLibCheck": true,
31 | // error out if import and file system have a casing mismatch. Recommended by TS
32 | "forceConsistentCasingInFileNames": true,
33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
34 | "noEmit": true,
35 | "allowJs": true
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react'
4 | import dts from 'vite-plugin-dts'
5 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
6 |
7 | // ref: https://vitejs.dev/guide/build.html#library-mode
8 | // https://vitejs.dev/config/
9 | export default defineConfig({
10 | resolve: {
11 | // ref: https://github.com/vitejs/vite/issues/1401#issuecomment-755594141
12 | // ref: https://vitejs.dev/config/shared-options.html#resolve-dedupe
13 | dedupe: ['react', 'react-dom'],
14 | },
15 | plugins: [react(), dts(), cssInjectedByJsPlugin()],
16 | build: {
17 | lib: {
18 | formats: ['es', 'cjs'],
19 | // Could also be a dictionary or array of multiple entry points
20 | entry: resolve(__dirname, 'src/index.tsx'),
21 | name: 'Slite',
22 | // the proper extensions will be added
23 | fileName: format => `react-slite.${format}.js`,
24 | },
25 | rollupOptions: {
26 | // make sure to externalize deps that shouldn't be bundled into your library
27 | // ref: https://stackoverflow.com/questions/76135802/how-do-i-make-my-react-package-use-an-external-jsx-runtime
28 | external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime' ],
29 | output: {
30 | // Provide global variables to use in the UMD build
31 | // for externalized deps
32 | globals: {
33 | react: 'React',
34 | },
35 | },
36 | },
37 | },
38 | })
39 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { defineConfig } from 'vite'
4 |
5 | export default defineConfig({
6 | test: {
7 | globals: false,
8 | environment: 'happy-dom',
9 | },
10 | })
11 |
--------------------------------------------------------------------------------