@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.
- package/.ai/rules/date-handling.md +39 -0
- package/.ai/rules/figma-design-system.md +372 -0
- package/.ai/rules/figma-lm-design-system-keys.md +680 -0
- package/.ai/rules/file-extensions.md +13 -0
- package/.ai/rules/modal-confirmation-mutation.md +56 -0
- package/.ai/rules/react-hooks.md +29 -0
- package/.ai/rules/styling.md +83 -0
- package/AGENTS.md +37 -0
- package/README.md +125 -0
- package/figma.config.json +9 -0
- package/package.json +127 -0
- package/scripts/install-ai-rules.mjs +136 -0
- package/src/ThemeProvider.tsx +57 -0
- package/src/charts.ts +32 -0
- package/src/components/ActionCard/ActionCard.css.ts +60 -0
- package/src/components/ActionCard/ActionCard.tsx +154 -0
- package/src/components/ActionCard/index.ts +2 -0
- package/src/components/Anchor/Anchor.tsx +47 -0
- package/src/components/Anchor/index.ts +2 -0
- package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
- package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
- package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
- package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
- package/src/components/Badge/Badge.css.ts +72 -0
- package/src/components/Badge/Badge.figma.tsx +43 -0
- package/src/components/Badge/Badge.tsx +159 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
- package/src/components/BreadCrumb/index.ts +2 -0
- package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
- package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
- package/src/components/BulkActionBar/index.ts +2 -0
- package/src/components/Button/Button.css.ts +272 -0
- package/src/components/Button/Button.figma.tsx +74 -0
- package/src/components/Button/Button.tsx +84 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
- package/src/components/Charts/ChartTooltip.tsx +101 -0
- package/src/components/Charts/MiniBarSparkline.tsx +75 -0
- package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
- package/src/components/Charts/TrendAreaChart.css.ts +23 -0
- package/src/components/Charts/TrendAreaChart.tsx +210 -0
- package/src/components/Charts/index.ts +12 -0
- package/src/components/CodePanel/CodePanel.css.ts +113 -0
- package/src/components/CodePanel/CodePanel.tsx +121 -0
- package/src/components/CodePanel/index.ts +2 -0
- package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
- package/src/components/CommentComposer/CommentComposer.tsx +181 -0
- package/src/components/CommentComposer/index.ts +2 -0
- package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
- package/src/components/ConfirmationModal/index.ts +2 -0
- package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
- package/src/components/ConfirmationTooltip/index.ts +2 -0
- package/src/components/DataDialog.figma.tsx +33 -0
- package/src/components/DataDialog.tsx +46 -0
- package/src/components/DataTable/DataTable.tsx +1042 -0
- package/src/components/DataTable/RowExpandToggle.tsx +105 -0
- package/src/components/DataTable/RowGroupHeader.tsx +190 -0
- package/src/components/DataTable/createActionsColumn.tsx +86 -0
- package/src/components/DataTable/index.ts +25 -0
- package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
- package/src/components/DatePicker/DateInput.tsx +329 -0
- package/src/components/DatePicker/DateNavigator.tsx +486 -0
- package/src/components/DatePicker/DatePicker.tsx +242 -0
- package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
- package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
- package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
- package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
- package/src/components/DatePicker/index.ts +7 -0
- package/src/components/DatePicker/types.ts +12 -0
- package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
- package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
- package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
- package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
- package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
- package/src/components/DesignSystemPrimitives/index.ts +55 -0
- package/src/components/Details/Details.css.ts +74 -0
- package/src/components/Details/Details.tsx +140 -0
- package/src/components/Details/index.ts +2 -0
- package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
- package/src/components/DownloadCard/DownloadCard.tsx +63 -0
- package/src/components/DownloadCard/index.ts +2 -0
- package/src/components/Drawer/Drawer.css.ts +32 -0
- package/src/components/Drawer/Drawer.tsx +236 -0
- package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
- package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
- package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
- package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
- package/src/components/EditableRichText/EditableRichText.tsx +324 -0
- package/src/components/EditableRichText/index.ts +2 -0
- package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
- package/src/components/EditableSelect/EditableSelect.tsx +224 -0
- package/src/components/EditableSelect/index.ts +2 -0
- package/src/components/EditableText/EditableText.tsx +377 -0
- package/src/components/EditableText/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
- package/src/components/EmptyState/EmptyState.tsx +230 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/ErrorBoundary.tsx +135 -0
- package/src/components/ErrorState/ErrorState.tsx +197 -0
- package/src/components/ErrorState/index.ts +2 -0
- package/src/components/FeatureCard.tsx +42 -0
- package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
- package/src/components/FilterMenu/FilterMenu.tsx +198 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
- package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
- package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
- package/src/components/FilterMenu/helpers.ts +115 -0
- package/src/components/FilterMenu/index.ts +35 -0
- package/src/components/FilterMenu/types.ts +101 -0
- package/src/components/IconButton/IconButton.css.ts +272 -0
- package/src/components/IconButton/IconButton.figma.tsx +47 -0
- package/src/components/IconButton/IconButton.tsx +72 -0
- package/src/components/IconButton/README.md +230 -0
- package/src/components/IconButton/index.ts +2 -0
- package/src/components/InfiniteScrollSentinel.tsx +86 -0
- package/src/components/InfiniteScrollTrigger.tsx +78 -0
- package/src/components/InfoCard.figma.tsx +47 -0
- package/src/components/InfoCard.tsx +216 -0
- package/src/components/KbdHint/KbdHint.tsx +23 -0
- package/src/components/KbdHint/index.ts +2 -0
- package/src/components/LabeledField/LabeledField.tsx +21 -0
- package/src/components/LabeledField/index.ts +2 -0
- package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
- package/src/components/LookupSelect/LookupSelect.tsx +325 -0
- package/src/components/LookupSelect/index.ts +2 -0
- package/src/components/Menu/Menu.css.ts +89 -0
- package/src/components/Menu/Menu.tsx +105 -0
- package/src/components/Menu/index.ts +2 -0
- package/src/components/MessageBox/MessageBox.tsx +168 -0
- package/src/components/MessageBox/index.ts +2 -0
- package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
- package/src/components/MetricDisplay/index.ts +1 -0
- package/src/components/MultiSelect/MultiSelect.tsx +278 -0
- package/src/components/MultiSelect/index.ts +2 -0
- package/src/components/Notifications/Notifications.tsx +12 -0
- package/src/components/Notifications/README.md +93 -0
- package/src/components/Notifications/index.ts +4 -0
- package/src/components/Notifications/showToast.tsx +100 -0
- package/src/components/PropertyRow/PropertyRow.tsx +96 -0
- package/src/components/PropertyRow/index.ts +2 -0
- package/src/components/RadioTile/RadioTile.tsx +253 -0
- package/src/components/RadioTile/index.ts +2 -0
- package/src/components/RichText/FormattingToolbar.css.ts +69 -0
- package/src/components/RichText/FormattingToolbar.tsx +112 -0
- package/src/components/RichText/RichTextInline.css.ts +54 -0
- package/src/components/RichText/RichTextInline.tsx +318 -0
- package/src/components/RichText/formattingCommands.ts +181 -0
- package/src/components/RichText/formattingTypes.ts +34 -0
- package/src/components/RichText/index.ts +49 -0
- package/src/components/RichText/richTextExtensions.ts +111 -0
- package/src/components/RichText/richTextHelpers.ts +65 -0
- package/src/components/RichText/richTextImage.ts +253 -0
- package/src/components/RichText/richTextImageHandlers.ts +244 -0
- package/src/components/RichText/richTextProse.css.ts +261 -0
- package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
- package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
- package/src/components/RichTextEditor/index.ts +2 -0
- package/src/components/RichTextView/RichTextView.css.ts +11 -0
- package/src/components/RichTextView/RichTextView.tsx +114 -0
- package/src/components/RichTextView/index.ts +2 -0
- package/src/components/Schedule/Schedule.tsx +35 -0
- package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
- package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
- package/src/components/SchedulePicker/index.ts +2 -0
- package/src/components/SearchableList/types.ts +30 -0
- package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
- package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
- package/src/components/SearchableSubMenu/index.ts +2 -0
- package/src/components/Select/README.md +114 -0
- package/src/components/Select/Select.css.ts +110 -0
- package/src/components/Select/Select.tsx +133 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
- package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
- package/src/components/SelectCreatable/index.ts +2 -0
- package/src/components/SettingsCard/SettingsCard.tsx +98 -0
- package/src/components/SettingsCard/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.css.ts +91 -0
- package/src/components/Sidebar/Sidebar.tsx +129 -0
- package/src/components/Sidebar/index.ts +5 -0
- package/src/components/SimpleList/SimpleList.css.ts +12 -0
- package/src/components/SimpleList/SimpleList.tsx +44 -0
- package/src/components/SimpleList/index.ts +2 -0
- package/src/components/SimpleTable/SimpleTable.tsx +296 -0
- package/src/components/SimpleTable/index.ts +2 -0
- package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
- package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
- package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
- package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
- package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
- package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
- package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
- package/src/components/SlashRichTextEditor/index.ts +13 -0
- package/src/components/SlashRichTextEditor/types.ts +48 -0
- package/src/components/StatCard/StatCard.css.ts +70 -0
- package/src/components/StatCard/StatCard.tsx +201 -0
- package/src/components/StatCard/index.ts +1 -0
- package/src/components/StatusBadge/StatusBadge.tsx +70 -0
- package/src/components/StatusBadge/index.ts +2 -0
- package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
- package/src/components/StatusIndicator/index.ts +6 -0
- package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
- package/src/components/SubNavigation/SubNavigation.tsx +104 -0
- package/src/components/SubNavigation/index.ts +2 -0
- package/src/components/SuspenseLoader.tsx +22 -0
- package/src/components/Table/SortableColumnHeader.tsx +99 -0
- package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
- package/src/components/Table/TableSkeletonRows.tsx +113 -0
- package/src/components/Table/index.ts +9 -0
- package/src/components/TableActionsMenu.tsx +58 -0
- package/src/components/TableCard.tsx +29 -0
- package/src/components/TableContainer/TableContainer.tsx +86 -0
- package/src/components/TableContainer/index.ts +2 -0
- package/src/components/TableControlBar/TableControlBar.tsx +156 -0
- package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
- package/src/components/TableControlBar/index.ts +13 -0
- package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
- package/src/components/TableSelection/TableSelection.tsx +43 -0
- package/src/components/TableSelection/index.ts +5 -0
- package/src/components/Tabs/README.md +76 -0
- package/src/components/Tabs/Tabs.css.ts +54 -0
- package/src/components/Tabs/Tabs.figma.tsx +47 -0
- package/src/components/Tabs/Tabs.tsx +96 -0
- package/src/components/Tabs/index.ts +8 -0
- package/src/components/TextInput/README.md +98 -0
- package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
- package/src/components/TextInput/SearchTextInput.tsx +150 -0
- package/src/components/TextInput/TextInput.figma.tsx +44 -0
- package/src/components/TextInput/TextInput.tsx +42 -0
- package/src/components/TextInput/index.ts +4 -0
- package/src/components/ThemeSwitcher.figma.tsx +28 -0
- package/src/components/ThemeSwitcher.tsx +69 -0
- package/src/components/TrendBadge/TrendBadge.tsx +76 -0
- package/src/components/TrendBadge/index.ts +2 -0
- package/src/components/TruncatedText.tsx +115 -0
- package/src/components/Typography/Text.tsx +74 -0
- package/src/components/Typography/Title.tsx +100 -0
- package/src/components/Typography/index.ts +4 -0
- package/src/geist-fonts.ts +48 -0
- package/src/hooks/index.ts +31 -0
- package/src/hooks/useFilters.ts +152 -0
- package/src/hooks/useInfiniteScroll.ts +62 -0
- package/src/hooks/usePlatform.ts +33 -0
- package/src/hooks/useServerTable.ts +495 -0
- package/src/hooks/useTableSelection.ts +102 -0
- package/src/hooks/useTableSort.ts +259 -0
- package/src/index.ts +483 -0
- package/src/mantine.ts +25 -0
- package/src/theme/mantineVars.ts +12 -0
- package/src/theme/themeContract.css.ts +131 -0
- package/src/theme/themeVars.ts +31 -0
- package/src/theme.ts +168 -0
- package/src/tokens/color-types.ts +107 -0
- package/src/tokens/colors.ts +243 -0
- package/src/tokens/index.ts +14 -0
- package/src/tokens/radius.ts +17 -0
- package/src/tokens/semantic-colors.ts +224 -0
- package/src/tokens/semantic-tokens-css.ts +53 -0
- package/src/tokens/shadows.ts +11 -0
- package/src/tokens/spacing.ts +20 -0
- package/src/tokens/text-styles.ts +179 -0
- package/src/tokens/typography.ts +40 -0
- package/src/tokens/zIndex.ts +27 -0
- package/src/types/mantine-theme.d.ts +17 -0
- package/src/types/tanstack-table.d.ts +22 -0
- package/src/utils/avatar.ts +150 -0
- package/src/utils/chartHelpers.ts +53 -0
- package/src/utils/color-props.ts +77 -0
- package/src/utils/createDesignComponent.tsx +104 -0
- package/src/utils/nestFlatRows.ts +111 -0
- 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
|
+
}
|