@olympusoss/canvas 2.20.1 → 4.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.
Files changed (208) hide show
  1. package/package.json +41 -177
  2. package/src/cn.ts +3 -0
  3. package/src/index.ts +12 -603
  4. package/src/theme.ts +41 -0
  5. package/src/tokens.ts +11 -0
  6. package/styles/base.css +17 -0
  7. package/styles/canvas.css +69 -52
  8. package/styles/components/alert.css +66 -0
  9. package/styles/components/app-shell.css +46 -0
  10. package/styles/components/avatar.css +15 -0
  11. package/styles/components/badge.css +83 -0
  12. package/styles/components/breadcrumb.css +35 -0
  13. package/styles/components/button-group.css +23 -0
  14. package/styles/components/button.css +107 -0
  15. package/styles/components/calendar.css +73 -0
  16. package/styles/components/card.css +58 -0
  17. package/styles/components/checkbox.css +55 -0
  18. package/styles/components/code-block.css +18 -0
  19. package/styles/components/combobox.css +75 -0
  20. package/styles/components/command.css +94 -0
  21. package/styles/components/data-table.css +142 -0
  22. package/styles/components/dialog.css +72 -0
  23. package/styles/components/dropdown.css +54 -0
  24. package/styles/components/empty-state.css +17 -0
  25. package/styles/components/field.css +27 -0
  26. package/styles/components/filter-panel.css +58 -0
  27. package/styles/components/form.css +27 -0
  28. package/styles/components/icon.css +8 -0
  29. package/styles/components/input-group.css +45 -0
  30. package/styles/components/input.css +56 -0
  31. package/styles/components/kbd.css +15 -0
  32. package/styles/components/page-header.css +52 -0
  33. package/styles/components/pagination.css +48 -0
  34. package/styles/components/popover.css +14 -0
  35. package/styles/components/radio.css +28 -0
  36. package/styles/components/row-menu.css +69 -0
  37. package/styles/components/section-card.css +49 -0
  38. package/styles/components/select.css +57 -0
  39. package/styles/components/separator.css +32 -0
  40. package/styles/components/sheet.css +70 -0
  41. package/styles/components/sidebar.css +146 -0
  42. package/styles/components/skeleton.css +32 -0
  43. package/styles/components/spinner.css +26 -0
  44. package/styles/components/stat-card.css +71 -0
  45. package/styles/components/stepper.css +63 -0
  46. package/styles/components/switch.css +45 -0
  47. package/styles/components/tabs.css +40 -0
  48. package/styles/components/textarea.css +31 -0
  49. package/styles/components/toast.css +95 -0
  50. package/styles/components/tooltip.css +53 -0
  51. package/styles/components/topbar.css +24 -0
  52. package/styles/components/typography.css +105 -0
  53. package/styles/patterns/backdrops.css +35 -0
  54. package/styles/patterns/density.css +66 -0
  55. package/styles/patterns/focus.css +38 -0
  56. package/styles/patterns/glass.css +85 -0
  57. package/styles/patterns/high-contrast.css +70 -0
  58. package/styles/patterns/reduced-motion.css +12 -0
  59. package/styles/patterns/scrollbar.css +10 -0
  60. package/styles/reset.css +89 -0
  61. package/styles/tokens/colors.css +106 -0
  62. package/styles/tokens/motion.css +33 -0
  63. package/styles/tokens/radius.css +10 -0
  64. package/styles/tokens/shadows.css +35 -0
  65. package/styles/tokens/spacing.css +19 -0
  66. package/styles/tokens/typography.css +6 -0
  67. package/styles/tokens/z-index.css +12 -0
  68. package/tsconfig.json +20 -21
  69. package/README.md +0 -60
  70. package/src/components/atoms/README.md +0 -11
  71. package/src/components/atoms/aspect-ratio.tsx +0 -32
  72. package/src/components/atoms/avatar.tsx +0 -98
  73. package/src/components/atoms/badge.tsx +0 -44
  74. package/src/components/atoms/brand-mark.tsx +0 -74
  75. package/src/components/atoms/button.tsx +0 -105
  76. package/src/components/atoms/checkbox.tsx +0 -63
  77. package/src/components/atoms/flex-box.tsx +0 -105
  78. package/src/components/atoms/icon.tsx +0 -34
  79. package/src/components/atoms/input.tsx +0 -92
  80. package/src/components/atoms/label.tsx +0 -41
  81. package/src/components/atoms/logo.tsx +0 -89
  82. package/src/components/atoms/progress.tsx +0 -55
  83. package/src/components/atoms/radio-group.tsx +0 -122
  84. package/src/components/atoms/scroll-area.tsx +0 -106
  85. package/src/components/atoms/section.tsx +0 -48
  86. package/src/components/atoms/separator.tsx +0 -45
  87. package/src/components/atoms/skeleton.tsx +0 -17
  88. package/src/components/atoms/slider.tsx +0 -93
  89. package/src/components/atoms/spinner.tsx +0 -47
  90. package/src/components/atoms/switch.tsx +0 -60
  91. package/src/components/atoms/textarea.tsx +0 -78
  92. package/src/components/atoms/toggle.tsx +0 -80
  93. package/src/components/charts/activity-heatmap.tsx +0 -186
  94. package/src/components/charts/axes.tsx +0 -21
  95. package/src/components/charts/chart-container.tsx +0 -254
  96. package/src/components/charts/chart-legend.tsx +0 -67
  97. package/src/components/charts/chart-tooltip.tsx +0 -161
  98. package/src/components/charts/chart-types.tsx +0 -49
  99. package/src/components/charts/containers.tsx +0 -11
  100. package/src/components/charts/data.tsx +0 -16
  101. package/src/components/charts/details.tsx +0 -25
  102. package/src/components/charts/dot-pulse.tsx +0 -61
  103. package/src/components/charts/gauge.tsx +0 -106
  104. package/src/components/charts/grids.tsx +0 -8
  105. package/src/components/charts/index.ts +0 -62
  106. package/src/components/charts/labeled-bar-list.tsx +0 -85
  107. package/src/components/charts/metric-breakdown.tsx +0 -316
  108. package/src/components/charts/references.tsx +0 -8
  109. package/src/components/charts/service-health-list.tsx +0 -85
  110. package/src/components/charts/sparkline-area.tsx +0 -80
  111. package/src/components/charts/sparkline.tsx +0 -52
  112. package/src/components/charts/stacked-bar.tsx +0 -104
  113. package/src/components/charts/text.tsx +0 -10
  114. package/src/components/charts/world-heat-map-inner.tsx +0 -317
  115. package/src/components/charts/world-heat-map.tsx +0 -184
  116. package/src/components/molecules/README.md +0 -12
  117. package/src/components/molecules/action-bar.tsx +0 -73
  118. package/src/components/molecules/activity-item.tsx +0 -74
  119. package/src/components/molecules/alert.tsx +0 -86
  120. package/src/components/molecules/animated-background.tsx +0 -92
  121. package/src/components/molecules/auth-shell.tsx +0 -95
  122. package/src/components/molecules/brand-lockup.tsx +0 -48
  123. package/src/components/molecules/breadcrumb.tsx +0 -157
  124. package/src/components/molecules/button-group.tsx +0 -104
  125. package/src/components/molecules/calendar.tsx +0 -217
  126. package/src/components/molecules/card.tsx +0 -102
  127. package/src/components/molecules/client-brand.tsx +0 -95
  128. package/src/components/molecules/code-block.tsx +0 -86
  129. package/src/components/molecules/countdown-button.tsx +0 -92
  130. package/src/components/molecules/empty-state.tsx +0 -56
  131. package/src/components/molecules/error-state.tsx +0 -42
  132. package/src/components/molecules/field-display.tsx +0 -35
  133. package/src/components/molecules/input-otp.tsx +0 -74
  134. package/src/components/molecules/launcher-card.tsx +0 -152
  135. package/src/components/molecules/loading-state.tsx +0 -36
  136. package/src/components/molecules/notification-item.tsx +0 -67
  137. package/src/components/molecules/notification-list.tsx +0 -45
  138. package/src/components/molecules/number-badge.tsx +0 -53
  139. package/src/components/molecules/or-separator.tsx +0 -38
  140. package/src/components/molecules/page-header.tsx +0 -88
  141. package/src/components/molecules/page-tabs.tsx +0 -94
  142. package/src/components/molecules/pagination.tsx +0 -150
  143. package/src/components/molecules/password-input.tsx +0 -83
  144. package/src/components/molecules/password-strength-meter.tsx +0 -104
  145. package/src/components/molecules/phone-input.tsx +0 -200
  146. package/src/components/molecules/search-bar.tsx +0 -64
  147. package/src/components/molecules/secret-field.tsx +0 -158
  148. package/src/components/molecules/section-card.tsx +0 -91
  149. package/src/components/molecules/social-buttons.tsx +0 -165
  150. package/src/components/molecules/stat-card.tsx +0 -100
  151. package/src/components/molecules/status-badge.tsx +0 -42
  152. package/src/components/molecules/stepper.tsx +0 -96
  153. package/src/components/molecules/table.tsx +0 -157
  154. package/src/components/molecules/terminal.tsx +0 -74
  155. package/src/components/molecules/toggle-group.tsx +0 -145
  156. package/src/components/molecules/tooltip.tsx +0 -155
  157. package/src/components/molecules/user-avatar-chip.tsx +0 -71
  158. package/src/components/organisms/README.md +0 -14
  159. package/src/components/organisms/accordion.tsx +0 -154
  160. package/src/components/organisms/alert-dialog.tsx +0 -277
  161. package/src/components/organisms/carousel.tsx +0 -244
  162. package/src/components/organisms/collapsible.tsx +0 -69
  163. package/src/components/organisms/command.tsx +0 -144
  164. package/src/components/organisms/context-menu.tsx +0 -339
  165. package/src/components/organisms/dashboard-grid.tsx +0 -369
  166. package/src/components/organisms/data-table.tsx +0 -330
  167. package/src/components/organisms/dialog.tsx +0 -312
  168. package/src/components/organisms/drawer.tsx +0 -123
  169. package/src/components/organisms/dropdown-menu.tsx +0 -440
  170. package/src/components/organisms/editors/code-editor.tsx +0 -144
  171. package/src/components/organisms/editors/index.ts +0 -4
  172. package/src/components/organisms/editors/markdown-editor.tsx +0 -153
  173. package/src/components/organisms/editors/markdown-renderer.ts +0 -27
  174. package/src/components/organisms/editors/prose-canvas-classes.ts +0 -45
  175. package/src/components/organisms/editors/rich-text-editor.tsx +0 -126
  176. package/src/components/organisms/editors/toolbar/md-toolbar.tsx +0 -129
  177. package/src/components/organisms/editors/toolbar/rte-toolbar.tsx +0 -211
  178. package/src/components/organisms/editors/toolbar/toolbar-shell.tsx +0 -45
  179. package/src/components/organisms/editors/use-codemirror-theme.ts +0 -61
  180. package/src/components/organisms/error-boundary.tsx +0 -61
  181. package/src/components/organisms/form.tsx +0 -174
  182. package/src/components/organisms/hover-card.tsx +0 -115
  183. package/src/components/organisms/menubar.tsx +0 -498
  184. package/src/components/organisms/navbar.tsx +0 -104
  185. package/src/components/organisms/navigation-menu.tsx +0 -235
  186. package/src/components/organisms/popover.tsx +0 -149
  187. package/src/components/organisms/resizable.tsx +0 -58
  188. package/src/components/organisms/schema-form.tsx +0 -232
  189. package/src/components/organisms/select.tsx +0 -309
  190. package/src/components/organisms/sheet.tsx +0 -265
  191. package/src/components/organisms/sidebar.tsx +0 -1040
  192. package/src/components/organisms/sonner.tsx +0 -96
  193. package/src/components/organisms/tabs.tsx +0 -133
  194. package/src/components/organisms/theme-provider.tsx +0 -101
  195. package/src/hooks/use-mobile.tsx +0 -19
  196. package/src/lib/portal-container.tsx +0 -35
  197. package/src/lib/utils.ts +0 -6
  198. package/src/native.ts +0 -23
  199. package/src/tokens/colors.ts +0 -91
  200. package/src/tokens/index.ts +0 -3
  201. package/src/tokens/spacing.ts +0 -55
  202. package/src/tokens/typography.ts +0 -27
  203. package/styles/dashboard-grid.css +0 -47
  204. package/styles/fonts/Roboto-VariableFont_wdth_wght.ttf +0 -0
  205. package/styles/glass.css +0 -171
  206. package/styles/leaflet.css +0 -13
  207. package/styles/tokens.css +0 -317
  208. package/tailwind.config.ts +0 -70
