sh-ui-cli 0.44.0 → 0.45.1

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.
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect } from "react";
4
+ import { useEditor, EditorContent, type Editor } from "@tiptap/react";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import Placeholder from "@tiptap/extension-placeholder";
7
+ import Link from "@tiptap/extension-link";
8
+ import {
9
+ BoldIcon, ItalicIcon, StrikethroughIcon,
10
+ Heading1Icon, Heading2Icon, Heading3Icon,
11
+ ListIcon, ListOrderedIcon, QuoteIcon,
12
+ CodeIcon, Code2Icon, LinkIcon, MinusIcon,
13
+ Undo2Icon, Redo2Icon,
14
+ } from "lucide-react";
15
+
16
+ export interface RichTextEditorProps {
17
+ value?: string;
18
+ defaultValue?: string;
19
+ onChange?: (html: string) => void;
20
+ placeholder?: string;
21
+ readOnly?: boolean;
22
+ hideToolbar?: boolean;
23
+ minHeight?: string;
24
+ maxHeight?: string;
25
+ className?: string;
26
+ "aria-label"?: string;
27
+ }
28
+
29
+ function cx(...args: (string | undefined | false | null)[]) {
30
+ return args.filter(Boolean).join(" ");
31
+ }
32
+
33
+ export function RichTextEditor({
34
+ value: valueProp, defaultValue, onChange, placeholder, readOnly = false, hideToolbar = false,
35
+ minHeight, maxHeight, className,
36
+ "aria-label": ariaLabel = "Rich text editor",
37
+ }: RichTextEditorProps) {
38
+ const isControlled = valueProp !== undefined;
39
+ const editor = useEditor({
40
+ extensions: [
41
+ StarterKit,
42
+ Placeholder.configure({
43
+ placeholder: placeholder ?? "",
44
+ emptyEditorClass: "sh-ui-rte__is-empty",
45
+ }),
46
+ Link.configure({
47
+ openOnClick: false, autolink: true,
48
+ HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
49
+ }),
50
+ ],
51
+ content: valueProp ?? defaultValue ?? "",
52
+ editable: !readOnly,
53
+ immediatelyRender: false,
54
+ onUpdate: ({ editor }) => { onChange?.(editor.getHTML()); },
55
+ editorProps: {
56
+ attributes: { class: "sh-ui-rte__content", "aria-label": ariaLabel },
57
+ },
58
+ });
59
+
60
+ useEffect(() => {
61
+ if (!isControlled) return;
62
+ if (!editor) return;
63
+ if (editor.getHTML() === valueProp) return;
64
+ editor.commands.setContent(valueProp ?? "", { emitUpdate: false });
65
+ }, [isControlled, valueProp, editor]);
66
+
67
+ useEffect(() => { editor?.setEditable(!readOnly); }, [readOnly, editor]);
68
+
69
+ return (
70
+ <div
71
+ className={cx(
72
+ "sh-ui-rte flex flex-col border border-border rounded-[var(--radius)] bg-background overflow-hidden transition-[border-color] duration-[var(--duration-fast)] focus-within:border-foreground focus-within:outline-[length:var(--border-width-strong)] focus-within:outline-foreground focus-within:outline-offset-2 data-[readonly]:bg-background-subtle motion-reduce:transition-none",
73
+ className,
74
+ )}
75
+ data-readonly={readOnly || undefined}
76
+ style={{ "--sh-ui-rte-min-height": minHeight, "--sh-ui-rte-max-height": maxHeight } as React.CSSProperties}
77
+ >
78
+ {!hideToolbar && <Toolbar editor={editor} disabled={readOnly} />}
79
+ <EditorContent editor={editor} className="sh-ui-rte__viewport" />
80
+ </div>
81
+ );
82
+ }
83
+
84
+ interface ToolbarProps { editor: Editor | null; disabled: boolean; }
85
+
86
+ function Toolbar({ editor, disabled }: ToolbarProps) {
87
+ const promptLink = useCallback(() => {
88
+ if (!editor) return;
89
+ const previous = editor.getAttributes("link").href as string | undefined;
90
+ const url = window.prompt("URL", previous ?? "https://");
91
+ if (url === null) return;
92
+ if (url === "") {
93
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
94
+ return;
95
+ }
96
+ const { empty } = editor.state.selection;
97
+ const inLink = editor.isActive("link");
98
+ if (empty && !inLink) {
99
+ editor.chain().focus().insertContent({
100
+ type: "text", text: url,
101
+ marks: [{ type: "link", attrs: { href: url } }],
102
+ }).run();
103
+ return;
104
+ }
105
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
106
+ }, [editor]);
107
+
108
+ return (
109
+ <div
110
+ className="flex flex-wrap items-center gap-0.5 py-[var(--space-1)] px-[var(--space-2)] bg-background-muted border-b border-border"
111
+ role="toolbar"
112
+ aria-label="Formatting"
113
+ aria-disabled={disabled || undefined}
114
+ >
115
+ <ToolbarButton editor={editor} label="Bold" icon={<BoldIcon size={16} />} isActive={editor?.isActive("bold")} canRun={() => !!editor?.can().toggleBold()} run={() => editor?.chain().focus().toggleBold().run()} disabled={disabled} />
116
+ <ToolbarButton editor={editor} label="Italic" icon={<ItalicIcon size={16} />} isActive={editor?.isActive("italic")} canRun={() => !!editor?.can().toggleItalic()} run={() => editor?.chain().focus().toggleItalic().run()} disabled={disabled} />
117
+ <ToolbarButton editor={editor} label="Strikethrough" icon={<StrikethroughIcon size={16} />} isActive={editor?.isActive("strike")} canRun={() => !!editor?.can().toggleStrike()} run={() => editor?.chain().focus().toggleStrike().run()} disabled={disabled} />
118
+ <ToolbarButton editor={editor} label="Inline code" icon={<CodeIcon size={16} />} isActive={editor?.isActive("code")} canRun={() => !!editor?.can().toggleCode()} run={() => editor?.chain().focus().toggleCode().run()} disabled={disabled} />
119
+
120
+ <ToolbarSeparator />
121
+
122
+ <ToolbarButton editor={editor} label="Heading 1" icon={<Heading1Icon size={16} />} isActive={editor?.isActive("heading", { level: 1 })} run={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={disabled} />
123
+ <ToolbarButton editor={editor} label="Heading 2" icon={<Heading2Icon size={16} />} isActive={editor?.isActive("heading", { level: 2 })} run={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={disabled} />
124
+ <ToolbarButton editor={editor} label="Heading 3" icon={<Heading3Icon size={16} />} isActive={editor?.isActive("heading", { level: 3 })} run={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={disabled} />
125
+
126
+ <ToolbarSeparator />
127
+
128
+ <ToolbarButton editor={editor} label="Bulleted list" icon={<ListIcon size={16} />} isActive={editor?.isActive("bulletList")} run={() => editor?.chain().focus().toggleBulletList().run()} disabled={disabled} />
129
+ <ToolbarButton editor={editor} label="Ordered list" icon={<ListOrderedIcon size={16} />} isActive={editor?.isActive("orderedList")} run={() => editor?.chain().focus().toggleOrderedList().run()} disabled={disabled} />
130
+ <ToolbarButton editor={editor} label="Blockquote" icon={<QuoteIcon size={16} />} isActive={editor?.isActive("blockquote")} run={() => editor?.chain().focus().toggleBlockquote().run()} disabled={disabled} />
131
+ <ToolbarButton editor={editor} label="Code block" icon={<Code2Icon size={16} />} isActive={editor?.isActive("codeBlock")} run={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={disabled} />
132
+
133
+ <ToolbarSeparator />
134
+
135
+ <ToolbarButton editor={editor} label="Link" icon={<LinkIcon size={16} />} isActive={editor?.isActive("link")} run={promptLink} disabled={disabled} />
136
+ <ToolbarButton editor={editor} label="Horizontal rule" icon={<MinusIcon size={16} />} run={() => editor?.chain().focus().setHorizontalRule().run()} disabled={disabled} />
137
+
138
+ <ToolbarSeparator />
139
+
140
+ <ToolbarButton editor={editor} label="Undo" icon={<Undo2Icon size={16} />} canRun={() => !!editor?.can().undo()} run={() => editor?.chain().focus().undo().run()} disabled={disabled} />
141
+ <ToolbarButton editor={editor} label="Redo" icon={<Redo2Icon size={16} />} canRun={() => !!editor?.can().redo()} run={() => editor?.chain().focus().redo().run()} disabled={disabled} />
142
+ </div>
143
+ );
144
+ }
145
+
146
+ interface ToolbarButtonProps {
147
+ editor: Editor | null;
148
+ label: string;
149
+ icon: React.ReactNode;
150
+ isActive?: boolean;
151
+ canRun?: () => boolean;
152
+ run: () => void;
153
+ disabled: boolean;
154
+ }
155
+
156
+ function ToolbarButton({ editor, label, icon, isActive, canRun, run, disabled }: ToolbarButtonProps) {
157
+ const isDisabled = disabled || !editor || (canRun ? !canRun() : false);
158
+ return (
159
+ <button
160
+ type="button"
161
+ className={cx(
162
+ "inline-flex items-center justify-center w-7 h-7 p-0 bg-transparent text-foreground-muted border border-transparent rounded-[calc(var(--radius)-2px)] cursor-pointer transition-[color,background-color,border-color] duration-[var(--duration-fast)] hover:not-disabled:text-foreground hover:not-disabled:bg-background hover:not-disabled:border-border focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed motion-reduce:transition-none",
163
+ isActive && "text-foreground bg-background border-border-strong",
164
+ )}
165
+ aria-label={label}
166
+ aria-pressed={isActive || undefined}
167
+ title={label}
168
+ disabled={isDisabled}
169
+ onMouseDown={(e) => { e.preventDefault(); }}
170
+ onClick={run}
171
+ >
172
+ {icon}
173
+ </button>
174
+ );
175
+ }
176
+
177
+ function ToolbarSeparator() {
178
+ return <span aria-hidden className="inline-block w-px h-5 mx-[var(--space-1)] bg-border" />;
179
+ }
180
+
181
+ if (typeof document !== "undefined" && !document.querySelector("style[data-sh-ui-rte]")) {
182
+ const style = document.createElement("style");
183
+ style.setAttribute("data-sh-ui-rte", "");
184
+ style.textContent = `
185
+ .sh-ui-rte__viewport { display: flex; min-height: var(--sh-ui-rte-min-height, 9rem); max-height: var(--sh-ui-rte-max-height, 28rem); overflow-y: auto; }
186
+ .sh-ui-rte__viewport > .ProseMirror { flex: 1; }
187
+ .sh-ui-rte__content { outline: none; padding: var(--space-3) var(--space-4); font-size: 0.9375rem; line-height: 1.65; color: var(--foreground); }
188
+ .sh-ui-rte__content > :first-child { margin-top: 0; }
189
+ .sh-ui-rte__content > :last-child { margin-bottom: 0; }
190
+ .sh-ui-rte__content p { margin: 0 0 var(--space-3); }
191
+ .sh-ui-rte__content h1, .sh-ui-rte__content h2, .sh-ui-rte__content h3, .sh-ui-rte__content h4, .sh-ui-rte__content h5, .sh-ui-rte__content h6 { margin: var(--space-4) 0 var(--space-2); font-weight: 600; line-height: 1.3; }
192
+ .sh-ui-rte__content h1 { font-size: 1.5rem; }
193
+ .sh-ui-rte__content h2 { font-size: 1.25rem; }
194
+ .sh-ui-rte__content h3 { font-size: 1.125rem; }
195
+ .sh-ui-rte__content ul, .sh-ui-rte__content ol { margin: 0 0 var(--space-3); padding-left: var(--space-5); }
196
+ .sh-ui-rte__content li { margin-bottom: var(--space-1); }
197
+ .sh-ui-rte__content li > p { margin: 0; }
198
+ .sh-ui-rte__content blockquote { margin: 0 0 var(--space-3); padding: var(--space-2) var(--space-3); border-left: 3px solid var(--border-strong); background: var(--background-subtle); color: var(--foreground-muted); border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0; }
199
+ .sh-ui-rte__content blockquote > :last-child { margin-bottom: 0; }
200
+ .sh-ui-rte__content code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.875em; padding: 0.125rem 0.375rem; border-radius: calc(var(--radius) - 4px); background: var(--background-muted); color: var(--foreground); }
201
+ .sh-ui-rte__content pre { margin: 0 0 var(--space-3); padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--background-subtle); overflow-x: auto; font-size: 0.8125rem; line-height: 1.6; }
202
+ .sh-ui-rte__content pre code { padding: 0; background: transparent; font-size: inherit; }
203
+ .sh-ui-rte__content hr { border: 0; border-top: 1px solid var(--border); margin: var(--space-4) 0; }
204
+ .sh-ui-rte__content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
205
+ .sh-ui-rte__content a:hover { text-decoration-thickness: 2px; }
206
+ .sh-ui-rte__content p.is-editor-empty:first-child::before, .sh-ui-rte__content .is-editor-empty:first-child::before { content: attr(data-placeholder); color: var(--foreground-muted); float: left; pointer-events: none; height: 0; }
207
+ .sh-ui-rte__content del, .sh-ui-rte__content s { color: var(--foreground-muted); }
208
+ .sh-ui-rte__content ::selection { background: var(--background-muted); }
209
+ `;
210
+ document.head.appendChild(style);
211
+ }