@scalepad/ui 0.1.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 (273) hide show
  1. package/.ai/rules/date-handling.md +39 -0
  2. package/.ai/rules/figma-design-system.md +372 -0
  3. package/.ai/rules/figma-lm-design-system-keys.md +680 -0
  4. package/.ai/rules/file-extensions.md +13 -0
  5. package/.ai/rules/modal-confirmation-mutation.md +56 -0
  6. package/.ai/rules/react-hooks.md +29 -0
  7. package/.ai/rules/styling.md +83 -0
  8. package/AGENTS.md +37 -0
  9. package/README.md +125 -0
  10. package/figma.config.json +9 -0
  11. package/package.json +127 -0
  12. package/scripts/install-ai-rules.mjs +136 -0
  13. package/src/ThemeProvider.tsx +57 -0
  14. package/src/charts.ts +32 -0
  15. package/src/components/ActionCard/ActionCard.css.ts +60 -0
  16. package/src/components/ActionCard/ActionCard.tsx +154 -0
  17. package/src/components/ActionCard/index.ts +2 -0
  18. package/src/components/Anchor/Anchor.tsx +47 -0
  19. package/src/components/Anchor/index.ts +2 -0
  20. package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
  21. package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
  22. package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
  23. package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
  24. package/src/components/Badge/Badge.css.ts +72 -0
  25. package/src/components/Badge/Badge.figma.tsx +43 -0
  26. package/src/components/Badge/Badge.tsx +159 -0
  27. package/src/components/Badge/index.ts +2 -0
  28. package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
  29. package/src/components/BreadCrumb/index.ts +2 -0
  30. package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
  31. package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
  32. package/src/components/BulkActionBar/index.ts +2 -0
  33. package/src/components/Button/Button.css.ts +272 -0
  34. package/src/components/Button/Button.figma.tsx +74 -0
  35. package/src/components/Button/Button.tsx +84 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
  38. package/src/components/Charts/ChartTooltip.tsx +101 -0
  39. package/src/components/Charts/MiniBarSparkline.tsx +75 -0
  40. package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
  41. package/src/components/Charts/TrendAreaChart.css.ts +23 -0
  42. package/src/components/Charts/TrendAreaChart.tsx +210 -0
  43. package/src/components/Charts/index.ts +12 -0
  44. package/src/components/CodePanel/CodePanel.css.ts +113 -0
  45. package/src/components/CodePanel/CodePanel.tsx +121 -0
  46. package/src/components/CodePanel/index.ts +2 -0
  47. package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
  48. package/src/components/CommentComposer/CommentComposer.tsx +181 -0
  49. package/src/components/CommentComposer/index.ts +2 -0
  50. package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
  51. package/src/components/ConfirmationModal/index.ts +2 -0
  52. package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
  53. package/src/components/ConfirmationTooltip/index.ts +2 -0
  54. package/src/components/DataDialog.figma.tsx +33 -0
  55. package/src/components/DataDialog.tsx +46 -0
  56. package/src/components/DataTable/DataTable.tsx +1042 -0
  57. package/src/components/DataTable/RowExpandToggle.tsx +105 -0
  58. package/src/components/DataTable/RowGroupHeader.tsx +190 -0
  59. package/src/components/DataTable/createActionsColumn.tsx +86 -0
  60. package/src/components/DataTable/index.ts +25 -0
  61. package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
  62. package/src/components/DatePicker/DateInput.tsx +329 -0
  63. package/src/components/DatePicker/DateNavigator.tsx +486 -0
  64. package/src/components/DatePicker/DatePicker.tsx +242 -0
  65. package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
  66. package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
  67. package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
  68. package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
  69. package/src/components/DatePicker/index.ts +7 -0
  70. package/src/components/DatePicker/types.ts +12 -0
  71. package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
  72. package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
  73. package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
  74. package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
  75. package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
  76. package/src/components/DesignSystemPrimitives/index.ts +55 -0
  77. package/src/components/Details/Details.css.ts +74 -0
  78. package/src/components/Details/Details.tsx +140 -0
  79. package/src/components/Details/index.ts +2 -0
  80. package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
  81. package/src/components/DownloadCard/DownloadCard.tsx +63 -0
  82. package/src/components/DownloadCard/index.ts +2 -0
  83. package/src/components/Drawer/Drawer.css.ts +32 -0
  84. package/src/components/Drawer/Drawer.tsx +236 -0
  85. package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
  86. package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
  87. package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
  88. package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
  89. package/src/components/EditableRichText/EditableRichText.tsx +324 -0
  90. package/src/components/EditableRichText/index.ts +2 -0
  91. package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
  92. package/src/components/EditableSelect/EditableSelect.tsx +224 -0
  93. package/src/components/EditableSelect/index.ts +2 -0
  94. package/src/components/EditableText/EditableText.tsx +377 -0
  95. package/src/components/EditableText/index.ts +2 -0
  96. package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
  97. package/src/components/EmptyState/EmptyState.tsx +230 -0
  98. package/src/components/EmptyState/index.ts +2 -0
  99. package/src/components/ErrorBoundary.tsx +135 -0
  100. package/src/components/ErrorState/ErrorState.tsx +197 -0
  101. package/src/components/ErrorState/index.ts +2 -0
  102. package/src/components/FeatureCard.tsx +42 -0
  103. package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
  104. package/src/components/FilterMenu/FilterMenu.tsx +198 -0
  105. package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
  106. package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
  107. package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
  108. package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
  109. package/src/components/FilterMenu/helpers.ts +115 -0
  110. package/src/components/FilterMenu/index.ts +35 -0
  111. package/src/components/FilterMenu/types.ts +101 -0
  112. package/src/components/IconButton/IconButton.css.ts +272 -0
  113. package/src/components/IconButton/IconButton.figma.tsx +47 -0
  114. package/src/components/IconButton/IconButton.tsx +72 -0
  115. package/src/components/IconButton/README.md +230 -0
  116. package/src/components/IconButton/index.ts +2 -0
  117. package/src/components/InfiniteScrollSentinel.tsx +86 -0
  118. package/src/components/InfiniteScrollTrigger.tsx +78 -0
  119. package/src/components/InfoCard.figma.tsx +47 -0
  120. package/src/components/InfoCard.tsx +216 -0
  121. package/src/components/KbdHint/KbdHint.tsx +23 -0
  122. package/src/components/KbdHint/index.ts +2 -0
  123. package/src/components/LabeledField/LabeledField.tsx +21 -0
  124. package/src/components/LabeledField/index.ts +2 -0
  125. package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
  126. package/src/components/LookupSelect/LookupSelect.tsx +325 -0
  127. package/src/components/LookupSelect/index.ts +2 -0
  128. package/src/components/Menu/Menu.css.ts +89 -0
  129. package/src/components/Menu/Menu.tsx +105 -0
  130. package/src/components/Menu/index.ts +2 -0
  131. package/src/components/MessageBox/MessageBox.tsx +168 -0
  132. package/src/components/MessageBox/index.ts +2 -0
  133. package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
  134. package/src/components/MetricDisplay/index.ts +1 -0
  135. package/src/components/MultiSelect/MultiSelect.tsx +278 -0
  136. package/src/components/MultiSelect/index.ts +2 -0
  137. package/src/components/Notifications/Notifications.tsx +12 -0
  138. package/src/components/Notifications/README.md +93 -0
  139. package/src/components/Notifications/index.ts +4 -0
  140. package/src/components/Notifications/showToast.tsx +100 -0
  141. package/src/components/PropertyRow/PropertyRow.tsx +96 -0
  142. package/src/components/PropertyRow/index.ts +2 -0
  143. package/src/components/RadioTile/RadioTile.tsx +253 -0
  144. package/src/components/RadioTile/index.ts +2 -0
  145. package/src/components/RichText/FormattingToolbar.css.ts +69 -0
  146. package/src/components/RichText/FormattingToolbar.tsx +112 -0
  147. package/src/components/RichText/RichTextInline.css.ts +54 -0
  148. package/src/components/RichText/RichTextInline.tsx +318 -0
  149. package/src/components/RichText/formattingCommands.ts +181 -0
  150. package/src/components/RichText/formattingTypes.ts +34 -0
  151. package/src/components/RichText/index.ts +49 -0
  152. package/src/components/RichText/richTextExtensions.ts +111 -0
  153. package/src/components/RichText/richTextHelpers.ts +65 -0
  154. package/src/components/RichText/richTextImage.ts +253 -0
  155. package/src/components/RichText/richTextImageHandlers.ts +244 -0
  156. package/src/components/RichText/richTextProse.css.ts +261 -0
  157. package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
  158. package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
  159. package/src/components/RichTextEditor/index.ts +2 -0
  160. package/src/components/RichTextView/RichTextView.css.ts +11 -0
  161. package/src/components/RichTextView/RichTextView.tsx +114 -0
  162. package/src/components/RichTextView/index.ts +2 -0
  163. package/src/components/Schedule/Schedule.tsx +35 -0
  164. package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
  165. package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
  166. package/src/components/SchedulePicker/index.ts +2 -0
  167. package/src/components/SearchableList/types.ts +30 -0
  168. package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
  169. package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
  170. package/src/components/SearchableSubMenu/index.ts +2 -0
  171. package/src/components/Select/README.md +114 -0
  172. package/src/components/Select/Select.css.ts +110 -0
  173. package/src/components/Select/Select.tsx +133 -0
  174. package/src/components/Select/index.ts +2 -0
  175. package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
  176. package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
  177. package/src/components/SelectCreatable/index.ts +2 -0
  178. package/src/components/SettingsCard/SettingsCard.tsx +98 -0
  179. package/src/components/SettingsCard/index.ts +2 -0
  180. package/src/components/Sidebar/Sidebar.css.ts +91 -0
  181. package/src/components/Sidebar/Sidebar.tsx +129 -0
  182. package/src/components/Sidebar/index.ts +5 -0
  183. package/src/components/SimpleList/SimpleList.css.ts +12 -0
  184. package/src/components/SimpleList/SimpleList.tsx +44 -0
  185. package/src/components/SimpleList/index.ts +2 -0
  186. package/src/components/SimpleTable/SimpleTable.tsx +296 -0
  187. package/src/components/SimpleTable/index.ts +2 -0
  188. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
  189. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
  190. package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
  191. package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
  192. package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
  193. package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
  194. package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
  195. package/src/components/SlashRichTextEditor/index.ts +13 -0
  196. package/src/components/SlashRichTextEditor/types.ts +48 -0
  197. package/src/components/StatCard/StatCard.css.ts +70 -0
  198. package/src/components/StatCard/StatCard.tsx +201 -0
  199. package/src/components/StatCard/index.ts +1 -0
  200. package/src/components/StatusBadge/StatusBadge.tsx +70 -0
  201. package/src/components/StatusBadge/index.ts +2 -0
  202. package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
  203. package/src/components/StatusIndicator/index.ts +6 -0
  204. package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
  205. package/src/components/SubNavigation/SubNavigation.tsx +104 -0
  206. package/src/components/SubNavigation/index.ts +2 -0
  207. package/src/components/SuspenseLoader.tsx +22 -0
  208. package/src/components/Table/SortableColumnHeader.tsx +99 -0
  209. package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
  210. package/src/components/Table/TableSkeletonRows.tsx +113 -0
  211. package/src/components/Table/index.ts +9 -0
  212. package/src/components/TableActionsMenu.tsx +58 -0
  213. package/src/components/TableCard.tsx +29 -0
  214. package/src/components/TableContainer/TableContainer.tsx +86 -0
  215. package/src/components/TableContainer/index.ts +2 -0
  216. package/src/components/TableControlBar/TableControlBar.tsx +156 -0
  217. package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
  218. package/src/components/TableControlBar/index.ts +13 -0
  219. package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
  220. package/src/components/TableSelection/TableSelection.tsx +43 -0
  221. package/src/components/TableSelection/index.ts +5 -0
  222. package/src/components/Tabs/README.md +76 -0
  223. package/src/components/Tabs/Tabs.css.ts +54 -0
  224. package/src/components/Tabs/Tabs.figma.tsx +47 -0
  225. package/src/components/Tabs/Tabs.tsx +96 -0
  226. package/src/components/Tabs/index.ts +8 -0
  227. package/src/components/TextInput/README.md +98 -0
  228. package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
  229. package/src/components/TextInput/SearchTextInput.tsx +150 -0
  230. package/src/components/TextInput/TextInput.figma.tsx +44 -0
  231. package/src/components/TextInput/TextInput.tsx +42 -0
  232. package/src/components/TextInput/index.ts +4 -0
  233. package/src/components/ThemeSwitcher.figma.tsx +28 -0
  234. package/src/components/ThemeSwitcher.tsx +69 -0
  235. package/src/components/TrendBadge/TrendBadge.tsx +76 -0
  236. package/src/components/TrendBadge/index.ts +2 -0
  237. package/src/components/TruncatedText.tsx +115 -0
  238. package/src/components/Typography/Text.tsx +74 -0
  239. package/src/components/Typography/Title.tsx +100 -0
  240. package/src/components/Typography/index.ts +4 -0
  241. package/src/geist-fonts.ts +48 -0
  242. package/src/hooks/index.ts +31 -0
  243. package/src/hooks/useFilters.ts +152 -0
  244. package/src/hooks/useInfiniteScroll.ts +62 -0
  245. package/src/hooks/usePlatform.ts +33 -0
  246. package/src/hooks/useServerTable.ts +495 -0
  247. package/src/hooks/useTableSelection.ts +102 -0
  248. package/src/hooks/useTableSort.ts +259 -0
  249. package/src/index.ts +483 -0
  250. package/src/mantine.ts +25 -0
  251. package/src/theme/mantineVars.ts +12 -0
  252. package/src/theme/themeContract.css.ts +131 -0
  253. package/src/theme/themeVars.ts +31 -0
  254. package/src/theme.ts +168 -0
  255. package/src/tokens/color-types.ts +107 -0
  256. package/src/tokens/colors.ts +243 -0
  257. package/src/tokens/index.ts +14 -0
  258. package/src/tokens/radius.ts +17 -0
  259. package/src/tokens/semantic-colors.ts +224 -0
  260. package/src/tokens/semantic-tokens-css.ts +53 -0
  261. package/src/tokens/shadows.ts +11 -0
  262. package/src/tokens/spacing.ts +20 -0
  263. package/src/tokens/text-styles.ts +179 -0
  264. package/src/tokens/typography.ts +40 -0
  265. package/src/tokens/zIndex.ts +27 -0
  266. package/src/types/mantine-theme.d.ts +17 -0
  267. package/src/types/tanstack-table.d.ts +22 -0
  268. package/src/utils/avatar.ts +150 -0
  269. package/src/utils/chartHelpers.ts +53 -0
  270. package/src/utils/color-props.ts +77 -0
  271. package/src/utils/createDesignComponent.tsx +104 -0
  272. package/src/utils/nestFlatRows.ts +111 -0
  273. package/src/utils/withStaticComponents.ts +6 -0
