@supatype/ui 0.1.0-alpha.9

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 (50) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/dist/Badge.d.ts +6 -0
  4. package/dist/Badge.d.ts.map +1 -0
  5. package/dist/Badge.js +12 -0
  6. package/dist/Badge.js.map +1 -0
  7. package/dist/Button.d.ts +7 -0
  8. package/dist/Button.d.ts.map +1 -0
  9. package/dist/Button.js +17 -0
  10. package/dist/Button.js.map +1 -0
  11. package/dist/Card.d.ts +6 -0
  12. package/dist/Card.d.ts.map +1 -0
  13. package/dist/Card.js +12 -0
  14. package/dist/Card.js.map +1 -0
  15. package/dist/Input.d.ts +7 -0
  16. package/dist/Input.d.ts.map +1 -0
  17. package/dist/Input.js +9 -0
  18. package/dist/Input.js.map +1 -0
  19. package/dist/RichTextEditor.d.ts +21 -0
  20. package/dist/RichTextEditor.d.ts.map +1 -0
  21. package/dist/RichTextEditor.js +100 -0
  22. package/dist/RichTextEditor.js.map +1 -0
  23. package/dist/Skeleton.d.ts +3 -0
  24. package/dist/Skeleton.d.ts.map +1 -0
  25. package/dist/Skeleton.js +6 -0
  26. package/dist/Skeleton.js.map +1 -0
  27. package/dist/ThemeProvider.d.ts +14 -0
  28. package/dist/ThemeProvider.d.ts.map +1 -0
  29. package/dist/ThemeProvider.js +45 -0
  30. package/dist/ThemeProvider.js.map +1 -0
  31. package/dist/index.d.ts +15 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +10 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/richtext-utils.d.ts +13 -0
  36. package/dist/richtext-utils.d.ts.map +1 -0
  37. package/dist/richtext-utils.js +109 -0
  38. package/dist/richtext-utils.js.map +1 -0
  39. package/package.json +53 -0
  40. package/src/Badge.tsx +27 -0
  41. package/src/Button.tsx +36 -0
  42. package/src/Card.tsx +29 -0
  43. package/src/Input.tsx +37 -0
  44. package/src/RichTextEditor.tsx +206 -0
  45. package/src/Skeleton.tsx +11 -0
  46. package/src/ThemeProvider.tsx +64 -0
  47. package/src/index.ts +22 -0
  48. package/src/richtext-utils.ts +112 -0
  49. package/tsconfig.json +11 -0
  50. package/tsconfig.tsbuildinfo +1 -0
