@thinhnguyencth1204/nextcli 0.7.0 → 0.8.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.
Files changed (33) hide show
  1. package/README.md +12 -2
  2. package/dist/cli.js +3 -3
  3. package/package.json +5 -3
  4. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  5. package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
  6. package/templates/next-base/bun.lock +59 -414
  7. package/templates/next-base/next-env.d.ts +1 -1
  8. package/templates/next-base/package.json +5 -0
  9. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  10. package/templates/next-base/src/app/globals.css +57 -0
  11. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  12. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  13. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  14. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  15. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  16. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  17. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  18. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  19. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  20. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  21. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  22. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  23. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  24. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  25. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  26. package/templates/next-base/src/hooks/index.ts +1 -1
  27. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  28. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  29. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  30. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  31. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  32. package/templates/next-base/src/types/index.ts +0 -2
  33. package/templates/next-base/tsconfig.tsbuildinfo +1 -0
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -23,6 +23,11 @@
23
23
  "@radix-ui/react-tooltip": "^1.2.8",
24
24
  "class-variance-authority": "^0.7.0",
25
25
  "clsx": "^2.1.1",
26
+ "@lexical/history": "^0.45.0",
27
+ "@lexical/react": "^0.45.0",
28
+ "@lexical/rich-text": "^0.45.0",
29
+ "@lexical/utils": "^0.45.0",
30
+ "lexical": "^0.45.0",
26
31
  "lucide-react": "^0.525.0",
27
32
  "next": "^16.1.6",
28
33
  "next-themes": "^0.4.6",
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { BlogRichTextDemo } from "@/components/rich-text/examples/blog-rich-text-demo";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export default function BlogDemoPage() {
8
+ return <BlogRichTextDemo />;
9
+ }
@@ -109,3 +109,60 @@
109
109
  @apply bg-background text-foreground antialiased;
110
110
  }
111
111
  }