@@ -1,153 +0,0 @@
1
- "use client";
2
-
3
- /* c8 ignore file -- CodeMirror requires real DOM measurements; jsdom-incompatible.
4
- * Verified visually in the docs site. */
5
-
6
- import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
7
- import { markdown } from "@codemirror/lang-markdown";
8
- import { EditorState } from "@codemirror/state";
9
- import { EditorView, keymap, placeholder as placeholderExt } from "@codemirror/view";
10
- import * as React from "react";
11
-
12
- import { cn } from "../../../lib/utils";
13
- import { renderMarkdown } from "./markdown-renderer";
14
- import { PROSE_CANVAS_CLASSES } from "./prose-canvas-classes";
15
- import { MdToolbar } from "./toolbar/md-toolbar";
16
- import { useCodemirrorTheme } from "./use-codemirror-theme";
17
-
18
- export interface MarkdownEditorProps {
19
- value: string;
20
- onChange: (next: string) => void;
21
- placeholder?: string;
22
- disabled?: boolean;
23
- readonly?: boolean;
24
- ariaLabel: string;
25
- className?: string;
26
- /** Show the formatting toolbar above the editor. Default true. */
27
- toolbar?: boolean;
28
- /** Render a side-by-side preview pane that re-renders on every keystroke. Default false. */
29
- preview?: boolean;
30
- /** Pixel height (or any CSS length) of the editing surface. Default `240`. */
31
- height?: number | string;
32
- }
33
-
34
- /**
35
- * Markdown editor backed by CodeMirror 6 with markdown syntax highlighting,
36
- * a canvas-styled toolbar, and an optional sanitized preview pane.
37
- *
38
- * Controlled component. `value` is the markdown source string; `onChange`
39
- * fires on every doc change.
40
- */
41
- const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
42
- (
43
- {
44
- value,
45
- onChange,
46
- placeholder,
47
- disabled = false,
48
- readonly = false,
49
- ariaLabel,
50
- className,
51
- toolbar = true,
52
- preview = false,
53
- height = 240,
54
- },
55
- ref,
56
- ) => {
57
- const editorParentRef = React.useRef<HTMLDivElement>(null);
58
- const viewRef = React.useRef<EditorView | null>(null);
59
- const onChangeRef = React.useRef(onChange);
60
- onChangeRef.current = onChange;
61
- const theme = useCodemirrorTheme();
62
- const [previewOpen, setPreviewOpen] = React.useState(preview);
63
- const [view, setView] = React.useState<EditorView | null>(null);
64
-
65
- // Mount/teardown the CodeMirror view whenever extensions change.
66
- React.useEffect(() => {
67
- if (!editorParentRef.current) return;
68
- const extensions = [
69
- history(),
70
- keymap.of([...defaultKeymap, ...historyKeymap]),
71
- markdown(),
72
- EditorView.lineWrapping,
73
- theme,
74
- EditorView.editable.of(!disabled),
75
- EditorState.readOnly.of(disabled || readonly),
76
- placeholder ? placeholderExt(placeholder) : [],
77
- EditorView.updateListener.of((update) => {
78
- if (update.docChanged) {
79
- onChangeRef.current(update.state.doc.toString());
80
- }
81
- }),
82
- ].flat();
83
-
84
- const v = new EditorView({
85
- state: EditorState.create({ doc: value, extensions }),
86
- parent: editorParentRef.current,
87
- });
88
- viewRef.current = v;
89
- setView(v);
90
- return () => {
91
- v.destroy();
92
- viewRef.current = null;
93
- setView(null);
94
- };
95
- // eslint-disable-next-line react-hooks/exhaustive-deps
96
- }, [theme, disabled, readonly, placeholder]);
97
-
98
- // Sync external `value` changes into the editor (without echoing back).
99
- React.useEffect(() => {
100
- const v = viewRef.current;
101
- if (!v) return;
102
- const current = v.state.doc.toString();
103
- if (current !== value) {
104
- v.dispatch({
105
- changes: { from: 0, to: current.length, insert: value },
106
- });
107
- }
108
- }, [value]);
109
-
110
- const heightStyle = typeof height === "number" ? `${height}px` : height;
111
-
112
- return (
113
- <div
114
- ref={ref}
115
- role="group"
116
- aria-label={ariaLabel}
117
- className={cn(
118
- "overflow-hidden rounded-md border border-input bg-background text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring",
119
- disabled && "pointer-events-none opacity-50",
120
- className,
121
- )}
122
- >
123
- {toolbar && (
124
- <MdToolbar
125
- view={view}
126
- previewActive={previewOpen}
127
- onTogglePreview={() => setPreviewOpen((p) => !p)}
128
- allowPreview
129
- />
130
- )}
131
- <div
132
- className={cn("grid", previewOpen ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1")}
133
- style={{ height: heightStyle }}
134
- >
135
- <div ref={editorParentRef} className="overflow-y-auto" />
136
- {previewOpen && (
137
- <div
138
- className={cn(
139
- "overflow-y-auto border-l border-border bg-muted/20 px-4 py-3",
140
- PROSE_CANVAS_CLASSES,
141
- )}
142
- // biome-ignore lint/security/noDangerouslySetInnerHtml: output is sanitized by DOMPurify in renderMarkdown
143
- dangerouslySetInnerHTML={{ __html: renderMarkdown(value) }}
144
- />
145
- )}
146
- </div>
147
- </div>
148
- );
149
- },
150
- );
151
- MarkdownEditor.displayName = "MarkdownEditor";
152
-
153
- export { MarkdownEditor };
@@ -1,27 +0,0 @@
1
- "use client";
2
-
3
- /* c8 ignore file -- DOMPurify + marked require a real DOM; jsdom-incompatible.
4
- * Verified visually in the docs site. */
5
-
6
- import DOMPurify from "dompurify";
7
- import { marked } from "marked";
8
-
9
- // GFM (tables, task lists, autolinks) is on by default in marked v15+ but
10
- // kept explicit for clarity and forward-compat.
11
- marked.setOptions({ gfm: true, breaks: false });
12
-
13
- /**
14
- * Convert a markdown string to a DOMPurify-sanitized HTML string suitable for
15
- * `dangerouslySetInnerHTML`. Strips `<script>`, inline event handlers, and
16
- * `javascript:` URLs.
17
- *
18
- * Used by `<MarkdownEditor preview>` for the live preview pane. The editor
19
- * is a `"use client"` component, so this only ever runs in the browser.
20
- */
21
- export function renderMarkdown(source: string): string {
22
- if (typeof window === "undefined") return "";
23
- const html = marked.parse(source, { async: false }) as string;
24
- return DOMPurify.sanitize(html, {
25
- ADD_ATTR: ["target", "rel"],
26
- });
27
- }
@@ -1,45 +0,0 @@
1
- /**
2
- * Tailwind class string applied to the editable surface of `RichTextEditor`
3
- * and the rendered preview of `MarkdownEditor`. Replaces the role of
4
- * `@tailwindcss/typography`'s `prose` plugin with token-aware styles so the
5
- * surface respects whichever canvas theme is active.
6
- *
7
- * Use this on a wrapping `<div>` around `<EditorContent />` (Tiptap) or
8
- * around the rendered HTML output (markdown preview).
9
- */
10
- export const PROSE_CANVAS_CLASSES = [
11
- // base text
12
- "text-foreground text-sm leading-relaxed",
13
- // paragraphs
14
- "[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
15
- // headings
16
- "[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h1]:text-foreground",
17
- "[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:text-foreground",
18
- "[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-foreground",
19
- "[&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-base [&_h4]:font-semibold [&_h4]:text-foreground",
20
- "[&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0",
21
- // inline emphasis
22
- "[&_strong]:font-semibold [&_strong]:text-foreground",
23
- "[&_em]:italic",
24
- "[&_u]:underline [&_u]:underline-offset-2",
25
- "[&_s]:line-through",
26
- // links — brand color, fades slightly on hover (no underline)
27
- "[&_a]:text-brand [&_a]:transition-colors hover:[&_a]:text-brand/80",
28
- // code
29
- "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-foreground",
30
- "[&_pre]:rounded-md [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/50 [&_pre]:p-3 [&_pre]:my-3 [&_pre]:overflow-x-auto",
31
- "[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-sm",
32
- // lists
33
- "[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6",
34
- "[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6",
35
- "[&_li]:my-1 [&_li_p]:my-0",
36
- "[&_li::marker]:text-muted-foreground",
37
- // blockquote
38
- "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:my-3 [&_blockquote]:text-muted-foreground [&_blockquote]:italic",
39
- // horizontal rule
40
- "[&_hr]:my-4 [&_hr]:border-border",
41
- // tables (GFM)
42
- "[&_table]:my-3 [&_table]:w-full [&_table]:text-sm [&_table]:border-collapse",
43
- "[&_th]:border [&_th]:border-border [&_th]:bg-muted/40 [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-semibold",
44
- "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-1.5",
45
- ].join(" ");
@@ -1,126 +0,0 @@
1
- "use client";
2
-
3
- /* c8 ignore file -- Tiptap/ProseMirror requires real DOM measurements;
4
- * jsdom-incompatible. Verified visually in the docs site. */
5
-
6
- import { Link } from "@tiptap/extension-link";
7
- import { Placeholder } from "@tiptap/extension-placeholder";
8
- import { EditorContent, type Extensions, useEditor } from "@tiptap/react";
9
- import { StarterKit } from "@tiptap/starter-kit";
10
- import * as React from "react";
11
-
12
- import { cn } from "../../../lib/utils";
13
- import { PROSE_CANVAS_CLASSES } from "./prose-canvas-classes";
14
- import { RteToolbar, type ToolbarItemId } from "./toolbar/rte-toolbar";
15
-
16
- export interface RichTextEditorProps {
17
- value: string;
18
- onChange: (next: string) => void;
19
- placeholder?: string;
20
- disabled?: boolean;
21
- readonly?: boolean;
22
- ariaLabel: string;
23
- className?: string;
24
- /** Show the toolbar above the editor. Default true. */
25
- toolbar?: boolean;
26
- /** Whitelist of toolbar buttons. Default: all. See `TOOLBAR_ITEM_IDS`. */
27
- toolbarItems?: readonly ToolbarItemId[];
28
- /** Output format for `value` / `onChange`. Default `"html"`. */
29
- outputFormat?: "html" | "json";
30
- /** Pixel height (or any CSS length) of the editing surface. Default `240`. */
31
- height?: number | string;
32
- /** Extra Tiptap extensions appended after the canvas defaults. */
33
- extensions?: Extensions;
34
- }
35
-
36
- /**
37
- * Rich text editor backed by Tiptap (ProseMirror). Ships with bold / italic /
38
- * strike / code / list / blockquote / code-block / link / heading levels.
39
- *
40
- * Controlled component: `value` is an HTML string by default, or a JSON string
41
- * when `outputFormat="json"`. `onChange` fires on every doc update.
42
- */
43
- const RichTextEditor = React.forwardRef<HTMLDivElement, RichTextEditorProps>(
44
- (
45
- {
46
- value,
47
- onChange,
48
- placeholder,
49
- disabled = false,
50
- readonly = false,
51
- ariaLabel,
52
- className,
53
- toolbar = true,
54
- toolbarItems,
55
- outputFormat = "html",
56
- height = 240,
57
- extensions: extraExtensions,
58
- },
59
- ref,
60
- ) => {
61
- const onChangeRef = React.useRef(onChange);
62
- onChangeRef.current = onChange;
63
-
64
- const editor = useEditor({
65
- extensions: [
66
- StarterKit,
67
- Link.configure({ openOnClick: false, autolink: true }),
68
- ...(placeholder ? [Placeholder.configure({ placeholder })] : []),
69
- ...(extraExtensions ?? []),
70
- ],
71
- content: value,
72
- editable: !disabled && !readonly,
73
- editorProps: {
74
- attributes: {
75
- "aria-label": ariaLabel,
76
- "aria-multiline": "true",
77
- role: "textbox",
78
- class: cn("min-h-full w-full px-4 py-3 outline-none", PROSE_CANVAS_CLASSES),
79
- },
80
- },
81
- onUpdate: ({ editor: e }) => {
82
- const next = outputFormat === "json" ? JSON.stringify(e.getJSON()) : e.getHTML();
83
- onChangeRef.current(next);
84
- },
85
- immediatelyRender: false,
86
- });
87
-
88
- // Keep `editable` in sync with prop changes.
89
- React.useEffect(() => {
90
- editor?.setEditable(!disabled && !readonly);
91
- }, [editor, disabled, readonly]);
92
-
93
- // Sync external `value` changes into the editor (without echoing back).
94
- React.useEffect(() => {
95
- if (!editor) return;
96
- const current = outputFormat === "json" ? JSON.stringify(editor.getJSON()) : editor.getHTML();
97
- if (current !== value) {
98
- const incoming = outputFormat === "json" && value ? JSON.parse(value) : value;
99
- editor.commands.setContent(incoming, false);
100
- }
101
- }, [editor, value, outputFormat]);
102
-
103
- const heightStyle = typeof height === "number" ? `${height}px` : height;
104
-
105
- return (
106
- <div
107
- ref={ref}
108
- role="group"
109
- aria-label={ariaLabel}
110
- className={cn(
111
- "overflow-hidden rounded-md border border-input bg-background text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring",
112
- disabled && "pointer-events-none opacity-50",
113
- className,
114
- )}
115
- >
116
- {toolbar && <RteToolbar editor={editor} items={toolbarItems} />}
117
- <div className="overflow-y-auto" style={{ height: heightStyle }}>
118
- <EditorContent editor={editor} className="h-full" />
119
- </div>
120
- </div>
121
- );
122
- },
123
- );
124
- RichTextEditor.displayName = "RichTextEditor";
125
-
126
- export { RichTextEditor };
@@ -1,129 +0,0 @@
1
- "use client";
2
-
3
- /* c8 ignore file -- CodeMirror toolbar requires real DOM; jsdom-incompatible. */
4
-
5
- import { EditorSelection } from "@codemirror/state";
6
- import type { EditorView } from "@codemirror/view";
7
-
8
- import { Icon } from "../../../atoms/icon";
9
- import { Toggle } from "../../../atoms/toggle";
10
- import { ToolbarDivider, ToolbarShell } from "./toolbar-shell";
11
-
12
- export interface MdToolbarProps {
13
- /** CodeMirror view ref (held by the editor). */
14
- view: EditorView | null;
15
- /** Whether the preview pane is currently shown. */
16
- previewActive: boolean;
17
- /** Callback when the user clicks the preview toggle. */
18
- onTogglePreview?: () => void;
19
- /** Hide the preview button entirely (when `<MarkdownEditor preview>` isn't enabled at all). */
20
- allowPreview: boolean;
21
- }
22
-
23
- /**
24
- * Wrap the current selection (or insert at the cursor) with `prefix`/`suffix`.
25
- * Used for **bold**, *italic*, `code`, and link insertion.
26
- */
27
- function wrap(view: EditorView | null, prefix: string, suffix = prefix) {
28
- if (!view) return;
29
- const { state } = view;
30
- const changes = state.changeByRange((range) => ({
31
- changes: [
32
- { from: range.from, insert: prefix },
33
- { from: range.to, insert: suffix },
34
- ],
35
- range: range.empty
36
- ? EditorSelection.cursor(range.from + prefix.length)
37
- : EditorSelection.range(range.from + prefix.length, range.to + prefix.length),
38
- }));
39
- view.dispatch({ ...changes, userEvent: "input.format" });
40
- view.focus();
41
- }
42
-
43
- /** Toggle a per-line prefix (e.g. `- ` or `1. `) on every line in the selection. */
44
- function togglePrefix(view: EditorView | null, prefix: string) {
45
- if (!view) return;
46
- const { state } = view;
47
- const changes = state.changeByRange((range) => {
48
- const startLine = state.doc.lineAt(range.from);
49
- const endLine = state.doc.lineAt(range.to);
50
- const inserts: { from: number; insert: string }[] = [];
51
- for (let n = startLine.number; n <= endLine.number; n++) {
52
- const line = state.doc.line(n);
53
- if (!line.text.startsWith(prefix)) {
54
- inserts.push({ from: line.from, insert: prefix });
55
- }
56
- }
57
- return {
58
- changes: inserts,
59
- range,
60
- };
61
- });
62
- view.dispatch({ ...changes, userEvent: "input.format" });
63
- view.focus();
64
- }
65
-
66
- /** Insert a markdown link at the cursor with the selected text as the label. */
67
- function insertLink(view: EditorView | null) {
68
- if (!view) return;
69
- const { state } = view;
70
- const sel = state.sliceDoc(state.selection.main.from, state.selection.main.to);
71
- const label = sel || "link";
72
- const replacement = `[${label}](https://)`;
73
- view.dispatch({
74
- changes: { from: state.selection.main.from, to: state.selection.main.to, insert: replacement },
75
- selection: {
76
- anchor: state.selection.main.from + label.length + 3,
77
- head: state.selection.main.from + label.length + 11,
78
- },
79
- userEvent: "input.format",
80
- });
81
- view.focus();
82
- }
83
-
84
- export function MdToolbar({ view, previewActive, onTogglePreview, allowPreview }: MdToolbarProps) {
85
- return (
86
- <ToolbarShell aria-label="Markdown formatting">
87
- <Toggle size="sm" aria-label="Bold" onPressedChange={() => wrap(view, "**")}>
88
- <Icon name="Bold" />
89
- </Toggle>
90
- <Toggle size="sm" aria-label="Italic" onPressedChange={() => wrap(view, "*")}>
91
- <Icon name="Italic" />
92
- </Toggle>
93
- <Toggle size="sm" aria-label="Inline code" onPressedChange={() => wrap(view, "`")}>
94
- <Icon name="Code" />
95
- </Toggle>
96
- <ToolbarDivider />
97
- <Toggle size="sm" aria-label="Bullet list" onPressedChange={() => togglePrefix(view, "- ")}>
98
- <Icon name="List" />
99
- </Toggle>
100
- <Toggle
101
- size="sm"
102
- aria-label="Numbered list"
103
- onPressedChange={() => togglePrefix(view, "1. ")}
104
- >
105
- <Icon name="ListOrdered" />
106
- </Toggle>
107
- <Toggle size="sm" aria-label="Block quote" onPressedChange={() => togglePrefix(view, "> ")}>
108
- <Icon name="Quote" />
109
- </Toggle>
110
- <ToolbarDivider />
111
- <Toggle size="sm" aria-label="Insert link" onPressedChange={() => insertLink(view)}>
112
- <Icon name="Link" />
113
- </Toggle>
114
- {allowPreview && onTogglePreview && (
115
- <>
116
- <ToolbarDivider />
117
- <Toggle
118
- size="sm"
119
- aria-label={previewActive ? "Hide preview" : "Show preview"}
120
- pressed={previewActive}
121
- onPressedChange={onTogglePreview}
122
- >
123
- <Icon name={previewActive ? "EyeOff" : "Eye"} />
124
- </Toggle>
125
- </>
126
- )}
127
- </ToolbarShell>
128
- );
129
- }
@@ -1,211 +0,0 @@
1
- "use client";
2
-
3
- /* c8 ignore file -- Tiptap toolbar requires real DOM; jsdom-incompatible. */
4
-
5
- import type { Editor } from "@tiptap/react";
6
-
7
- import { Button } from "../../../atoms/button";
8
- import { Icon } from "../../../atoms/icon";
9
- import { Toggle } from "../../../atoms/toggle";
10
- import { ToolbarDivider, ToolbarShell } from "./toolbar-shell";
11
-
12
- export const TOOLBAR_ITEM_IDS = [
13
- "heading",
14
- "bold",
15
- "italic",
16
- "underline",
17
- "strike",
18
- "code",
19
- "codeBlock",
20
- "bulletList",
21
- "orderedList",
22
- "blockquote",
23
- "link",
24
- "undo",
25
- "redo",
26
- ] as const;
27
- export type ToolbarItemId = (typeof TOOLBAR_ITEM_IDS)[number];
28
-
29
- export interface RteToolbarProps {
30
- editor: Editor | null;
31
- /** Whitelist of toolbar items to render. Default: every item in `TOOLBAR_ITEM_IDS`. */
32
- items?: readonly ToolbarItemId[];
33
- }
34
-
35
- function show(items: readonly ToolbarItemId[] | undefined, id: ToolbarItemId) {
36
- return !items || items.includes(id);
37
- }
38
-
39
- export function RteToolbar({ editor, items }: RteToolbarProps) {
40
- if (!editor) return null;
41
-
42
- const setHeading = (level: 1 | 2 | 3 | null) => {
43
- if (level === null) editor.chain().focus().setParagraph().run();
44
- else editor.chain().focus().toggleHeading({ level }).run();
45
- };
46
-
47
- const insertLink = () => {
48
- const previous = editor.getAttributes("link").href as string | undefined;
49
- const url = window.prompt("URL", previous ?? "https://");
50
- if (url === null) return;
51
- if (url === "") editor.chain().focus().unsetLink().run();
52
- else
53
- editor
54
- .chain()
55
- .focus()
56
- .extendMarkRange("link")
57
- .setLink({ href: url, target: "_blank", rel: "noopener noreferrer" })
58
- .run();
59
- };
60
-
61
- return (
62
- <ToolbarShell aria-label="Rich text formatting">
63
- {show(items, "heading") && (
64
- <>
65
- <Toggle
66
- size="sm"
67
- aria-label="Heading 1"
68
- pressed={editor.isActive("heading", { level: 1 })}
69
- onPressedChange={() => setHeading(1)}
70
- >
71
- <Icon name="Heading1" />
72
- </Toggle>
73
- <Toggle
74
- size="sm"
75
- aria-label="Heading 2"
76
- pressed={editor.isActive("heading", { level: 2 })}
77
- onPressedChange={() => setHeading(2)}
78
- >
79
- <Icon name="Heading2" />
80
- </Toggle>
81
- <Toggle
82
- size="sm"
83
- aria-label="Heading 3"
84
- pressed={editor.isActive("heading", { level: 3 })}
85
- onPressedChange={() => setHeading(3)}
86
- >
87
- <Icon name="Heading3" />
88
- </Toggle>
89
- <ToolbarDivider />
90
- </>
91
- )}
92
- {show(items, "bold") && (
93
- <Toggle
94
- size="sm"
95
- aria-label="Bold"
96
- pressed={editor.isActive("bold")}
97
- onPressedChange={() => editor.chain().focus().toggleBold().run()}
98
- >
99
- <Icon name="Bold" />
100
- </Toggle>
101
- )}
102
- {show(items, "italic") && (
103
- <Toggle
104
- size="sm"
105
- aria-label="Italic"
106
- pressed={editor.isActive("italic")}
107
- onPressedChange={() => editor.chain().focus().toggleItalic().run()}
108
- >
109
- <Icon name="Italic" />
110
- </Toggle>
111
- )}
112
- {show(items, "strike") && (
113
- <Toggle
114
- size="sm"
115
- aria-label="Strikethrough"
116
- pressed={editor.isActive("strike")}
117
- onPressedChange={() => editor.chain().focus().toggleStrike().run()}
118
- >
119
- <Icon name="Strikethrough" />
120
- </Toggle>
121
- )}
122
- {show(items, "code") && (
123
- <Toggle
124
- size="sm"
125
- aria-label="Inline code"
126
- pressed={editor.isActive("code")}
127
- onPressedChange={() => editor.chain().focus().toggleCode().run()}
128
- >
129
- <Icon name="Code" />
130
- </Toggle>
131
- )}
132
- <ToolbarDivider />
133
- {show(items, "bulletList") && (
134
- <Toggle
135
- size="sm"
136
- aria-label="Bullet list"
137
- pressed={editor.isActive("bulletList")}
138
- onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
139
- >
140
- <Icon name="List" />
141
- </Toggle>
142
- )}
143
- {show(items, "orderedList") && (
144
- <Toggle
145
- size="sm"
146
- aria-label="Ordered list"
147
- pressed={editor.isActive("orderedList")}
148
- onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
149
- >
150
- <Icon name="ListOrdered" />
151
- </Toggle>
152
- )}
153
- {show(items, "blockquote") && (
154
- <Toggle
155
- size="sm"
156
- aria-label="Block quote"
157
- pressed={editor.isActive("blockquote")}
158
- onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
159
- >
160
- <Icon name="Quote" />
161
- </Toggle>
162
- )}
163
- {show(items, "codeBlock") && (
164
- <Toggle
165
- size="sm"
166
- aria-label="Code block"
167
- pressed={editor.isActive("codeBlock")}
168
- onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
169
- >
170
- <Icon name="SquareCode" />
171
- </Toggle>
172
- )}
173
- <ToolbarDivider />
174
- {show(items, "link") && (
175
- <Toggle
176
- size="sm"
177
- aria-label="Insert link"
178
- pressed={editor.isActive("link")}
179
- onPressedChange={insertLink}
180
- >
181
- <Icon name="Link" />
182
- </Toggle>
183
- )}
184
- <ToolbarDivider />
185
- {show(items, "undo") && (
186
- <Button
187
- type="button"
188
- variant="ghost"
189
- size="sm"
190
- aria-label="Undo"
191
- disabled={!editor.can().undo()}
192
- onClick={() => editor.chain().focus().undo().run()}
193
- >
194
- <Icon name="Undo2" />
195
- </Button>
196
- )}
197
- {show(items, "redo") && (
198
- <Button
199
- type="button"
200
- variant="ghost"
201
- size="sm"
202
- aria-label="Redo"
203
- disabled={!editor.can().redo()}
204
- onClick={() => editor.chain().focus().redo().run()}
205
- >
206
- <Icon name="Redo2" />
207
- </Button>
208
- )}
209
- </ToolbarShell>
210
- );
211
- }