@@ -0,0 +1,65 @@
1
+ import type { JSONContent } from '@tiptap/react';
2
+
3
+ /**
4
+ * Shared helpers for ProseMirror / Tiptap document manipulation used
5
+ * across `RichTextEditor`, `RichTextView`, and `EditableRichText`.
6
+ */
7
+
8
+ export function normalizeValue(value: JSONContent | null): JSONContent {
9
+ if (value != null) {
10
+ return value;
11
+ }
12
+
13
+ return {
14
+ type: 'doc',
15
+ content: [{ type: 'paragraph' }],
16
+ };
17
+ }
18
+
19
+ export function docsEqual(a: JSONContent, b: JSONContent): boolean {
20
+ return JSON.stringify(a) === JSON.stringify(b);
21
+ }
22
+
23
+ export function plainTextFromDoc(
24
+ value: JSONContent | null | undefined,
25
+ ): string {
26
+ if (!value) {
27
+ return '';
28
+ }
29
+
30
+ if (typeof value.text === 'string') {
31
+ return value.text;
32
+ }
33
+
34
+ if (!Array.isArray(value.content)) {
35
+ return '';
36
+ }
37
+
38
+ return value.content
39
+ .map(item => plainTextFromDoc(item))
40
+ .join('\n')
41
+ .trim();
42
+ }
43
+
44
+ export function richTextDocFromPlainText(text: string): JSONContent {
45
+ return {
46
+ type: 'doc',
47
+ content: [
48
+ {
49
+ type: 'paragraph',
50
+ content: text
51
+ ? [
52
+ {
53
+ type: 'text',
54
+ text,
55
+ },
56
+ ]
57
+ : [],
58
+ },
59
+ ],
60
+ };
61
+ }
62
+
63
+ export function isDocEmpty(value: JSONContent | null | undefined): boolean {
64
+ return plainTextFromDoc(value).length === 0;
65
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Configurable image upload + lazy load support for `@scalepad/ui` rich-text
3
+ * editors.
4
+ *
5
+ * The design system intentionally doesn't know how to talk to a backend, so
6
+ * consumers wire two callbacks:
7
+ *
8
+ * - `uploadImage(blob, file)` — persist the uploaded blob and return a
9
+ * stable `fileId` the doc can carry. The editor stores only the id; the
10
+ * `src` attribute stays empty until the loader resolves it.
11
+ * - `loadImage(fileId)` — resolve a stored `fileId` back into a renderable
12
+ * `src` (data URL, signed CDN URL — whatever the caller prefers). Returns
13
+ * `null`/`undefined` to leave the image in the loading state.
14
+ *
15
+ * Both are optional. The hook surface enforces opt-in behaviour so the
16
+ * legacy "load 200 sections at once" rate-limit problem doesn't bite — see
17
+ * `autoLoad` on {@link useRichTextImageLoader}.
18
+ */
19
+ import { useCallback, useEffect, useRef } from 'react';
20
+
21
+ import type { Editor } from '@tiptap/react';
22
+
23
+ import {
24
+ RICH_TEXT_FILE_ID_ATTRIBUTE,
25
+ RICH_TEXT_IMAGE_CLASS,
26
+ } from './richTextExtensions';
27
+
28
+ /**
29
+ * Bytes after WebP conversion. Matches the legacy editor's limit so users
30
+ * with existing instincts about "what fits" don't have to relearn it.
31
+ */
32
+ export const DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES = 3 * 1024 * 1024;
33
+
34
+ export interface RichTextImageUploadResult {
35
+ /** Stable identifier the consumer can use to fetch / replace the image. */
36
+ fileId: string;
37
+ }
38
+
39
+ export interface RichTextImageUploadError {
40
+ /** User-facing error message; the editor inserts it as a paragraph. */
41
+ error: string;
42
+ }
43
+
44
+ export type RichTextImageUploadResponse =
45
+ | RichTextImageUploadResult
46
+ | RichTextImageUploadError;
47
+
48
+ export interface RichTextImageUploadConfig {
49
+ /**
50
+ * Persist the uploaded blob (already converted to WebP) and return the
51
+ * stable file id. Throw or return `{ error }` to surface an inline error
52
+ * to the user.
53
+ */
54
+ uploadImage: (
55
+ blob: Blob,
56
+ originalFile: File,
57
+ ) => Promise<RichTextImageUploadResponse>;
58
+ /**
59
+ * Override the post-WebP-conversion size limit. Defaults to
60
+ * {@link DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES} (3 MB) so behaviour matches
61
+ * the legacy editor.
62
+ */
63
+ maxBytes?: number;
64
+ /**
65
+ * WebP quality (0..1). Defaults to 0.8, matching the legacy editor.
66
+ */
67
+ quality?: number;
68
+ }
69
+
70
+ /**
71
+ * Build a callback that converts a `File` to WebP, validates its size, and
72
+ * defers to the consumer's `uploadImage` to persist it. Returns the file
73
+ * id on success, `{ error }` on validation failure / upload rejection.
74
+ */
75
+ export function useRichTextImageUploader({
76
+ uploadImage,
77
+ maxBytes = DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES,
78
+ quality = 0.8,
79
+ }: RichTextImageUploadConfig) {
80
+ return useCallback(
81
+ async (file: File): Promise<RichTextImageUploadResponse> => {
82
+ const blob = await convertToWebp(file, quality).catch(err => {
83
+ return {
84
+ error:
85
+ err instanceof Error ? err.message : 'Failed to process image.',
86
+ } as const;
87
+ });
88
+
89
+ if ('error' in (blob as object)) {
90
+ return blob as RichTextImageUploadError;
91
+ }
92
+
93
+ const webpBlob = blob as Blob;
94
+ if (webpBlob.size > maxBytes) {
95
+ const limitMb = Math.round(maxBytes / (1024 * 1024));
96
+ return {
97
+ error: `Image must be less than ${limitMb}MB, please try again.`,
98
+ };
99
+ }
100
+
101
+ try {
102
+ return await uploadImage(webpBlob, file);
103
+ } catch (err) {
104
+ return {
105
+ error: err instanceof Error ? err.message : 'Failed to upload image.',
106
+ };
107
+ }
108
+ },
109
+ [uploadImage, maxBytes, quality],
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Convert an image File into a WebP blob via an offscreen canvas. The
115
+ * implementation matches the legacy `useTiptapImageUploader` precisely so
116
+ * file ids minted in either editor stay interchangeable.
117
+ *
118
+ * Exported so the non-React paste/drop pipeline in
119
+ * `richTextImageHandlers` can share the same conversion path.
120
+ */
121
+ export function convertToWebp(file: File, quality: number): Promise<Blob> {
122
+ return new Promise((resolve, reject) => {
123
+ const canvas = document.createElement('canvas');
124
+ const ctx = canvas.getContext('2d');
125
+ if (!ctx) {
126
+ reject(new Error('Browser does not support canvas conversion.'));
127
+ return;
128
+ }
129
+ const img = new window.Image();
130
+ const objectUrl = URL.createObjectURL(file);
131
+
132
+ img.onload = () => {
133
+ canvas.width = img.width;
134
+ canvas.height = img.height;
135
+ ctx.drawImage(img, 0, 0);
136
+ URL.revokeObjectURL(objectUrl);
137
+ canvas.toBlob(
138
+ result => {
139
+ if (!result) {
140
+ reject(new Error('Failed to convert image to WebP.'));
141
+ return;
142
+ }
143
+ resolve(result);
144
+ },
145
+ 'image/webp',
146
+ quality,
147
+ );
148
+ };
149
+ img.onerror = () => {
150
+ URL.revokeObjectURL(objectUrl);
151
+ reject(new Error('Failed to load image.'));
152
+ };
153
+ img.src = objectUrl;
154
+ });
155
+ }
156
+
157
+ export interface RichTextImageLoaderConfig {
158
+ /**
159
+ * Resolve a stored file id into a renderable `src`. Returning `null` or
160
+ * `undefined` leaves the image in the loading state — useful when the
161
+ * caller wants to skip loading entirely for a specific id (e.g. behind a
162
+ * feature flag) without erroring.
163
+ */
164
+ loadImage: (fileId: string) => Promise<string | null | undefined>;
165
+ /**
166
+ * When `false` (the default), images stay in the loading state until the
167
+ * consumer calls the returned `loadImages` function manually. Surfaces
168
+ * that render many editors at once (Assessments renders ~200 sections
169
+ * simultaneously) MUST keep this off so they don't fan out 200 parallel
170
+ * blob fetches and trip the server's rate limiter.
171
+ *
172
+ * When `true`, the loader runs on every editor doc change.
173
+ */
174
+ autoLoad?: boolean;
175
+ }
176
+
177
+ export interface RichTextImageLoaderHandle {
178
+ /** Force-load every image with an unresolved `data-rich-text-file-id`. */
179
+ loadImages: () => Promise<void>;
180
+ }
181
+
182
+ /**
183
+ * Walk the editor DOM for image nodes that carry a `data-rich-text-file-id`
184
+ * but no usable `src`, and resolve each via the consumer-provided callback.
185
+ *
186
+ * The DOM is the source of truth here rather than the Tiptap doc JSON
187
+ * because the editor renders each node into a real `<img>` whose `src` we
188
+ * can mutate without recomputing the doc — which keeps the doc payload
189
+ * compact (just the file id) and avoids unnecessary onUpdate fires.
190
+ */
191
+ export function useRichTextImageLoader(
192
+ editor: Editor | null,
193
+ config: RichTextImageLoaderConfig | null | undefined,
194
+ ): RichTextImageLoaderHandle {
195
+ // Cache the latest loader callback so the imperative `loadImages` always
196
+ // hits the freshest implementation without re-running the auto-load
197
+ // effect on every render.
198
+ const loadImageRef = useRef<RichTextImageLoaderConfig['loadImage'] | null>(
199
+ config?.loadImage ?? null,
200
+ );
201
+ loadImageRef.current = config?.loadImage ?? null;
202
+
203
+ const loadImages = useCallback(async () => {
204
+ const load = loadImageRef.current;
205
+ if (!editor || !load) return;
206
+ const root = editor.view.dom as HTMLElement;
207
+ const images = root.querySelectorAll<HTMLImageElement>(
208
+ `img.${RICH_TEXT_IMAGE_CLASS}[${RICH_TEXT_FILE_ID_ATTRIBUTE}]`,
209
+ );
210
+ if (images.length === 0) return;
211
+ await Promise.all(
212
+ Array.from(images).map(async img => {
213
+ const fileId = img.getAttribute(RICH_TEXT_FILE_ID_ATTRIBUTE);
214
+ if (!fileId) return;
215
+ if (img.dataset.richTextImageState === 'loaded') return;
216
+ // `data:` srcs are placeholder thumbnails or already-resolved
217
+ // payloads; skip them so we don't re-fetch.
218
+ if (img.src && img.src.startsWith('data:')) return;
219
+ img.dataset.richTextImageState = 'loading';
220
+ try {
221
+ const src = await load(fileId);
222
+ if (src) {
223
+ img.src = src;
224
+ img.dataset.richTextImageState = 'loaded';
225
+ } else {
226
+ img.dataset.richTextImageState = 'idle';
227
+ }
228
+ } catch {
229
+ img.dataset.richTextImageState = 'error';
230
+ }
231
+ }),
232
+ );
233
+ }, [editor]);
234
+
235
+ // Optional auto-loader. The default is OFF — surfaces that render many
236
+ // sections at once opt out so they don't fan a request per section.
237
+ useEffect(() => {
238
+ if (!editor || !config?.autoLoad || !config.loadImage) return;
239
+ const handle = window.setTimeout(() => {
240
+ void loadImages();
241
+ }, 100);
242
+ const onUpdate = () => {
243
+ void loadImages();
244
+ };
245
+ editor.on('update', onUpdate);
246
+ return () => {
247
+ window.clearTimeout(handle);
248
+ editor.off('update', onUpdate);
249
+ };
250
+ }, [editor, config?.autoLoad, config?.loadImage, loadImages]);
251
+
252
+ return { loadImages };
253
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Helpers that bridge the design-system-agnostic image hooks
3
+ * ({@link useRichTextImageUploader}, {@link useRichTextImageLoader}) into
4
+ * the editor components' `editorProps`. Centralised here so every editor
5
+ * surface (`RichTextEditor`, `EditableRichText`, `SlashRichTextEditor`)
6
+ * speaks the same upload/insert/error contract.
7
+ */
8
+ import type { EditorView } from '@tiptap/pm/view';
9
+ import type { Editor } from '@tiptap/react';
10
+
11
+ import { RICH_TEXT_FILE_ID_ATTRIBUTE } from './richTextExtensions';
12
+ import {
13
+ convertToWebp,
14
+ DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES,
15
+ useRichTextImageLoader as useImageLoader,
16
+ type RichTextImageLoaderConfig,
17
+ type RichTextImageLoaderHandle,
18
+ type RichTextImageUploadConfig,
19
+ type RichTextImageUploadResponse,
20
+ } from './richTextImage';
21
+
22
+ const DEFAULT_UPLOAD_ERROR = 'Failed to upload image. Please try again.';
23
+
24
+ /**
25
+ * Combined image-handling config consumed by every editor in `@scalepad/ui`.
26
+ *
27
+ * The shape is opt-in at four layers:
28
+ *
29
+ * 1. If the consumer doesn't pass an `image` prop at all, the editor
30
+ * schema omits the Image extension entirely — so the user can't even
31
+ * paste an image into the document.
32
+ * 2. If `image.uploadImage` is omitted, paste / drop image data is
33
+ * ignored. (Useful for read-only-ish surfaces that should still render
34
+ * existing image nodes faithfully but never accept a new upload.)
35
+ * 3. If `image.loadImage` is omitted, images stay in their initial
36
+ * `data:` / blank state forever. (Useful when the consumer wants the
37
+ * editor to roundtrip the doc but the surface itself isn't responsible
38
+ * for fetching binary content.)
39
+ * 4. If `image.autoLoad` is omitted (or `false`), images stay in the
40
+ * loading state until the consumer calls `handle.loadImages()` from
41
+ * the returned handle.
42
+ *
43
+ * Surfaces that render many editors at once (e.g. Assessments — ~200
44
+ * sections simultaneously) MUST keep `autoLoad: false` and trigger
45
+ * `loadImages()` from a viewport observer to avoid rate-limiting the
46
+ * backend.
47
+ */
48
+ export interface RichTextImageEditorConfig
49
+ extends
50
+ Partial<RichTextImageUploadConfig>,
51
+ Partial<RichTextImageLoaderConfig> {
52
+ /**
53
+ * Called when a paste/drop upload fails. When provided, the error is
54
+ * surfaced through this callback (e.g. so the consumer can show a
55
+ * toast) instead of being inserted into the document as an editable
56
+ * paragraph the user has to delete.
57
+ *
58
+ * Omit to fall back to the legacy behaviour of inserting the error
59
+ * message as a paragraph at the drop location.
60
+ */
61
+ onError?: (message: string) => void;
62
+ }
63
+
64
+ const noopHandlers = {} as const;
65
+
66
+ /**
67
+ * Build the `editorProps.handlePaste` / `handleDrop` pair that uploads
68
+ * pasted / dropped image files via the consumer-provided callback and
69
+ * inserts an empty `image` node carrying the resulting
70
+ * `data-rich-text-file-id`.
71
+ *
72
+ * Returns an empty object when `image` is undefined or doesn't contain an
73
+ * `uploadImage` callback — that's the signal that the surface doesn't
74
+ * accept image input.
75
+ */
76
+ export function installRichTextImageHandlers(
77
+ image: RichTextImageEditorConfig | undefined,
78
+ ) {
79
+ if (!image?.uploadImage) return noopHandlers;
80
+
81
+ return {
82
+ handlePaste: (view: EditorView, event: ClipboardEvent) => {
83
+ const items = event.clipboardData?.items;
84
+ if (!items) return false;
85
+ for (const item of items) {
86
+ if (item.type.startsWith('image/')) {
87
+ const file = item.getAsFile();
88
+ if (file) {
89
+ event.preventDefault();
90
+ void uploadAndInsertImage(
91
+ view,
92
+ file,
93
+ view.state.selection.from,
94
+ image,
95
+ );
96
+ return true;
97
+ }
98
+ }
99
+ }
100
+ return false;
101
+ },
102
+ handleDrop: (view: EditorView, event: DragEvent) => {
103
+ const files = event.dataTransfer?.files;
104
+ if (!files || files.length === 0) return false;
105
+ const file = files[0];
106
+ if (!file || !file.type.startsWith('image/')) return false;
107
+ event.preventDefault();
108
+ const coords = view.posAtCoords({
109
+ left: event.clientX,
110
+ top: event.clientY,
111
+ });
112
+ void uploadAndInsertImage(
113
+ view,
114
+ file,
115
+ coords?.pos ?? view.state.selection.from,
116
+ image,
117
+ );
118
+ return true;
119
+ },
120
+ };
121
+ }
122
+
123
+ async function uploadAndInsertImage(
124
+ view: EditorView,
125
+ file: File,
126
+ insertPos: number,
127
+ image: RichTextImageEditorConfig,
128
+ ) {
129
+ if (!image.uploadImage) return;
130
+
131
+ const response = await runUploadPipeline(file, image);
132
+
133
+ // The upload is async and the user may have unmounted the editor (closed
134
+ // the modal, navigated away, etc.) while it was in flight. Bail out
135
+ // before touching `view.state` or dispatching, both of which throw on a
136
+ // destroyed view.
137
+ if (view.isDestroyed) return;
138
+
139
+ const safePos = clampInsertPos(view, insertPos);
140
+ if ('error' in response) {
141
+ const message = response.error || DEFAULT_UPLOAD_ERROR;
142
+ if (image.onError) {
143
+ image.onError(message);
144
+ } else {
145
+ insertErrorParagraph(view, safePos, message);
146
+ }
147
+ return;
148
+ }
149
+
150
+ const imageNode = view.state.schema.nodes.image;
151
+ if (!imageNode) return;
152
+ const tr = view.state.tr.insert(
153
+ safePos,
154
+ imageNode.create({
155
+ src: '',
156
+ [RICH_TEXT_FILE_ID_ATTRIBUTE]: response.fileId,
157
+ }),
158
+ );
159
+ view.dispatch(tr);
160
+ }
161
+
162
+ const DEFAULT_PROCESS_ERROR = 'Failed to process image. Please try again.';
163
+
164
+ /**
165
+ * Mirror of {@link useRichTextImageUploader} that runs outside React (so
166
+ * paste / drop handlers fire). Performs WebP conversion + size validation
167
+ * via the shared {@link convertToWebp} helper before delegating to the
168
+ * consumer's `uploadImage`.
169
+ *
170
+ * Unexpected exceptions from the conversion or the consumer's uploader
171
+ * are intentionally NOT echoed back to the caller — the raw `err.message`
172
+ * may contain stack traces, internal URLs, or library-internal text that
173
+ * shouldn't surface in the document or in a toast. We log the original
174
+ * for debugging and return a generic, user-facing string instead.
175
+ *
176
+ * Errors the consumer surfaces by *returning* `{ error }` from
177
+ * `uploadImage` are passed through as-is — those are the consumer's
178
+ * intentional user-facing messages.
179
+ */
180
+ async function runUploadPipeline(
181
+ file: File,
182
+ image: RichTextImageEditorConfig,
183
+ ): Promise<RichTextImageUploadResponse> {
184
+ let blob: Blob;
185
+ try {
186
+ blob = await convertToWebp(file, image.quality ?? 0.8);
187
+ } catch (err) {
188
+ console.warn('[richTextImageHandlers] Image conversion failed:', err);
189
+ return { error: DEFAULT_PROCESS_ERROR };
190
+ }
191
+
192
+ const maxBytes = image.maxBytes ?? DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES;
193
+ if (blob.size > maxBytes) {
194
+ const limitMb = Math.round(maxBytes / (1024 * 1024));
195
+ return {
196
+ error: `Image must be less than ${limitMb}MB, please try again.`,
197
+ };
198
+ }
199
+
200
+ try {
201
+ return await image.uploadImage!(blob, file);
202
+ } catch (err) {
203
+ console.warn('[richTextImageHandlers] Image upload failed:', err);
204
+ return { error: DEFAULT_UPLOAD_ERROR };
205
+ }
206
+ }
207
+
208
+ function clampInsertPos(view: EditorView, pos: number) {
209
+ return Math.max(0, Math.min(pos, view.state.doc.content.size));
210
+ }
211
+
212
+ function insertErrorParagraph(view: EditorView, pos: number, message: string) {
213
+ const { schema } = view.state;
214
+ const paragraph = schema.nodes.paragraph;
215
+ if (!paragraph) return;
216
+ const tr = view.state.tr.insert(
217
+ pos,
218
+ paragraph.create(null, [schema.text(message)]),
219
+ );
220
+ view.dispatch(tr);
221
+ }
222
+
223
+ /**
224
+ * Editor-side wrapper around the design-system-agnostic
225
+ * {@link useRichTextImageLoader}. Returns the loader handle so callers can
226
+ * trigger lazy loads (e.g. from a viewport intersection observer) without
227
+ * touching the editor.
228
+ */
229
+ export function useRichTextImageLoader(
230
+ editor: Editor | null,
231
+ image: RichTextImageEditorConfig | undefined,
232
+ ): RichTextImageLoaderHandle {
233
+ // Always call the hook so the rules-of-hooks check stays consistent
234
+ // even when the consumer doesn't pass an `image` config.
235
+ return useImageLoader(
236
+ editor,
237
+ image?.loadImage
238
+ ? {
239
+ loadImage: image.loadImage,
240
+ autoLoad: image.autoLoad,
241
+ }
242
+ : null,
243
+ );
244
+ }