112
+
113
+ /* Lexical rich text */
114
+ .lexical-content-editable {
115
+ position: relative;
116
+ }
117
+
118
+ .lexical-paragraph {
119
+ @apply mb-2;
120
+ }
121
+
122
+ .lexical-text-bold {
123
+ @apply font-bold;
124
+ }
125
+
126
+ .lexical-text-italic {
127
+ @apply italic;
128
+ }
129
+
130
+ .lexical-text-underline {
131
+ @apply underline;
132
+ }
133
+
134
+ .lexical-text-strikethrough {
135
+ @apply line-through;
136
+ }
137
+
138
+ .lexical-text-underline-strikethrough {
139
+ @apply underline line-through;
140
+ }
141
+
142
+ .lexical-text-code {
143
+ @apply bg-muted rounded px-1 font-mono text-[0.9em];
144
+ }
145
+
146
+ .lexical-heading-h1 {
147
+ @apply mb-3 text-3xl font-bold;
148
+ }
149
+
150
+ .lexical-heading-h2 {
151
+ @apply mb-2 text-2xl font-semibold;
152
+ }
153
+
154
+ .lexical-heading-h3 {
155
+ @apply mb-2 text-xl font-semibold;
156
+ }
157
+
158
+ .lexical-quote {
159
+ @apply border-muted-foreground mb-2 border-l-4 pl-4 italic;
160
+ }
161
+
162
+ .lexical-image-element {
163
+ @apply my-2 block;
164
+ }
165
+
166
+ .lexical-renderer .lexical-content-editable {
167
+ min-height: auto;
168
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { Textarea } from "@/components/ui/textarea";
4
+ import { cn } from "@/utils/cn";
5
+ import type { TextareaEditorContent } from "../types";
6
+
7
+ export type TextareaFieldAdapterProps = {
8
+ value: TextareaEditorContent | null;
9
+ onChange: (value: TextareaEditorContent) => void;
10
+ placeholder?: string;
11
+ disabled?: boolean;
12
+ readOnly?: boolean;
13
+ error?: string;
14
+ className?: string;
15
+ id?: string;
16
+ name?: string;
17
+ };
18
+
19
+ export function TextareaFieldAdapter({
20
+ value,
21
+ onChange,
22
+ placeholder,
23
+ disabled,
24
+ readOnly,
25
+ error,
26
+ className,
27
+ id,
28
+ name,
29
+ }: TextareaFieldAdapterProps) {
30
+ return (
31
+ <div className={cn("space-y-1", className)}>
32
+ <Textarea
33
+ id={id}
34
+ name={name}
35
+ value={value ?? ""}
36
+ onChange={(event) => onChange(event.target.value)}
37
+ placeholder={placeholder}
38
+ disabled={disabled}
39
+ readOnly={readOnly}
40
+ aria-invalid={Boolean(error)}
41
+ className={cn(error && "border-destructive")}
42
+ />
43
+ {error ? (
44
+ <p className="text-destructive text-xs" role="alert">
45
+ {error}
46
+ </p>
47
+ ) : null}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ReactNode } from "react";
4
+
5
+ type ClientOnlyProps = {
6
+ children: ReactNode;
7
+ fallback?: ReactNode;
8
+ };
9
+
10
+ /** Renders children only after mount — required for Lexical (no SSR). */
11
+ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
12
+ const [mounted, setMounted] = useState(false);
13
+
14
+ useEffect(() => {
15
+ setMounted(true);
16
+ }, []);
17
+
18
+ if (!mounted) {
19
+ return fallback;
20
+ }
21
+
22
+ return children;
23
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/utils/cn";
4
+ import type { EditorFieldProps, LexicalEditorContent } from "./types";
5
+ import { isLexicalContent } from "./types";
6
+ import { TextareaFieldAdapter } from "./adapters/textarea-field";
7
+ import { LexicalRichTextEditor } from "./lexical/rich-text-editor";
8
+
9
+ export function EditorField({
10
+ variant = "lexical",
11
+ value,
12
+ onChange,
13
+ onImagesRemoved,
14
+ placeholder,
15
+ disabled,
16
+ readOnly,
17
+ error,
18
+ className,
19
+ id,
20
+ name,
21
+ }: EditorFieldProps) {
22
+ if (variant === "textarea") {
23
+ const stringValue = isLexicalContent(value) ? "" : (value ?? "");
24
+
25
+ return (
26
+ <TextareaFieldAdapter
27
+ id={id}
28
+ name={name}
29
+ value={stringValue}
30
+ onChange={onChange}
31
+ placeholder={placeholder}
32
+ disabled={disabled}
33
+ readOnly={readOnly}
34
+ error={error}
35
+ className={className}
36
+ />
37
+ );
38
+ }
39
+
40
+ const lexicalValue: LexicalEditorContent | null = isLexicalContent(value)
41
+ ? value
42
+ : null;
43
+
44
+ return (
45
+ <div className={cn("space-y-1", className)}>
46
+ <LexicalRichTextEditor
47
+ id={id}
48
+ value={lexicalValue}
49
+ onChange={onChange}
50
+ onImagesRemoved={onImagesRemoved}
51
+ placeholder={placeholder}
52
+ disabled={disabled}
53
+ readOnly={readOnly}
54
+ />
55
+ {error ? (
56
+ <p className="text-destructive text-xs" role="alert">
57
+ {error}
58
+ </p>
59
+ ) : null}
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@/components/ui/select";
15
+ import {
16
+ EditorField,
17
+ RichTextRenderer,
18
+ createEmptyLexicalContent,
19
+ isLexicalContent,
20
+ type EditorContent,
21
+ type EditorVariant,
22
+ type LexicalEditorContent,
23
+ } from "@/components/rich-text";
24
+ import {
25
+ syncRemovedRichTextImages,
26
+ defaultRemoveManagedImage,
27
+ isSupabaseManagedImageUrl,
28
+ parseSupabaseStorageUrl,
29
+ type RemoveManagedImageFn,
30
+ } from "@/lib/rich-text";
31
+
32
+ type BlogPost = {
33
+ id: string;
34
+ title: string;
35
+ content: LexicalEditorContent;
36
+ };
37
+
38
+ /** Swap with `removeSupabaseManagedImage` when the supabase module is installed. */
39
+ const removeManagedImage: RemoveManagedImageFn = defaultRemoveManagedImage;
40
+
41
+ export function BlogRichTextDemo() {
42
+ const [variant, setVariant] = useState<EditorVariant>("lexical");
43
+ const [title, setTitle] = useState("");
44
+ const [draft, setDraft] = useState<EditorContent | null>(
45
+ createEmptyLexicalContent(),
46
+ );
47
+ const [posts, setPosts] = useState<BlogPost[]>([]);
48
+ const [editingId, setEditingId] = useState<string | null>(null);
49
+ const [previousContent, setPreviousContent] =
50
+ useState<LexicalEditorContent | null>(null);
51
+ const [editorKey, setEditorKey] = useState("new");
52
+
53
+ const handleImagesRemoved = useCallback((removedUrls: string[]) => {
54
+ void Promise.all(
55
+ removedUrls.map(async (url) => {
56
+ if (!isSupabaseManagedImageUrl(url)) {
57
+ return;
58
+ }
59
+
60
+ const ref = parseSupabaseStorageUrl(url);
61
+ if (!ref) {
62
+ return;
63
+ }
64
+
65
+ await removeManagedImage(url, ref);
66
+ }),
67
+ );
68
+ }, []);
69
+
70
+ const handleSave = useCallback(async () => {
71
+ if (!title.trim() || !isLexicalContent(draft)) {
72
+ return;
73
+ }
74
+
75
+ if (editingId && previousContent) {
76
+ await syncRemovedRichTextImages(previousContent, draft, {
77
+ removeManagedImage,
78
+ });
79
+ }
80
+
81
+ if (editingId) {
82
+ setPosts((current) =>
83
+ current.map((post) =>
84
+ post.id === editingId
85
+ ? { ...post, title: title.trim(), content: draft }
86
+ : post,
87
+ ),
88
+ );
89
+ } else {
90
+ setPosts((current) => [
91
+ {
92
+ id: crypto.randomUUID(),
93
+ title: title.trim(),
94
+ content: draft,
95
+ },
96
+ ...current,
97
+ ]);
98
+ }
99
+
100
+ setTitle("");
101
+ setDraft(createEmptyLexicalContent());
102
+ setPreviousContent(null);
103
+ setEditingId(null);
104
+ setEditorKey(`new-${Date.now()}`);
105
+ }, [draft, editingId, previousContent, title]);
106
+
107
+ const startEdit = useCallback((post: BlogPost) => {
108
+ setEditingId(post.id);
109
+ setTitle(post.title);
110
+ setDraft(post.content);
111
+ setPreviousContent(post.content);
112
+ setEditorKey(post.id);
113
+ }, []);
114
+
115
+ const resetForm = useCallback(() => {
116
+ setEditingId(null);
117
+ setTitle("");
118
+ setDraft(createEmptyLexicalContent());
119
+ setPreviousContent(null);
120
+ setEditorKey(`new-${Date.now()}`);
121
+ }, []);
122
+
123
+ return (
124
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6">
125
+ <div>
126
+ <h1 className="text-2xl font-semibold tracking-tight">
127
+ Blog rich text demo
128
+ </h1>
129
+ <p className="text-muted-foreground mt-1 text-sm">
130
+ Toggle between native textarea and Lexical. Lexical content is stored
131
+ as JSON for API persistence. When the Supabase module is installed,
132
+ removed Supabase image URLs are deleted from storage.
133
+ </p>
134
+ </div>
135
+
136
+ <Card>
137
+ <CardHeader>
138
+ <CardTitle>{editingId ? "Edit post" : "Create post"}</CardTitle>
139
+ </CardHeader>
140
+ <CardContent className="space-y-4">
141
+ <div className="space-y-2">
142
+ <Label htmlFor="editor-variant">Editor type</Label>
143
+ <Select
144
+ value={variant}
145
+ onValueChange={(value) => setVariant(value as EditorVariant)}
146
+ >
147
+ <SelectTrigger id="editor-variant" className="w-48">
148
+ <SelectValue />
149
+ </SelectTrigger>
150
+ <SelectContent>
151
+ <SelectItem value="lexical">Lexical (rich text)</SelectItem>
152
+ <SelectItem value="textarea">Textarea (plain)</SelectItem>
153
+ </SelectContent>
154
+ </Select>
155
+ </div>
156
+
157
+ <div className="space-y-2">
158
+ <Label htmlFor="post-title">Title</Label>
159
+ <Input
160
+ id="post-title"
161
+ value={title}
162
+ onChange={(event) => setTitle(event.target.value)}
163
+ placeholder="Post title"
164
+ />
165
+ </div>
166
+
167
+ <div className="space-y-2">
168
+ <Label>Content</Label>
169
+ <EditorField
170
+ key={editorKey}
171
+ variant={variant}
172
+ value={draft}
173
+ onChange={setDraft}
174
+ onImagesRemoved={handleImagesRemoved}
175
+ placeholder="Write your post..."
176
+ />
177
+ </div>
178
+
179
+ <div className="flex gap-2">
180
+ <Button type="button" onClick={() => void handleSave()}>
181
+ {editingId ? "Update post" : "Save post"}
182
+ </Button>
183
+ {editingId ? (
184
+ <Button type="button" variant="outline" onClick={resetForm}>
185
+ Cancel
186
+ </Button>
187
+ ) : null}
188
+ </div>
189
+ </CardContent>
190
+ </Card>
191
+
192
+ <div className="space-y-4">
193
+ {posts.length === 0 ? (
194
+ <p className="text-muted-foreground text-sm">No posts yet.</p>
195
+ ) : (
196
+ posts.map((post) => (
197
+ <Card key={post.id}>
198
+ <CardHeader className="flex flex-row items-center justify-between gap-4">
199
+ <CardTitle className="text-lg">{post.title}</CardTitle>
200
+ <Button
201
+ type="button"
202
+ variant="outline"
203
+ size="sm"
204
+ onClick={() => startEdit(post)}
205
+ >
206
+ Edit
207
+ </Button>
208
+ </CardHeader>
209
+ <CardContent>
210
+ <RichTextRenderer content={post.content} />
211
+ </CardContent>
212
+ </Card>
213
+ ))
214
+ )}
215
+ </div>
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,11 @@
1
+ export type { EditorFieldProps, EditorVariant, EditorContent } from "./types";
2
+ export {
3
+ createEmptyLexicalContent,
4
+ isLexicalContent,
5
+ type LexicalEditorContent,
6
+ type TextareaEditorContent,
7
+ } from "./types";
8
+ export { EditorField } from "./editor-field";
9
+ export { RichTextRenderer } from "./rich-text-renderer";
10
+ export { LexicalRichTextEditor } from "./lexical/rich-text-editor";
11
+ export { TextareaFieldAdapter } from "./adapters/textarea-field";
@@ -0,0 +1,37 @@
1
+ import { defineExtension, configExtension } from "lexical";
2
+ import { HistoryExtension } from "@lexical/history";
3
+ import { RichTextExtension } from "@lexical/rich-text";
4
+ import { ReactExtension } from "@lexical/react/ReactExtension";
5
+ import { ImageNode } from "./nodes/image-node";
6
+ import { richTextTheme } from "./theme";
7
+
8
+ /** Stable module-scoped extension graph (Lexical migration best practice). */
9
+ export const richTextEditorExtension = defineExtension({
10
+ name: "@nextcli/rich-text-editor",
11
+ namespace: "NextCLIRichText",
12
+ dependencies: [
13
+ RichTextExtension,
14
+ HistoryExtension,
15
+ configExtension(ReactExtension, { contentEditable: null }),
16
+ ],
17
+ nodes: [ImageNode],
18
+ theme: richTextTheme,
19
+ });
20
+
21
+ /** Read-only extension shares nodes/theme with the editor for identical rendering. */
22
+ export const richTextReadOnlyExtension = defineExtension({
23
+ name: "@nextcli/rich-text-readonly",
24
+ namespace: "NextCLIRichTextReadOnly",
25
+ dependencies: [
26
+ RichTextExtension,
27
+ configExtension(ReactExtension, { contentEditable: null }),
28
+ ],
29
+ nodes: [ImageNode],
30
+ theme: richTextTheme,
31
+ register(editor) {
32
+ editor.setEditable(false);
33
+ return () => {
34
+ editor.setEditable(true);
35
+ };
36
+ },
37
+ });
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import type {
4
+ DOMConversionMap,
5
+ DOMConversionOutput,
6
+ DOMExportOutput,
7
+ EditorConfig,
8
+ LexicalNode,
9
+ NodeKey,
10
+ SerializedLexicalNode,
11
+ Spread,
12
+ } from "lexical";
13
+ import {
14
+ $applyNodeReplacement,
15
+ DecoratorNode,
16
+ createCommand,
17
+ type LexicalCommand,
18
+ } from "lexical";
19
+ import type { JSX } from "react";
20
+
21
+ export type SerializedImageNode = Spread<
22
+ {
23
+ altText: string;
24
+ height?: number;
25
+ maxWidth?: number;
26
+ src: string;
27
+ width?: number;
28
+ },
29
+ SerializedLexicalNode
30
+ >;
31
+
32
+ export type InsertImagePayload = {
33
+ altText?: string;
34
+ src: string;
35
+ };
36
+
37
+ export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
38
+ createCommand("INSERT_IMAGE_COMMAND");
39
+
40
+ function convertImageElement(domNode: Node): DOMConversionOutput | null {
41
+ if (!(domNode instanceof HTMLImageElement)) {
42
+ return null;
43
+ }
44
+
45
+ const src = domNode.getAttribute("src");
46
+ if (!src) {
47
+ return null;
48
+ }
49
+
50
+ const altText = domNode.getAttribute("alt") ?? "";
51
+ const node = $createImageNode({ src, altText });
52
+ return { node };
53
+ }
54
+
55
+ export class ImageNode extends DecoratorNode<JSX.Element> {
56
+ __src: string;
57
+ __altText: string;
58
+ __width: number | "inherit";
59
+ __height: number | "inherit";
60
+ __maxWidth: number;
61
+
62
+ static getType(): string {
63
+ return "image";
64
+ }
65
+
66
+ static clone(node: ImageNode): ImageNode {
67
+ return new ImageNode(
68
+ node.__src,
69
+ node.__altText,
70
+ node.__maxWidth,
71
+ node.__width,
72
+ node.__height,
73
+ node.__key,
74
+ );
75
+ }
76
+
77
+ static importJSON(serializedNode: SerializedImageNode): ImageNode {
78
+ return $createImageNode({
79
+ altText: serializedNode.altText,
80
+ height: serializedNode.height,
81
+ maxWidth: serializedNode.maxWidth,
82
+ src: serializedNode.src,
83
+ width: serializedNode.width,
84
+ });
85
+ }
86
+
87
+ static importDOM(): DOMConversionMap | null {
88
+ return {
89
+ img: () => ({
90
+ conversion: convertImageElement,
91
+ priority: 0,
92
+ }),
93
+ };
94
+ }
95
+
96
+ constructor(
97
+ src: string,
98
+ altText: string,
99
+ maxWidth: number,
100
+ width?: number | "inherit",
101
+ height?: number | "inherit",
102
+ key?: NodeKey,
103
+ ) {
104
+ super(key);
105
+ this.__src = src;
106
+ this.__altText = altText;
107
+ this.__maxWidth = maxWidth;
108
+ this.__width = width ?? "inherit";
109
+ this.__height = height ?? "inherit";
110
+ }
111
+
112
+ exportJSON(): SerializedImageNode {
113
+ return {
114
+ altText: this.__altText,
115
+ height: this.__height === "inherit" ? undefined : this.__height,
116
+ maxWidth: this.__maxWidth,
117
+ src: this.__src,
118
+ type: "image",
119
+ version: 1,
120
+ width: this.__width === "inherit" ? undefined : this.__width,
121
+ };
122
+ }
123
+
124
+ exportDOM(): DOMExportOutput {
125
+ const element = document.createElement("img");
126
+ element.setAttribute("src", this.__src);
127
+ element.setAttribute("alt", this.__altText);
128
+ return { element };
129
+ }
130
+
131
+ createDOM(config: EditorConfig): HTMLElement {
132
+ const span = document.createElement("span");
133
+ const theme = config.theme.image;
134
+ if (typeof theme === "string") {
135
+ span.className = theme;
136
+ }
137
+ return span;
138
+ }
139
+
140
+ updateDOM(): false {
141
+ return false;
142
+ }
143
+
144
+ getSrc(): string {
145
+ return this.__src;
146
+ }
147
+
148
+ decorate(): JSX.Element {
149
+ return (
150
+ <img
151
+ src={this.__src}
152
+ alt={this.__altText}
153
+ className="lexical-image-element max-w-full rounded-md"
154
+ draggable={false}
155
+ style={{
156
+ maxWidth: this.__maxWidth,
157
+ width: this.__width === "inherit" ? undefined : this.__width,
158
+ height: this.__height === "inherit" ? undefined : this.__height,
159
+ }}
160
+ />
161
+ );
162
+ }
163
+ }
164
+
165
+ export function $createImageNode({
166
+ altText = "",
167
+ height,
168
+ maxWidth = 800,
169
+ src,
170
+ width,
171
+ }: {
172
+ altText?: string;
173
+ height?: number | "inherit";
174
+ maxWidth?: number;
175
+ src: string;
176
+ width?: number | "inherit";
177
+ }): ImageNode {
178
+ return $applyNodeReplacement(
179
+ new ImageNode(src, altText, maxWidth, width, height),
180
+ );
181
+ }
182
+
183
+ export function $isImageNode(
184
+ node: LexicalNode | null | undefined,
185
+ ): node is ImageNode {
186
+ return node instanceof ImageNode;
187
+ }