package/src/Badge.tsx ADDED
@@ -0,0 +1,27 @@
1
+ import React from "react"
2
+ import { clsx } from "clsx"
3
+
4
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
5
+ variant?: "default" | "success" | "warning" | "danger" | "purple"
6
+ }
7
+
8
+ export const Badge: React.FC<BadgeProps> = ({ variant = "default", className, children, ...props }) => {
9
+ return (
10
+ <span
11
+ className={clsx(
12
+ "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
13
+ {
14
+ "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300": variant === "default",
15
+ "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300": variant === "success",
16
+ "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300": variant === "warning",
17
+ "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300": variant === "danger",
18
+ "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300": variant === "purple",
19
+ },
20
+ className,
21
+ )}
22
+ {...props}
23
+ >
24
+ {children}
25
+ </span>
26
+ )
27
+ }
package/src/Button.tsx ADDED
@@ -0,0 +1,36 @@
1
+ import React from "react"
2
+ import { clsx } from "clsx"
3
+
4
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ variant?: "primary" | "secondary" | "ghost" | "danger"
6
+ size?: "sm" | "md" | "lg"
7
+ }
8
+
9
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
10
+ ({ variant = "primary", size = "md", className, children, ...props }, ref) => {
11
+ return (
12
+ <button
13
+ ref={ref}
14
+ className={clsx(
15
+ "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
16
+ {
17
+ "bg-purple-600 text-white hover:bg-purple-700": variant === "primary",
18
+ "border border-neutral-300 bg-white text-neutral-900 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800": variant === "secondary",
19
+ "text-neutral-600 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800": variant === "ghost",
20
+ "bg-red-600 text-white hover:bg-red-700": variant === "danger",
21
+ },
22
+ {
23
+ "h-8 px-3 text-sm": size === "sm",
24
+ "h-10 px-4 text-sm": size === "md",
25
+ "h-12 px-6 text-base": size === "lg",
26
+ },
27
+ className,
28
+ )}
29
+ {...props}
30
+ >
31
+ {children}
32
+ </button>
33
+ )
34
+ },
35
+ )
36
+ Button.displayName = "Button"
package/src/Card.tsx ADDED
@@ -0,0 +1,29 @@
1
+ import React from "react"
2
+ import { clsx } from "clsx"
3
+
4
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ padding?: "sm" | "md" | "lg"
6
+ }
7
+
8
+ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
9
+ ({ padding = "md", className, children, ...props }, ref) => {
10
+ return (
11
+ <div
12
+ ref={ref}
13
+ className={clsx(
14
+ "rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900",
15
+ {
16
+ "p-4": padding === "sm",
17
+ "p-6": padding === "md",
18
+ "p-8": padding === "lg",
19
+ },
20
+ className,
21
+ )}
22
+ {...props}
23
+ >
24
+ {children}
25
+ </div>
26
+ )
27
+ },
28
+ )
29
+ Card.displayName = "Card"
package/src/Input.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import React from "react"
2
+ import { clsx } from "clsx"
3
+
4
+ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
5
+ label?: string
6
+ error?: string
7
+ }
8
+
9
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
10
+ ({ label, error, className, id, ...props }, ref) => {
11
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-")
12
+ return (
13
+ <div className="flex flex-col gap-1.5">
14
+ {label && (
15
+ <label htmlFor={inputId} className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
16
+ {label}
17
+ </label>
18
+ )}
19
+ <input
20
+ ref={ref}
21
+ id={inputId}
22
+ className={clsx(
23
+ "h-10 rounded-lg border px-3 text-sm transition-colors",
24
+ "border-neutral-300 bg-white text-neutral-900 placeholder:text-neutral-500",
25
+ "dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500",
26
+ "focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500",
27
+ error && "border-red-500 focus:border-red-500 focus:ring-red-500",
28
+ className,
29
+ )}
30
+ {...props}
31
+ />
32
+ {error && <p className="text-sm text-red-500">{error}</p>}
33
+ </div>
34
+ )
35
+ },
36
+ )
37
+ Input.displayName = "Input"
@@ -0,0 +1,206 @@
1
+ "use client"
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef } from "react"
4
+ import type { SerializedEditorState } from "@supatype/types/lexical"
5
+ import { LexicalComposer } from "@lexical/react/LexicalComposer"
6
+ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
7
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable"
8
+ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
9
+ import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
10
+ import { ListPlugin } from "@lexical/react/LexicalListPlugin"
11
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
12
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
13
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
14
+ import {
15
+ $getSelection,
16
+ $isRangeSelection,
17
+ FORMAT_TEXT_COMMAND,
18
+ type EditorState,
19
+ type LexicalEditor,
20
+ type TextFormatType,
21
+ } from "lexical"
22
+ import { $createHeadingNode, HeadingNode, QuoteNode } from "@lexical/rich-text"
23
+ import { ListNode, ListItemNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
24
+ import { AutoLinkNode, LinkNode } from "@lexical/link"
25
+ import { $setBlocksType } from "@lexical/selection"
26
+ import { $createParagraphNode, $getRoot } from "lexical"
27
+ import clsx from "clsx"
28
+
29
+ const EDITOR_NODES = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode]
30
+
31
+ const EDITOR_THEME = {
32
+ heading: { h1: "su-rt-h1", h2: "su-rt-h2", h3: "su-rt-h3" },
33
+ text: { bold: "su-rt-bold", italic: "su-rt-italic", underline: "su-rt-underline", strikethrough: "su-rt-strikethrough" },
34
+ list: { ul: "su-rt-ul", ol: "su-rt-ol", listitem: "su-rt-li" },
35
+ link: "su-rt-link",
36
+ paragraph: "su-rt-p",
37
+ }
38
+
39
+ function ToolbarPlugin(): React.ReactElement {
40
+ const [editor] = useLexicalComposerContext()
41
+
42
+ const format = useCallback((fmt: TextFormatType) => {
43
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, fmt)
44
+ }, [editor])
45
+
46
+ const setHeading = useCallback((tag: "h1" | "h2" | "h3") => {
47
+ editor.update(() => {
48
+ const selection = $getSelection()
49
+ if ($isRangeSelection(selection)) {
50
+ $setBlocksType(selection, () => $createHeadingNode(tag))
51
+ }
52
+ })
53
+ }, [editor])
54
+
55
+ const setParagraph = useCallback(() => {
56
+ editor.update(() => {
57
+ const selection = $getSelection()
58
+ if ($isRangeSelection(selection)) {
59
+ $setBlocksType(selection, () => $createParagraphNode())
60
+ }
61
+ })
62
+ }, [editor])
63
+
64
+ return (
65
+ <div className="su-richtext-toolbar" role="toolbar" aria-label="Formatting">
66
+ <button type="button" title="Bold" onMouseDown={(e) => { e.preventDefault(); format("bold") }}>
67
+ B
68
+ </button>
69
+ <button type="button" title="Italic" onMouseDown={(e) => { e.preventDefault(); format("italic") }}>
70
+ I
71
+ </button>
72
+ <button type="button" title="Underline" onMouseDown={(e) => { e.preventDefault(); format("underline") }}>
73
+ U
74
+ </button>
75
+ <span className="su-richtext-toolbar-divider" aria-hidden />
76
+ <button type="button" title="Heading 1" onMouseDown={(e) => { e.preventDefault(); setHeading("h1") }}>
77
+ H1
78
+ </button>
79
+ <button type="button" title="Heading 2" onMouseDown={(e) => { e.preventDefault(); setHeading("h2") }}>
80
+ H2
81
+ </button>
82
+ <button type="button" title="Paragraph" onMouseDown={(e) => { e.preventDefault(); setParagraph() }}>
83
+
84
+ </button>
85
+ <span className="su-richtext-toolbar-divider" aria-hidden />
86
+ <button
87
+ type="button"
88
+ title="Bullet list"
89
+ onMouseDown={(e) => {
90
+ e.preventDefault()
91
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
92
+ }}
93
+ >
94
+
95
+ </button>
96
+ <button
97
+ type="button"
98
+ title="Numbered list"
99
+ onMouseDown={(e) => {
100
+ e.preventDefault()
101
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
102
+ }}
103
+ >
104
+ 1.
105
+ </button>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ function InitialStatePlugin({ value }: { value: SerializedEditorState | null | undefined }): null {
111
+ const [editor] = useLexicalComposerContext()
112
+ const loaded = useRef(false)
113
+
114
+ useEffect(() => {
115
+ if (loaded.current) return
116
+ loaded.current = true
117
+ if (value !== null && value !== undefined && typeof value === "object") {
118
+ try {
119
+ const state = editor.parseEditorState(JSON.stringify(value))
120
+ editor.setEditorState(state)
121
+ } catch {
122
+ editor.update(() => {
123
+ const root = $getRoot()
124
+ root.clear()
125
+ root.append($createParagraphNode())
126
+ })
127
+ }
128
+ }
129
+ }, [editor, value])
130
+
131
+ return null
132
+ }
133
+
134
+ export interface RichTextEditorProps {
135
+ /** Initial document (Lexical JSON). Updates only on mount; use `documentKey` to reload. */
136
+ value?: SerializedEditorState | null | undefined
137
+ onChange: (json: SerializedEditorState) => void
138
+ placeholder?: string | undefined
139
+ className?: string | undefined
140
+ editable?: boolean | undefined
141
+ /** Change when switching documents so Lexical remounts with new `value`. */
142
+ documentKey?: string | undefined
143
+ /** `id` on the editable surface (e.g. for `<label htmlFor>`). */
144
+ contentEditableId?: string | undefined
145
+ }
146
+
147
+ /**
148
+ * Lexical-based rich text editor aligned with `@supatype/types` `RichText` / `SerializedEditorState`.
149
+ *
150
+ * Peer dependencies: `lexical`, `@lexical/react`, `@lexical/rich-text`, `@lexical/list`, `@lexical/link`, `@lexical/selection`.
151
+ */
152
+ export function RichTextEditor({
153
+ value,
154
+ onChange,
155
+ placeholder = "Write something…",
156
+ className,
157
+ editable = true,
158
+ documentKey = "default",
159
+ contentEditableId,
160
+ }: RichTextEditorProps): React.ReactElement {
161
+ const initialConfig = useMemo(
162
+ () => ({
163
+ namespace: `supatype-richtext-${documentKey}`,
164
+ theme: EDITOR_THEME,
165
+ nodes: EDITOR_NODES,
166
+ editable,
167
+ onError: (err: Error) => {
168
+ console.error("[RichTextEditor]", err)
169
+ },
170
+ }),
171
+ [documentKey, editable],
172
+ )
173
+
174
+ const handleChange = useCallback(
175
+ (editorState: EditorState, _editor: LexicalEditor) => {
176
+ onChange(editorState.toJSON() as SerializedEditorState)
177
+ },
178
+ [onChange],
179
+ )
180
+
181
+ return (
182
+ <div className={clsx("su-richtext", className, !editable && "su-richtext-readonly")}>
183
+ <LexicalComposer key={documentKey} initialConfig={initialConfig}>
184
+ {editable ? <ToolbarPlugin /> : null}
185
+ <InitialStatePlugin value={value} />
186
+ <div className="su-richtext-surface">
187
+ <RichTextPlugin
188
+ contentEditable={
189
+ <ContentEditable
190
+ id={contentEditableId}
191
+ className="su-richtext-editable"
192
+ aria-label={placeholder}
193
+ />
194
+ }
195
+ placeholder={<div className="su-richtext-placeholder">{placeholder}</div>}
196
+ ErrorBoundary={LexicalErrorBoundary}
197
+ />
198
+ </div>
199
+ <HistoryPlugin />
200
+ <ListPlugin />
201
+ <LinkPlugin />
202
+ <OnChangePlugin onChange={handleChange} ignoreSelectionChange />
203
+ </LexicalComposer>
204
+ </div>
205
+ )
206
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react"
2
+ import { clsx } from "clsx"
3
+
4
+ export const Skeleton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => {
5
+ return (
6
+ <div
7
+ className={clsx("animate-pulse rounded-md bg-neutral-200 dark:bg-neutral-800", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
@@ -0,0 +1,64 @@
1
+ import React, { createContext, useContext, useEffect, useState } from "react"
2
+
3
+ type Theme = "light" | "dark" | "system"
4
+
5
+ interface ThemeContextValue {
6
+ theme: Theme
7
+ resolvedTheme: "light" | "dark"
8
+ setTheme: (theme: Theme) => void
9
+ }
10
+
11
+ const ThemeContext = createContext<ThemeContextValue | null>(null)
12
+
13
+ export function useTheme(): ThemeContextValue {
14
+ const ctx = useContext(ThemeContext)
15
+ if (!ctx) throw new Error("useTheme must be used within a ThemeProvider")
16
+ return ctx
17
+ }
18
+
19
+ function getSystemTheme(): "light" | "dark" {
20
+ if (typeof window === "undefined") return "dark"
21
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
22
+ }
23
+
24
+ export const ThemeProvider: React.FC<{ defaultTheme?: Theme; children: React.ReactNode }> = ({
25
+ defaultTheme = "system",
26
+ children,
27
+ }) => {
28
+ const [theme, setThemeState] = useState<Theme>(() => {
29
+ if (typeof window === "undefined") return defaultTheme
30
+ return (localStorage.getItem("supatype-theme") as Theme) ?? defaultTheme
31
+ })
32
+ const [resolvedTheme, setResolved] = useState<"light" | "dark">(() =>
33
+ theme === "system" ? getSystemTheme() : theme,
34
+ )
35
+
36
+ useEffect(() => {
37
+ const resolved = theme === "system" ? getSystemTheme() : theme
38
+ setResolved(resolved)
39
+ document.documentElement.classList.toggle("dark", resolved === "dark")
40
+ }, [theme])
41
+
42
+ useEffect(() => {
43
+ if (theme !== "system") return
44
+ const mq = window.matchMedia("(prefers-color-scheme: dark)")
45
+ const handler = () => {
46
+ const resolved = getSystemTheme()
47
+ setResolved(resolved)
48
+ document.documentElement.classList.toggle("dark", resolved === "dark")
49
+ }
50
+ mq.addEventListener("change", handler)
51
+ return () => mq.removeEventListener("change", handler)
52
+ }, [theme])
53
+
54
+ const setTheme = (t: Theme) => {
55
+ setThemeState(t)
56
+ localStorage.setItem("supatype-theme", t)
57
+ }
58
+
59
+ return (
60
+ <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
61
+ {children}
62
+ </ThemeContext.Provider>
63
+ )
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // @supatype/ui — Shared component library
2
+
3
+ export { Button } from "./Button.js"
4
+ export type { ButtonProps } from "./Button.js"
5
+
6
+ export { Input } from "./Input.js"
7
+ export type { InputProps } from "./Input.js"
8
+
9
+ export { Card } from "./Card.js"
10
+ export type { CardProps } from "./Card.js"
11
+
12
+ export { Badge } from "./Badge.js"
13
+ export type { BadgeProps } from "./Badge.js"
14
+
15
+ export { Skeleton } from "./Skeleton.js"
16
+
17
+ export { ThemeProvider, useTheme } from "./ThemeProvider.js"
18
+
19
+ export { RichTextEditor } from "./RichTextEditor.js"
20
+ export type { RichTextEditorProps } from "./RichTextEditor.js"
21
+ export { emptyRichTextDocument, normalizeRichTextDefault, richTextIsEmpty, stringToRichTextDocument } from "./richtext-utils.js"
22
+ export type { SerializedEditorState, SerializedLexicalNode } from "@supatype/types/lexical"
@@ -0,0 +1,112 @@
1
+ import type { SerializedEditorState, SerializedLexicalNode } from "@supatype/types/lexical"
2
+
3
+ /** Lexical-compatible empty document (one empty paragraph). */
4
+ export function emptyRichTextDocument(): SerializedEditorState {
5
+ return {
6
+ root: {
7
+ type: "root",
8
+ format: "",
9
+ indent: 0,
10
+ version: 1,
11
+ children: [
12
+ {
13
+ type: "paragraph",
14
+ format: "",
15
+ indent: 0,
16
+ version: 1,
17
+ children: [
18
+ {
19
+ type: "text",
20
+ text: "",
21
+ format: 0,
22
+ style: "",
23
+ mode: "normal",
24
+ detail: 0,
25
+ version: 1,
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ },
31
+ }
32
+ }
33
+
34
+ /** Plain sentence → minimal Lexical document (single paragraph). Not HTML — literal text only. */
35
+ export function stringToRichTextDocument(text: string): SerializedEditorState {
36
+ return {
37
+ root: {
38
+ type: "root",
39
+ format: "",
40
+ indent: 0,
41
+ version: 1,
42
+ children: [
43
+ {
44
+ type: "paragraph",
45
+ format: "",
46
+ indent: 0,
47
+ version: 1,
48
+ children: [
49
+ {
50
+ type: "text",
51
+ text,
52
+ format: 0,
53
+ style: "",
54
+ mode: "normal",
55
+ detail: 0,
56
+ version: 1,
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ }
63
+ }
64
+
65
+ function isLexicalDocument(value: unknown): value is SerializedEditorState {
66
+ return (
67
+ typeof value === "object" &&
68
+ value !== null &&
69
+ "root" in value &&
70
+ typeof (value as SerializedEditorState).root === "object"
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Normalize a RichText default or seed value: Lexical object passthrough, JSON Lexical string, or plain string → Lexical.
76
+ * Does not parse HTML.
77
+ */
78
+ export function normalizeRichTextDefault(
79
+ value: SerializedEditorState | string | null | undefined,
80
+ ): SerializedEditorState | null {
81
+ if (value === null || value === undefined) return null
82
+ if (isLexicalDocument(value)) return value
83
+ if (typeof value === "string") {
84
+ const trimmed = value.trim()
85
+ if (trimmed.startsWith("{")) {
86
+ try {
87
+ const parsed = JSON.parse(trimmed) as unknown
88
+ if (isLexicalDocument(parsed)) return parsed
89
+ } catch {
90
+ // fall through — treat as plain text
91
+ }
92
+ }
93
+ return stringToRichTextDocument(value)
94
+ }
95
+ return null
96
+ }
97
+
98
+ function collectText(node: SerializedLexicalNode): string {
99
+ let out = ""
100
+ if (typeof node.text === "string") out += node.text
101
+ const children = node.children
102
+ if (Array.isArray(children)) {
103
+ for (const c of children) out += collectText(c as SerializedLexicalNode)
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** True when the editor has no visible text (whitespace-only counts as empty). */
109
+ export function richTextIsEmpty(state: SerializedEditorState | null | undefined): boolean {
110
+ if (state === null || state === undefined) return true
111
+ return collectText(state.root).trim() === ""
112
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "composite": true,
7
+ "jsx": "react-jsx",
8
+ "lib": ["ES2022", "DOM"]
9
+ },
10
+ "include": ["src"]
11
+ }