@zuilib/text-editor 0.0.0
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.
- package/README.md +57 -0
- package/dist/index.css +79 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +391 -0
- package/dist/styles.css +81 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @zuilib/text-editor
|
|
2
|
+
|
|
3
|
+
Markdown editor for ZUI Design System — a React component built on [Lexical](https://lexical.dev/) with markdown shortcuts, code highlighting, checklists, and raw/rich/view modes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @zuilib/text-editor @zuilib/core lexical @lexical/react @lexical/markdown @lexical/rich-text @lexical/code @lexical/list @lexical/link
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { useState } from 'react'
|
|
15
|
+
import { MarkdownEditor } from '@zuilib/text-editor'
|
|
16
|
+
import '@zuilib/text-editor/styles.css'
|
|
17
|
+
|
|
18
|
+
function Notes() {
|
|
19
|
+
const [value, setValue] = useState('# Hello\n\n- [ ] Try me')
|
|
20
|
+
|
|
21
|
+
return <MarkdownEditor value={value} onChange={setValue} />
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Modes
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// Rich markdown editing (default) — shortcuts like `#`, `- [ ]`, ``` `
|
|
29
|
+
<MarkdownEditor mode="edit-md" value={value} onChange={setValue} />
|
|
30
|
+
|
|
31
|
+
// Raw markdown in a plain textarea
|
|
32
|
+
<MarkdownEditor mode="edit-raw" value={value} onChange={setValue} />
|
|
33
|
+
|
|
34
|
+
// Read-only rendered view
|
|
35
|
+
<MarkdownEditor mode="view" value={value} />
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Props
|
|
39
|
+
|
|
40
|
+
- `value`: `string` — markdown source
|
|
41
|
+
- `onChange`: `(value: string) => void`
|
|
42
|
+
- `mode`: `'edit-md' | 'edit-raw' | 'view'` (default `'edit-md'`)
|
|
43
|
+
- `placeholder`: `string` (default `'Start writing...'`)
|
|
44
|
+
- `readOnly`: `boolean`
|
|
45
|
+
- `autoFocus`: `boolean`
|
|
46
|
+
- `className`: `string`
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Markdown shortcuts: headings, lists, checklists, code blocks, quotes, links
|
|
51
|
+
- Syntax-highlighted fenced code blocks
|
|
52
|
+
- Undo/redo history
|
|
53
|
+
- Cursor-stable mode switching between raw and rich editing
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/dist/index.css
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* src/styles.css */
|
|
2
|
+
.zui-text-editor {
|
|
3
|
+
position: relative;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
overflow-y: auto;
|
|
7
|
+
}
|
|
8
|
+
.zui-text-editor-content {
|
|
9
|
+
min-height: 100%;
|
|
10
|
+
padding: 1.5rem 2rem;
|
|
11
|
+
outline: none;
|
|
12
|
+
}
|
|
13
|
+
.zui-text-editor-placeholder {
|
|
14
|
+
position: absolute;
|
|
15
|
+
top: 1.5rem;
|
|
16
|
+
left: 2rem;
|
|
17
|
+
pointer-events: none;
|
|
18
|
+
user-select: none;
|
|
19
|
+
}
|
|
20
|
+
.zui-checklist {
|
|
21
|
+
list-style: none;
|
|
22
|
+
margin-left: 0;
|
|
23
|
+
padding-left: 0;
|
|
24
|
+
}
|
|
25
|
+
.zui-checklist li[role=checkbox] {
|
|
26
|
+
position: relative;
|
|
27
|
+
padding-left: 1.75rem;
|
|
28
|
+
margin-left: 0;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
list-style: none;
|
|
31
|
+
}
|
|
32
|
+
.zui-checklist li[role=checkbox]::before {
|
|
33
|
+
content: "";
|
|
34
|
+
position: absolute;
|
|
35
|
+
left: 0;
|
|
36
|
+
top: 0.25em;
|
|
37
|
+
width: 1rem;
|
|
38
|
+
height: 1rem;
|
|
39
|
+
border: 2px solid currentColor;
|
|
40
|
+
border-radius: 0.2rem;
|
|
41
|
+
opacity: 0.5;
|
|
42
|
+
background: transparent;
|
|
43
|
+
}
|
|
44
|
+
.zui-checklist li[role=checkbox][aria-checked=true]::before {
|
|
45
|
+
background: currentColor;
|
|
46
|
+
opacity: 0.5;
|
|
47
|
+
}
|
|
48
|
+
.zui-checklist li[role=checkbox][aria-checked=true]::after {
|
|
49
|
+
content: "";
|
|
50
|
+
position: absolute;
|
|
51
|
+
left: 0.3rem;
|
|
52
|
+
top: 0.35em;
|
|
53
|
+
width: 0.4rem;
|
|
54
|
+
height: 0.7rem;
|
|
55
|
+
border: solid white;
|
|
56
|
+
border-width: 0 2px 2px 0;
|
|
57
|
+
transform: rotate(45deg);
|
|
58
|
+
}
|
|
59
|
+
.zui-text-editor-textarea {
|
|
60
|
+
width: 100%;
|
|
61
|
+
height: 100%;
|
|
62
|
+
min-height: 100%;
|
|
63
|
+
padding: 1.5rem 2rem;
|
|
64
|
+
outline: none;
|
|
65
|
+
resize: none;
|
|
66
|
+
background: transparent;
|
|
67
|
+
color: inherit;
|
|
68
|
+
font-family:
|
|
69
|
+
ui-monospace,
|
|
70
|
+
SFMono-Regular,
|
|
71
|
+
"SF Mono",
|
|
72
|
+
Menlo,
|
|
73
|
+
Consolas,
|
|
74
|
+
"Liberation Mono",
|
|
75
|
+
monospace;
|
|
76
|
+
font-size: 0.875rem;
|
|
77
|
+
line-height: 1.6;
|
|
78
|
+
border: none;
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
type Props = Readonly<{
|
|
4
|
+
value?: string;
|
|
5
|
+
onChange?: (value: string) => void;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
readOnly?: boolean;
|
|
8
|
+
className?: string;
|
|
9
|
+
mode?: 'edit-raw' | 'edit-md' | 'view';
|
|
10
|
+
autoFocus?: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
declare function MarkdownEditor({ value, onChange, placeholder, readOnly, className, mode, autoFocus, }: Props): react_jsx_runtime.JSX.Element;
|
|
13
|
+
|
|
14
|
+
export { MarkdownEditor, type Props as MarkdownEditorProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// src/MarkdownEditor.tsx
|
|
2
|
+
import { useCallback, useEffect as useEffect5, useMemo, useRef as useRef2, useState } from "react";
|
|
3
|
+
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
|
4
|
+
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
5
|
+
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
|
6
|
+
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
|
7
|
+
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
|
8
|
+
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
|
9
|
+
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
|
|
10
|
+
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
|
|
11
|
+
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
|
|
12
|
+
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
|
13
|
+
import { CHECK_LIST, CODE, TRANSFORMERS } from "@lexical/markdown";
|
|
14
|
+
import { useLexicalComposerContext as useLexicalComposerContext5 } from "@lexical/react/LexicalComposerContext";
|
|
15
|
+
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
|
16
|
+
import { ListItemNode, ListNode as ListNode2 } from "@lexical/list";
|
|
17
|
+
import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
|
18
|
+
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
|
19
|
+
|
|
20
|
+
// src/plugins/ChecklistShortcutPlugin.tsx
|
|
21
|
+
import { useEffect } from "react";
|
|
22
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
23
|
+
import {
|
|
24
|
+
$isListItemNode,
|
|
25
|
+
$isListNode,
|
|
26
|
+
ListNode
|
|
27
|
+
} from "@lexical/list";
|
|
28
|
+
import { $getNodeByKey, TextNode } from "lexical";
|
|
29
|
+
var CHECK_PATTERN = /^\[(\s|x)?\]\s/i;
|
|
30
|
+
function ChecklistShortcutPlugin() {
|
|
31
|
+
const [editor] = useLexicalComposerContext();
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const unregisterMutation = editor.registerMutationListener(
|
|
34
|
+
ListNode,
|
|
35
|
+
(mutations) => {
|
|
36
|
+
editor.getEditorState().read(() => {
|
|
37
|
+
for (const [key, type] of mutations) {
|
|
38
|
+
if (type === "destroyed") continue;
|
|
39
|
+
const dom = editor.getElementByKey(key);
|
|
40
|
+
const node = $getNodeByKey(key);
|
|
41
|
+
if (dom && $isListNode(node)) {
|
|
42
|
+
;
|
|
43
|
+
dom.__lexicalListType = node.getListType();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
const unregisterTransform = editor.registerNodeTransform(
|
|
50
|
+
TextNode,
|
|
51
|
+
(textNode) => {
|
|
52
|
+
const listItem = textNode.getParent();
|
|
53
|
+
if (!$isListItemNode(listItem)) return;
|
|
54
|
+
if (listItem.getFirstChild()?.getKey() !== textNode.getKey()) return;
|
|
55
|
+
const listNode = listItem.getParent();
|
|
56
|
+
if (!$isListNode(listNode) || listNode.getListType() === "check")
|
|
57
|
+
return;
|
|
58
|
+
const text = textNode.getTextContent();
|
|
59
|
+
const match = text.match(CHECK_PATTERN);
|
|
60
|
+
if (!match) return;
|
|
61
|
+
const checked = match[1] === "x" || match[1] === "X";
|
|
62
|
+
textNode.setTextContent(text.slice(match[0].length));
|
|
63
|
+
listNode.setListType("check");
|
|
64
|
+
listItem.setChecked(checked);
|
|
65
|
+
textNode.select(0, 0);
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
return () => {
|
|
69
|
+
unregisterMutation();
|
|
70
|
+
unregisterTransform();
|
|
71
|
+
};
|
|
72
|
+
}, [editor]);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/plugins/CodeBlockShortcutPlugin.tsx
|
|
77
|
+
import { useEffect as useEffect2 } from "react";
|
|
78
|
+
import { useLexicalComposerContext as useLexicalComposerContext2 } from "@lexical/react/LexicalComposerContext";
|
|
79
|
+
import {
|
|
80
|
+
$createParagraphNode,
|
|
81
|
+
$getSelection,
|
|
82
|
+
$isParagraphNode,
|
|
83
|
+
$isRangeSelection,
|
|
84
|
+
$isRootOrShadowRoot,
|
|
85
|
+
COMMAND_PRIORITY_LOW,
|
|
86
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
87
|
+
TextNode as TextNode2
|
|
88
|
+
} from "lexical";
|
|
89
|
+
import { $createCodeNode, $isCodeNode } from "@lexical/code";
|
|
90
|
+
var CODE_BLOCK_PATTERN = /^```(\w[\w-]*)?$/;
|
|
91
|
+
function CodeBlockShortcutPlugin() {
|
|
92
|
+
const [editor] = useLexicalComposerContext2();
|
|
93
|
+
useEffect2(() => {
|
|
94
|
+
const unregisterTransform = editor.registerNodeTransform(
|
|
95
|
+
TextNode2,
|
|
96
|
+
(textNode) => {
|
|
97
|
+
const parent = textNode.getParent();
|
|
98
|
+
if (!$isParagraphNode(parent)) return;
|
|
99
|
+
if (parent.getFirstChild()?.getKey() !== textNode.getKey()) return;
|
|
100
|
+
const grandParent = parent.getParent();
|
|
101
|
+
if (!grandParent || !$isRootOrShadowRoot(grandParent)) return;
|
|
102
|
+
const text = textNode.getTextContent().trimEnd();
|
|
103
|
+
const match = text.match(CODE_BLOCK_PATTERN);
|
|
104
|
+
if (!match) return;
|
|
105
|
+
const language = match[1] || void 0;
|
|
106
|
+
const codeNode = $createCodeNode(language);
|
|
107
|
+
parent.replace(codeNode);
|
|
108
|
+
codeNode.selectEnd();
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
const unregisterCommand = editor.registerCommand(
|
|
112
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
113
|
+
() => {
|
|
114
|
+
const selection = $getSelection();
|
|
115
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
|
|
116
|
+
const anchor = selection.anchor;
|
|
117
|
+
const anchorNode = anchor.getNode();
|
|
118
|
+
let topNode = anchorNode;
|
|
119
|
+
while (topNode.getParent() && !$isRootOrShadowRoot(topNode.getParent())) {
|
|
120
|
+
topNode = topNode.getParent();
|
|
121
|
+
}
|
|
122
|
+
if (!$isCodeNode(topNode)) return false;
|
|
123
|
+
const root = topNode.getParent();
|
|
124
|
+
if (!root || root.getLastChild()?.getKey() !== topNode.getKey()) return false;
|
|
125
|
+
const codeText = topNode.getTextContent();
|
|
126
|
+
const lastNewline = codeText.lastIndexOf("\n");
|
|
127
|
+
const isOnLastLine = anchor.offset >= lastNewline + 1;
|
|
128
|
+
if (!isOnLastLine) return false;
|
|
129
|
+
const paragraph = $createParagraphNode();
|
|
130
|
+
topNode.insertAfter(paragraph);
|
|
131
|
+
paragraph.selectStart();
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
COMMAND_PRIORITY_LOW
|
|
135
|
+
);
|
|
136
|
+
return () => {
|
|
137
|
+
unregisterTransform();
|
|
138
|
+
unregisterCommand();
|
|
139
|
+
};
|
|
140
|
+
}, [editor]);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/plugins/CodeHighlightPlugin.tsx
|
|
145
|
+
import { useEffect as useEffect3 } from "react";
|
|
146
|
+
import { useLexicalComposerContext as useLexicalComposerContext3 } from "@lexical/react/LexicalComposerContext";
|
|
147
|
+
import { registerCodeHighlighting } from "@lexical/code";
|
|
148
|
+
function CodeHighlightPlugin() {
|
|
149
|
+
const [editor] = useLexicalComposerContext3();
|
|
150
|
+
useEffect3(() => {
|
|
151
|
+
return registerCodeHighlighting(editor);
|
|
152
|
+
}, [editor]);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/plugins/MarkdownSyncPlugin.tsx
|
|
157
|
+
import { useEffect as useEffect4, useRef } from "react";
|
|
158
|
+
import { useLexicalComposerContext as useLexicalComposerContext4 } from "@lexical/react/LexicalComposerContext";
|
|
159
|
+
import {
|
|
160
|
+
$convertFromMarkdownString,
|
|
161
|
+
$convertToMarkdownString
|
|
162
|
+
} from "@lexical/markdown";
|
|
163
|
+
function MarkdownSyncPlugin({
|
|
164
|
+
initialMarkdown = "",
|
|
165
|
+
onChange,
|
|
166
|
+
transformers
|
|
167
|
+
}) {
|
|
168
|
+
const [editor] = useLexicalComposerContext4();
|
|
169
|
+
const lastMarkdownRef = useRef("");
|
|
170
|
+
const onChangeRef = useRef(onChange);
|
|
171
|
+
onChangeRef.current = onChange;
|
|
172
|
+
useEffect4(() => {
|
|
173
|
+
if (initialMarkdown && initialMarkdown !== lastMarkdownRef.current) {
|
|
174
|
+
lastMarkdownRef.current = initialMarkdown;
|
|
175
|
+
editor.update(() => {
|
|
176
|
+
$convertFromMarkdownString(initialMarkdown, transformers);
|
|
177
|
+
}, { tag: "initial-load" });
|
|
178
|
+
}
|
|
179
|
+
}, [editor, initialMarkdown]);
|
|
180
|
+
useEffect4(() => {
|
|
181
|
+
return editor.registerUpdateListener(
|
|
182
|
+
({ editorState, dirtyElements, dirtyLeaves, tags }) => {
|
|
183
|
+
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
|
|
184
|
+
if (tags.has("initial-load")) return;
|
|
185
|
+
editorState.read(() => {
|
|
186
|
+
const markdown = $convertToMarkdownString(transformers);
|
|
187
|
+
onChangeRef.current?.(markdown);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
}, [editor]);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/theme.ts
|
|
196
|
+
var editorTheme = {
|
|
197
|
+
ltr: "text-left",
|
|
198
|
+
rtl: "text-right",
|
|
199
|
+
placeholder: "text-muted-foreground opacity-50",
|
|
200
|
+
paragraph: "mb-2",
|
|
201
|
+
heading: {
|
|
202
|
+
h1: "text-4xl font-bold mb-4",
|
|
203
|
+
h2: "text-3xl font-bold mb-3",
|
|
204
|
+
h3: "text-2xl font-bold mb-2",
|
|
205
|
+
h4: "text-xl font-bold mb-2",
|
|
206
|
+
h5: "text-lg font-bold mb-1",
|
|
207
|
+
h6: "text-base font-bold mb-1"
|
|
208
|
+
},
|
|
209
|
+
list: {
|
|
210
|
+
listitem: "ml-8",
|
|
211
|
+
listitemChecked: "line-through opacity-50",
|
|
212
|
+
listitemUnchecked: "",
|
|
213
|
+
nested: {
|
|
214
|
+
listitem: "ml-8"
|
|
215
|
+
},
|
|
216
|
+
checklist: "zui-checklist",
|
|
217
|
+
ol: "list-decimal ml-4",
|
|
218
|
+
ul: "list-disc ml-4"
|
|
219
|
+
},
|
|
220
|
+
link: "text-primary underline hover:opacity-80 cursor-pointer",
|
|
221
|
+
text: {
|
|
222
|
+
bold: "font-bold",
|
|
223
|
+
code: "bg-muted px-1.5 py-0.5 rounded font-mono text-sm",
|
|
224
|
+
italic: "italic",
|
|
225
|
+
strikethrough: "line-through",
|
|
226
|
+
subscript: "text-xs align-sub",
|
|
227
|
+
superscript: "text-xs align-super",
|
|
228
|
+
underline: "underline",
|
|
229
|
+
underlineStrikethrough: "underline line-through"
|
|
230
|
+
},
|
|
231
|
+
code: "bg-muted rounded-md p-4 font-mono text-sm block my-2 overflow-x-auto",
|
|
232
|
+
codeHighlight: {
|
|
233
|
+
atrule: "text-blue-500",
|
|
234
|
+
attr: "text-yellow-500",
|
|
235
|
+
boolean: "text-purple-500",
|
|
236
|
+
builtin: "text-cyan-500",
|
|
237
|
+
cdata: "text-gray-500",
|
|
238
|
+
char: "text-green-500",
|
|
239
|
+
class: "text-yellow-500",
|
|
240
|
+
"class-name": "text-yellow-500",
|
|
241
|
+
comment: "text-gray-500",
|
|
242
|
+
constant: "text-purple-500",
|
|
243
|
+
deleted: "text-red-500",
|
|
244
|
+
doctype: "text-gray-500",
|
|
245
|
+
entity: "text-red-500",
|
|
246
|
+
function: "text-blue-500",
|
|
247
|
+
important: "text-red-500",
|
|
248
|
+
inserted: "text-green-500",
|
|
249
|
+
keyword: "text-purple-500",
|
|
250
|
+
namespace: "text-purple-500",
|
|
251
|
+
number: "text-green-500",
|
|
252
|
+
operator: "text-gray-500",
|
|
253
|
+
prolog: "text-gray-500",
|
|
254
|
+
property: "text-blue-500",
|
|
255
|
+
punctuation: "text-gray-500",
|
|
256
|
+
regex: "text-red-500",
|
|
257
|
+
selector: "text-blue-500",
|
|
258
|
+
string: "text-green-500",
|
|
259
|
+
symbol: "text-green-500",
|
|
260
|
+
tag: "text-red-500",
|
|
261
|
+
url: "text-blue-500",
|
|
262
|
+
variable: "text-blue-500"
|
|
263
|
+
},
|
|
264
|
+
quote: "border-l-4 border-border pl-4 italic"
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// src/MarkdownEditor.tsx
|
|
268
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
269
|
+
var SYNC_TRANSFORMERS = [CHECK_LIST, ...TRANSFORMERS];
|
|
270
|
+
var SHORTCUT_TRANSFORMERS = TRANSFORMERS.filter((t) => t !== CODE);
|
|
271
|
+
function onError(error) {
|
|
272
|
+
console.error(error);
|
|
273
|
+
}
|
|
274
|
+
var editorNodes = [
|
|
275
|
+
HeadingNode,
|
|
276
|
+
ListNode2,
|
|
277
|
+
ListItemNode,
|
|
278
|
+
QuoteNode,
|
|
279
|
+
CodeNode,
|
|
280
|
+
CodeHighlightNode,
|
|
281
|
+
AutoLinkNode,
|
|
282
|
+
LinkNode
|
|
283
|
+
];
|
|
284
|
+
function ReadOnlyPlugin({ readOnly }) {
|
|
285
|
+
const [editor] = useLexicalComposerContext5();
|
|
286
|
+
useEffect5(() => {
|
|
287
|
+
editor.setEditable(!readOnly);
|
|
288
|
+
}, [editor, readOnly]);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function MarkdownEditor({
|
|
292
|
+
value,
|
|
293
|
+
onChange,
|
|
294
|
+
placeholder = "Start writing...",
|
|
295
|
+
readOnly = false,
|
|
296
|
+
className,
|
|
297
|
+
mode = "edit-md",
|
|
298
|
+
autoFocus = false
|
|
299
|
+
}) {
|
|
300
|
+
const latestValueRef = useRef2(value ?? "");
|
|
301
|
+
const [mountKey, setMountKey] = useState(0);
|
|
302
|
+
const [capturedMarkdown, setCapturedMarkdown] = useState(value ?? "");
|
|
303
|
+
useEffect5(() => {
|
|
304
|
+
if (value !== void 0) {
|
|
305
|
+
latestValueRef.current = value;
|
|
306
|
+
}
|
|
307
|
+
}, [value]);
|
|
308
|
+
const handleTextChange = useCallback(
|
|
309
|
+
(e) => {
|
|
310
|
+
const newValue = e.target.value;
|
|
311
|
+
latestValueRef.current = newValue;
|
|
312
|
+
onChange?.(newValue);
|
|
313
|
+
},
|
|
314
|
+
[onChange]
|
|
315
|
+
);
|
|
316
|
+
const handleLexicalChange = useCallback(
|
|
317
|
+
(newValue) => {
|
|
318
|
+
latestValueRef.current = newValue;
|
|
319
|
+
onChange?.(newValue);
|
|
320
|
+
},
|
|
321
|
+
[onChange]
|
|
322
|
+
);
|
|
323
|
+
const prevModeRef = useRef2(mode);
|
|
324
|
+
useEffect5(() => {
|
|
325
|
+
if (prevModeRef.current === "edit-raw" && mode !== "edit-raw") {
|
|
326
|
+
setCapturedMarkdown(latestValueRef.current);
|
|
327
|
+
setMountKey((k) => k + 1);
|
|
328
|
+
}
|
|
329
|
+
prevModeRef.current = mode;
|
|
330
|
+
}, [mode]);
|
|
331
|
+
const initialConfig = useMemo(
|
|
332
|
+
() => ({
|
|
333
|
+
namespace: "ZuiTextEditor",
|
|
334
|
+
theme: editorTheme,
|
|
335
|
+
onError,
|
|
336
|
+
nodes: editorNodes
|
|
337
|
+
}),
|
|
338
|
+
[]
|
|
339
|
+
);
|
|
340
|
+
if (mode === "edit-raw") {
|
|
341
|
+
return /* @__PURE__ */ jsx("div", { className: `zui-text-editor ${className ?? ""}`, children: /* @__PURE__ */ jsx(
|
|
342
|
+
"textarea",
|
|
343
|
+
{
|
|
344
|
+
className: "zui-text-editor-textarea",
|
|
345
|
+
value: value ?? "",
|
|
346
|
+
onChange: handleTextChange,
|
|
347
|
+
placeholder,
|
|
348
|
+
readOnly,
|
|
349
|
+
autoFocus,
|
|
350
|
+
spellCheck: false
|
|
351
|
+
}
|
|
352
|
+
) });
|
|
353
|
+
}
|
|
354
|
+
return /* @__PURE__ */ jsx(LexicalComposer, { initialConfig, children: /* @__PURE__ */ jsxs("div", { className: `zui-text-editor ${className ?? ""}`, children: [
|
|
355
|
+
/* @__PURE__ */ jsx(
|
|
356
|
+
RichTextPlugin,
|
|
357
|
+
{
|
|
358
|
+
contentEditable: /* @__PURE__ */ jsx(
|
|
359
|
+
ContentEditable,
|
|
360
|
+
{
|
|
361
|
+
className: "zui-text-editor-content",
|
|
362
|
+
"aria-placeholder": placeholder,
|
|
363
|
+
placeholder: /* @__PURE__ */ jsx("div", { className: "zui-text-editor-placeholder", children: placeholder })
|
|
364
|
+
}
|
|
365
|
+
),
|
|
366
|
+
ErrorBoundary: LexicalErrorBoundary
|
|
367
|
+
}
|
|
368
|
+
),
|
|
369
|
+
/* @__PURE__ */ jsx(
|
|
370
|
+
MarkdownSyncPlugin,
|
|
371
|
+
{
|
|
372
|
+
initialMarkdown: mode === "view" ? value ?? "" : capturedMarkdown,
|
|
373
|
+
onChange: handleLexicalChange,
|
|
374
|
+
transformers: SYNC_TRANSFORMERS
|
|
375
|
+
}
|
|
376
|
+
),
|
|
377
|
+
/* @__PURE__ */ jsx(HistoryPlugin, {}),
|
|
378
|
+
/* @__PURE__ */ jsx(ListPlugin, {}),
|
|
379
|
+
/* @__PURE__ */ jsx(CheckListPlugin, {}),
|
|
380
|
+
/* @__PURE__ */ jsx(ChecklistShortcutPlugin, {}),
|
|
381
|
+
/* @__PURE__ */ jsx(CodeBlockShortcutPlugin, {}),
|
|
382
|
+
/* @__PURE__ */ jsx(CodeHighlightPlugin, {}),
|
|
383
|
+
/* @__PURE__ */ jsx(LinkPlugin, {}),
|
|
384
|
+
/* @__PURE__ */ jsx(MarkdownShortcutPlugin, { transformers: SHORTCUT_TRANSFORMERS }),
|
|
385
|
+
/* @__PURE__ */ jsx(ReadOnlyPlugin, { readOnly: readOnly || mode === "view" }),
|
|
386
|
+
autoFocus && /* @__PURE__ */ jsx(AutoFocusPlugin, {})
|
|
387
|
+
] }) }, mountKey);
|
|
388
|
+
}
|
|
389
|
+
export {
|
|
390
|
+
MarkdownEditor
|
|
391
|
+
};
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
.zui-text-editor {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
overflow-y: auto;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.zui-text-editor-content {
|
|
9
|
+
min-height: 100%;
|
|
10
|
+
padding: 1.5rem 2rem;
|
|
11
|
+
outline: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.zui-text-editor-placeholder {
|
|
15
|
+
position: absolute;
|
|
16
|
+
top: 1.5rem;
|
|
17
|
+
left: 2rem;
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
user-select: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Checklist styles */
|
|
23
|
+
.zui-checklist {
|
|
24
|
+
list-style: none;
|
|
25
|
+
margin-left: 0;
|
|
26
|
+
padding-left: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.zui-checklist li[role='checkbox'] {
|
|
30
|
+
position: relative;
|
|
31
|
+
padding-left: 1.75rem;
|
|
32
|
+
margin-left: 0;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
list-style: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.zui-checklist li[role='checkbox']::before {
|
|
38
|
+
content: '';
|
|
39
|
+
position: absolute;
|
|
40
|
+
left: 0;
|
|
41
|
+
top: 0.25em;
|
|
42
|
+
width: 1rem;
|
|
43
|
+
height: 1rem;
|
|
44
|
+
border: 2px solid currentColor;
|
|
45
|
+
border-radius: 0.2rem;
|
|
46
|
+
opacity: 0.5;
|
|
47
|
+
background: transparent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.zui-checklist li[role='checkbox'][aria-checked='true']::before {
|
|
51
|
+
background: currentColor;
|
|
52
|
+
opacity: 0.5;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.zui-checklist li[role='checkbox'][aria-checked='true']::after {
|
|
56
|
+
content: '';
|
|
57
|
+
position: absolute;
|
|
58
|
+
left: 0.3rem;
|
|
59
|
+
top: 0.35em;
|
|
60
|
+
width: 0.4rem;
|
|
61
|
+
height: 0.7rem;
|
|
62
|
+
border: solid white;
|
|
63
|
+
border-width: 0 2px 2px 0;
|
|
64
|
+
transform: rotate(45deg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.zui-text-editor-textarea {
|
|
68
|
+
width: 100%;
|
|
69
|
+
height: 100%;
|
|
70
|
+
min-height: 100%;
|
|
71
|
+
padding: 1.5rem 2rem;
|
|
72
|
+
outline: none;
|
|
73
|
+
resize: none;
|
|
74
|
+
background: transparent;
|
|
75
|
+
color: inherit;
|
|
76
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
|
77
|
+
'Liberation Mono', monospace;
|
|
78
|
+
font-size: 0.875rem;
|
|
79
|
+
line-height: 1.6;
|
|
80
|
+
border: none;
|
|
81
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zuilib/text-editor",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "ZUI — A markdown editor component wrapping Lexical",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"sideEffects": ["**/*.css"],
|
|
9
|
+
"files": ["dist"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./styles.css": "./dist/styles.css",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
23
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
24
|
+
"lexical": "^0.35.0",
|
|
25
|
+
"@lexical/react": "^0.35.0",
|
|
26
|
+
"@lexical/markdown": "^0.35.0",
|
|
27
|
+
"@lexical/rich-text": "^0.35.0",
|
|
28
|
+
"@lexical/code": "^0.35.0",
|
|
29
|
+
"@lexical/list": "^0.35.0",
|
|
30
|
+
"@lexical/link": "^0.35.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zuilib/core": "^0.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "^18.0.0 || ^19.0.0",
|
|
37
|
+
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
|
38
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
39
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
40
|
+
"lexical": "^0.35.0",
|
|
41
|
+
"@lexical/react": "^0.35.0",
|
|
42
|
+
"@lexical/markdown": "^0.35.0",
|
|
43
|
+
"@lexical/rich-text": "^0.35.0",
|
|
44
|
+
"@lexical/code": "^0.35.0",
|
|
45
|
+
"@lexical/list": "^0.35.0",
|
|
46
|
+
"@lexical/link": "^0.35.0"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"lexical",
|
|
50
|
+
"markdown",
|
|
51
|
+
"editor",
|
|
52
|
+
"zui",
|
|
53
|
+
"design-system",
|
|
54
|
+
"react",
|
|
55
|
+
"typescript"
|
|
56
|
+
]
|
|
57
|
+
}
|