@borisj74/bv-ds 0.1.7 → 0.1.8

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,175 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { EditorContent } from "@tiptap/react";
3
+ import clsx from "clsx";
4
+ import { Bold01, Link01, AlignLeft, AlignCenter, AlignRight, XClose } from "@borisj74/bv-ds-icons";
5
+ import { ButtonUtility } from "../ButtonUtility";
6
+ import { TextEditorToolbar, TextEditorToolbarDivider } from "../TextEditorToolbar";
7
+ import { TextEditorTooltip } from "../TextEditorTooltip";
8
+ import { IconBox } from "../../internal/iconBox";
9
+ import { useTextEditor } from "../../hooks/useTextEditor";
10
+
11
+ export interface TextEditorProps {
12
+ placeholder?: string;
13
+ /** Initial HTML content. */
14
+ content?: string;
15
+ /** Fires with serialized HTML on every change. */
16
+ onUpdate?: (html: string) => void;
17
+ disabled?: boolean;
18
+ className?: string;
19
+ }
20
+
21
+ /*
22
+ * Inline-SVG fallbacks for formatting glyphs absent from @borisj74/bv-ds-icons
23
+ * v0.1.0 (italic / underline / strike / bullet-list / ordered-list / heading).
24
+ * Swap to package icons once they exist. 24-grid, currentColor stroke.
25
+ */
26
+ const stroke = {
27
+ fill: "none",
28
+ stroke: "currentColor",
29
+ strokeWidth: 2,
30
+ strokeLinecap: "round" as const,
31
+ strokeLinejoin: "round" as const,
32
+ };
33
+ const ItalicIcon = () => (
34
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M19 4h-9M14 20H5M15 4 9 20" /></svg>
35
+ );
36
+ const UnderlineIcon = () => (
37
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M6 4v6a6 6 0 0 0 12 0V4M4 21h16" /></svg>
38
+ );
39
+ const StrikeIcon = () => (
40
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M4 12h16M16 7.5A4 3 0 0 0 8 8M8 16.5a4 3 0 0 0 8-.5" /></svg>
41
+ );
42
+ const HeadingIcon = () => (
43
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M6 4v16M18 4v16M6 12h12" /></svg>
44
+ );
45
+ const BulletListIcon = () => (
46
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" /></svg>
47
+ );
48
+ const OrderedListIcon = () => (
49
+ <svg viewBox="0 0 24 24" {...stroke}><path d="M10 6h10M10 12h10M10 18h10M4 4v4M3 14h2l-2 3h2" /></svg>
50
+ );
51
+
52
+ // ProseMirror content styling (Tailwind preflight strips list/heading styles).
53
+ const proseClass = clsx(
54
+ "font-body text-sm text-text-primary",
55
+ "[&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none",
56
+ "[&_.ProseMirror_p]:my-0",
57
+ "[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-2xl",
58
+ "[&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-2xl",
59
+ "[&_.ProseMirror_h2]:text-lg [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:text-text-primary",
60
+ "[&_.ProseMirror_a]:text-text-brand-secondary [&_.ProseMirror_a]:underline",
61
+ // Placeholder (extension adds .is-editor-empty + data-placeholder).
62
+ "[&_.ProseMirror_p.is-editor-empty:first-child]:before:pointer-events-none",
63
+ "[&_.ProseMirror_p.is-editor-empty:first-child]:before:float-left",
64
+ "[&_.ProseMirror_p.is-editor-empty:first-child]:before:h-0",
65
+ "[&_.ProseMirror_p.is-editor-empty:first-child]:before:text-text-placeholder",
66
+ "[&_.ProseMirror_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)]",
67
+ );
68
+
69
+ /**
70
+ * Rich-text editor — composes the `TextEditorToolbar` / `TextEditorTooltip`
71
+ * chrome shells onto a TipTap editor (`useTextEditor`). The toolbar buttons map
72
+ * to TipTap commands and reflect `editor.isActive(...)`; a `TextEditorTooltip`
73
+ * surfaces link controls when the caret sits inside a link.
74
+ *
75
+ * Pass `content` as initial HTML and read changes via `onUpdate(html)`.
76
+ */
77
+ export function TextEditor({
78
+ placeholder,
79
+ content,
80
+ onUpdate,
81
+ disabled = false,
82
+ className,
83
+ }: TextEditorProps) {
84
+ const editor = useTextEditor({ placeholder, content, editable: !disabled, onUpdate });
85
+
86
+ // Sync editable when `disabled` toggles without recreating the editor.
87
+ useEffect(() => {
88
+ editor?.setEditable(!disabled);
89
+ }, [editor, disabled]);
90
+
91
+ const isActive = (name: string, attrs?: Record<string, unknown>) =>
92
+ editor?.isActive(name, attrs) ?? false;
93
+
94
+ const setLink = () => {
95
+ if (!editor) return;
96
+ const prev = (editor.getAttributes("link").href as string | undefined) ?? "";
97
+ const url = window.prompt("Link URL", prev);
98
+ if (url === null) return;
99
+ const chain = editor.chain().focus().extendMarkRange("link");
100
+ if (url === "") chain.unsetLink().run();
101
+ else chain.setLink({ href: url }).run();
102
+ };
103
+
104
+ const ToolbarButton = ({
105
+ label,
106
+ icon,
107
+ active,
108
+ onRun,
109
+ }: {
110
+ label: string;
111
+ icon: ReactNode;
112
+ active?: boolean;
113
+ onRun: () => void;
114
+ }) => (
115
+ <ButtonUtility
116
+ variant="ghost"
117
+ label={label}
118
+ icon={<IconBox size={20}>{icon}</IconBox>}
119
+ aria-pressed={active}
120
+ disabled={disabled || !editor}
121
+ className={active ? "!bg-bg-secondary !text-fg-secondary" : undefined}
122
+ // Keep the editor selection when pressing a toolbar button.
123
+ onMouseDown={(e) => e.preventDefault()}
124
+ onClick={onRun}
125
+ />
126
+ );
127
+
128
+ return (
129
+ <div
130
+ className={clsx(
131
+ "relative flex flex-col overflow-hidden rounded-xl border border-border-primary bg-bg-primary",
132
+ "focus-within:ring-2 focus-within:ring-border-brand",
133
+ disabled && "opacity-60",
134
+ className,
135
+ )}
136
+ >
137
+ <TextEditorToolbar className="w-full !rounded-none !border-l-0 !border-r-0 !border-t-0 !shadow-none">
138
+ <ToolbarButton label="Bold" icon={<Bold01 />} active={isActive("bold")} onRun={() => editor?.chain().focus().toggleBold().run()} />
139
+ <ToolbarButton label="Italic" icon={<ItalicIcon />} active={isActive("italic")} onRun={() => editor?.chain().focus().toggleItalic().run()} />
140
+ <ToolbarButton label="Underline" icon={<UnderlineIcon />} active={isActive("underline")} onRun={() => editor?.chain().focus().toggleUnderline().run()} />
141
+ <ToolbarButton label="Strikethrough" icon={<StrikeIcon />} active={isActive("strike")} onRun={() => editor?.chain().focus().toggleStrike().run()} />
142
+ <TextEditorToolbarDivider />
143
+ <ToolbarButton label="Heading" icon={<HeadingIcon />} active={isActive("heading", { level: 2 })} onRun={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
144
+ <ToolbarButton label="Bullet list" icon={<BulletListIcon />} active={isActive("bulletList")} onRun={() => editor?.chain().focus().toggleBulletList().run()} />
145
+ <ToolbarButton label="Ordered list" icon={<OrderedListIcon />} active={isActive("orderedList")} onRun={() => editor?.chain().focus().toggleOrderedList().run()} />
146
+ <TextEditorToolbarDivider />
147
+ <ToolbarButton label="Align left" icon={<AlignLeft />} active={editor?.isActive({ textAlign: "left" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("left").run()} />
148
+ <ToolbarButton label="Align center" icon={<AlignCenter />} active={editor?.isActive({ textAlign: "center" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("center").run()} />
149
+ <ToolbarButton label="Align right" icon={<AlignRight />} active={editor?.isActive({ textAlign: "right" }) ?? false} onRun={() => editor?.chain().focus().setTextAlign("right").run()} />
150
+ <TextEditorToolbarDivider />
151
+ <ToolbarButton label="Link" icon={<Link01 />} active={isActive("link")} onRun={setLink} />
152
+ </TextEditorToolbar>
153
+
154
+ <div className="relative min-h-[120px] px-3xl py-2xl">
155
+ <EditorContent editor={editor} className={proseClass} />
156
+
157
+ {editor?.isActive("link") && (
158
+ <TextEditorTooltip className="absolute right-3xl top-md z-10">
159
+ <span className="max-w-[160px] truncate px-xs text-xs text-text-secondary">
160
+ {String(editor.getAttributes("link").href ?? "")}
161
+ </span>
162
+ <ButtonUtility variant="ghost" size="xs" label="Edit link" icon={<IconBox size={16}><Link01 /></IconBox>} onClick={setLink} />
163
+ <ButtonUtility
164
+ variant="ghost"
165
+ size="xs"
166
+ label="Remove link"
167
+ icon={<IconBox size={16}><XClose /></IconBox>}
168
+ onClick={() => editor.chain().focus().extendMarkRange("link").unsetLink().run()}
169
+ />
170
+ </TextEditorTooltip>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,2 @@
1
+ export { TextEditor } from "./TextEditor";
2
+ export type { TextEditorProps } from "./TextEditor";
@@ -0,0 +1,45 @@
1
+ import { useEditor, type Editor } from "@tiptap/react";
2
+ import { StarterKit } from "@tiptap/starter-kit";
3
+ import { TextAlign } from "@tiptap/extension-text-align";
4
+ import { Placeholder } from "@tiptap/extension-placeholder";
5
+
6
+ export interface UseTextEditorOptions {
7
+ /** Placeholder shown while the document is empty. */
8
+ placeholder?: string;
9
+ /** Initial HTML content. */
10
+ content?: string;
11
+ /** Whether the editor is editable (default `true`). */
12
+ editable?: boolean;
13
+ /** Fires with the serialized HTML on every change. */
14
+ onUpdate?: (html: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Thin wrapper around TipTap's `useEditor`, pre-configured with the standard
19
+ * extension set: StarterKit (bold/italic/strike/code/heading/lists — and, in
20
+ * TipTap v3, **underline + link**), TextAlign (left/center/right) and
21
+ * Placeholder. Link is configured `openOnClick: false` via StarterKit.
22
+ *
23
+ * Note: Underline and Link ship inside StarterKit v3, so they are NOT added as
24
+ * separate extensions (doing so throws a duplicate-extension error).
25
+ *
26
+ * Returns the TipTap `Editor` (or `null` until mounted). Compose it with
27
+ * `EditorContent` + the toolbar, or use the `TextEditor` composite.
28
+ */
29
+ export function useTextEditor({
30
+ placeholder,
31
+ content,
32
+ editable = true,
33
+ onUpdate,
34
+ }: UseTextEditorOptions = {}): Editor | null {
35
+ return useEditor({
36
+ editable,
37
+ content: content ?? "",
38
+ extensions: [
39
+ StarterKit.configure({ link: { openOnClick: false } }),
40
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
41
+ Placeholder.configure({ placeholder: placeholder ?? "" }),
42
+ ],
43
+ onUpdate: ({ editor }) => onUpdate?.(editor.getHTML()),
44
+ });
45
+ }
package/src/index.ts CHANGED
@@ -135,6 +135,7 @@ export * from "./components/TableHeaderCell";
135
135
  export * from "./components/TableHeaderLabel";
136
136
  export * from "./components/Tabs";
137
137
  export * from "./components/Tag";
138
+ export * from "./components/TextEditor";
138
139
  export * from "./components/TextEditorToolbar";
139
140
  export * from "./components/TextEditorTooltip";
140
141
  export * from "./components/TextareaInputField";
@@ -144,6 +145,9 @@ export * from "./components/TreeView";
144
145
  export * from "./components/TreeViewConnector";
145
146
  export * from "./components/TreeViewItem";
146
147
  export * from "./components/VerificationCodeInput";
148
+ // Hooks
149
+ export { useTextEditor } from "./hooks/useTextEditor";
150
+ export type { UseTextEditorOptions } from "./hooks/useTextEditor";
147
151
  // Illustration set — namespaced group (not individual barrel exports).
148
152
  export * as illustrations from "./illustrations";
149
153
  // figma-to-react appends one line here per new component, e.g.: