@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,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RichTextInline
|
|
3
|
+
*
|
|
4
|
+
* Single-line preview renderer for Tiptap / ProseMirror `JSONContent`.
|
|
5
|
+
* Walks the doc, flattens its block structure into an array of "logical
|
|
6
|
+
* lines" (one per paragraph / heading / list item / blockquote / code
|
|
7
|
+
* block), drops empty lines, and joins the non-empty ones with a single
|
|
8
|
+
* subtle dot separator. Inline marks (`<strong>`, `<em>`, `<code>`,
|
|
9
|
+
* `<u>`, `<s>`, `<a>`) are preserved so a bold word in the editor stays
|
|
10
|
+
* bold in the row preview.
|
|
11
|
+
*
|
|
12
|
+
* Use this for cell-style previews. For full multi-line rendering, use
|
|
13
|
+
* `RichTextView`.
|
|
14
|
+
*/
|
|
15
|
+
import { Fragment, type ReactNode } from 'react';
|
|
16
|
+
|
|
17
|
+
import * as classes from './RichTextInline.css';
|
|
18
|
+
|
|
19
|
+
import type { JSONContent } from '@tiptap/react';
|
|
20
|
+
|
|
21
|
+
export interface RichTextInlineProps {
|
|
22
|
+
value: JSONContent | null;
|
|
23
|
+
/** Placeholder rendered when the doc is empty. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MarkSpec {
|
|
29
|
+
type: string;
|
|
30
|
+
attrs?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One flattened logical line of the doc. The renderer walks the JSON
|
|
35
|
+
* tree, emits a `Segment` per visible block, and finally joins them
|
|
36
|
+
* with a single separator. Each segment also carries its raw text so
|
|
37
|
+
* we can quickly drop blocks whose visible text is empty/whitespace
|
|
38
|
+
* (Tiptap loves empty paragraphs from accidental Enter presses).
|
|
39
|
+
*/
|
|
40
|
+
interface Segment {
|
|
41
|
+
key: string;
|
|
42
|
+
rawText: string;
|
|
43
|
+
/** Optional decoration wrapper — heading weight, list bullet, blockquote italic. */
|
|
44
|
+
prefix?: ReactNode;
|
|
45
|
+
wrapper?: 'heading1' | 'heading2' | 'blockquote' | 'codeBlock' | null;
|
|
46
|
+
inline: ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wrap a leaf text node in the React equivalents of its ProseMirror marks
|
|
51
|
+
* (e.g. `<strong>` for `bold`, `<a>` for `link`). Marks are nested in
|
|
52
|
+
* declaration order so the outermost wrapper is the first mark; that
|
|
53
|
+
* matches how Tiptap serializes them and keeps anchor / inline-code
|
|
54
|
+
* styling deterministic.
|
|
55
|
+
*/
|
|
56
|
+
function renderMarkedText(
|
|
57
|
+
text: string,
|
|
58
|
+
marks: MarkSpec[] | undefined,
|
|
59
|
+
): ReactNode {
|
|
60
|
+
if (!marks || marks.length === 0) return text;
|
|
61
|
+
return marks.reduceRight<ReactNode>((child, mark) => {
|
|
62
|
+
switch (mark.type) {
|
|
63
|
+
case 'bold':
|
|
64
|
+
return <strong>{child}</strong>;
|
|
65
|
+
case 'italic':
|
|
66
|
+
return <em>{child}</em>;
|
|
67
|
+
case 'code':
|
|
68
|
+
return <code className={classes.code}>{child}</code>;
|
|
69
|
+
case 'underline':
|
|
70
|
+
return <u>{child}</u>;
|
|
71
|
+
case 'strike':
|
|
72
|
+
return <s>{child}</s>;
|
|
73
|
+
case 'link': {
|
|
74
|
+
const href =
|
|
75
|
+
(mark.attrs &&
|
|
76
|
+
typeof mark.attrs.href === 'string' &&
|
|
77
|
+
mark.attrs.href) ||
|
|
78
|
+
undefined;
|
|
79
|
+
return (
|
|
80
|
+
<a
|
|
81
|
+
className={classes.link}
|
|
82
|
+
href={href}
|
|
83
|
+
target="_blank"
|
|
84
|
+
rel="noreferrer noopener"
|
|
85
|
+
>
|
|
86
|
+
{child}
|
|
87
|
+
</a>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return child;
|
|
92
|
+
}
|
|
93
|
+
}, text);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface InlineResult {
|
|
97
|
+
nodes: ReactNode[];
|
|
98
|
+
text: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Walk the inline children of a block (text + hardBreak only) and
|
|
103
|
+
* produce both the rendered React nodes and the raw text. Anything
|
|
104
|
+
* that isn't an inline leaf is ignored — block-level descendants are
|
|
105
|
+
* picked up by the outer flattener and become their own segments.
|
|
106
|
+
*/
|
|
107
|
+
function renderInline(
|
|
108
|
+
nodes: JSONContent[] | undefined,
|
|
109
|
+
keyPrefix: string,
|
|
110
|
+
): InlineResult {
|
|
111
|
+
if (!nodes || nodes.length === 0) return { nodes: [], text: '' };
|
|
112
|
+
|
|
113
|
+
const out: ReactNode[] = [];
|
|
114
|
+
let text = '';
|
|
115
|
+
|
|
116
|
+
nodes.forEach((node, idx) => {
|
|
117
|
+
const key = `${keyPrefix}-${idx}`;
|
|
118
|
+
switch (node.type) {
|
|
119
|
+
case 'text': {
|
|
120
|
+
const value = node.text ?? '';
|
|
121
|
+
text += value;
|
|
122
|
+
out.push(
|
|
123
|
+
<Fragment key={key}>
|
|
124
|
+
{renderMarkedText(value, node.marks as MarkSpec[] | undefined)}
|
|
125
|
+
</Fragment>,
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'hardBreak':
|
|
130
|
+
text += ' ';
|
|
131
|
+
out.push(<Fragment key={key}> </Fragment>);
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
// Block-level descendant; surface its inline text so the
|
|
135
|
+
// wrapper paragraph stays non-empty for filtering, but let
|
|
136
|
+
// the outer walker handle the block itself.
|
|
137
|
+
if (Array.isArray(node.content)) {
|
|
138
|
+
const child = renderInline(node.content, key);
|
|
139
|
+
text += child.text;
|
|
140
|
+
out.push(<Fragment key={key}>{child.nodes}</Fragment>);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return { nodes: out, text };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recursively flatten the doc into an ordered array of inline
|
|
151
|
+
* segments. Each call appends to `acc` so callers can capture
|
|
152
|
+
* structure (heading vs list item vs paragraph) without having to
|
|
153
|
+
* thread separators through the tree.
|
|
154
|
+
*/
|
|
155
|
+
function collectSegments(
|
|
156
|
+
nodes: JSONContent[] | undefined,
|
|
157
|
+
acc: Segment[],
|
|
158
|
+
keyPrefix: string,
|
|
159
|
+
context: { listMarker?: string } = {},
|
|
160
|
+
): void {
|
|
161
|
+
if (!nodes || nodes.length === 0) return;
|
|
162
|
+
|
|
163
|
+
nodes.forEach((node, idx) => {
|
|
164
|
+
const key = `${keyPrefix}-${idx}-${node.type}`;
|
|
165
|
+
|
|
166
|
+
switch (node.type) {
|
|
167
|
+
case 'paragraph': {
|
|
168
|
+
const inline = renderInline(node.content, key);
|
|
169
|
+
acc.push({
|
|
170
|
+
key,
|
|
171
|
+
rawText: inline.text,
|
|
172
|
+
inline: inline.nodes,
|
|
173
|
+
prefix: context.listMarker ? (
|
|
174
|
+
<span className={classes.bullet}>{context.listMarker}</span>
|
|
175
|
+
) : undefined,
|
|
176
|
+
wrapper: null,
|
|
177
|
+
});
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'heading': {
|
|
182
|
+
const level = (node.attrs?.level as number | undefined) ?? 1;
|
|
183
|
+
const inline = renderInline(node.content, key);
|
|
184
|
+
acc.push({
|
|
185
|
+
key,
|
|
186
|
+
rawText: inline.text,
|
|
187
|
+
inline: inline.nodes,
|
|
188
|
+
wrapper: level === 1 ? 'heading1' : 'heading2',
|
|
189
|
+
prefix: context.listMarker ? (
|
|
190
|
+
<span className={classes.bullet}>{context.listMarker}</span>
|
|
191
|
+
) : undefined,
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'blockquote': {
|
|
197
|
+
// A blockquote can contain either inline-only content (the common
|
|
198
|
+
// case: `<blockquote><p>quote</p></blockquote>`) or block-level
|
|
199
|
+
// children (lists, nested blockquotes, code blocks). When all the
|
|
200
|
+
// children flatten to inline content, emit a single quoted segment
|
|
201
|
+
// for the whole quote. When there are block-level children, defer
|
|
202
|
+
// entirely to the recursive walker so we don't double-count.
|
|
203
|
+
const children = node.content ?? [];
|
|
204
|
+
const hasBlockLevelChild = children.some(
|
|
205
|
+
child =>
|
|
206
|
+
child.type !== 'paragraph' &&
|
|
207
|
+
child.type !== 'text' &&
|
|
208
|
+
child.type !== 'hardBreak',
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!hasBlockLevelChild) {
|
|
212
|
+
const inline = renderInline(node.content, key);
|
|
213
|
+
acc.push({
|
|
214
|
+
key,
|
|
215
|
+
rawText: inline.text,
|
|
216
|
+
inline: inline.nodes,
|
|
217
|
+
wrapper: 'blockquote',
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
children.forEach((child, childIdx) => {
|
|
221
|
+
collectSegments([child], acc, `${key}-${childIdx}`);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case 'codeBlock': {
|
|
228
|
+
const inline = renderInline(node.content, key);
|
|
229
|
+
acc.push({
|
|
230
|
+
key,
|
|
231
|
+
rawText: inline.text,
|
|
232
|
+
inline: inline.nodes,
|
|
233
|
+
wrapper: 'codeBlock',
|
|
234
|
+
});
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case 'bulletList':
|
|
239
|
+
case 'orderedList': {
|
|
240
|
+
const items = node.content ?? [];
|
|
241
|
+
const isOrdered = node.type === 'orderedList';
|
|
242
|
+
items.forEach((item, itemIdx) => {
|
|
243
|
+
const marker = isOrdered ? `${itemIdx + 1}.` : '•';
|
|
244
|
+
collectSegments(item.content, acc, `${key}-i${itemIdx}`, {
|
|
245
|
+
listMarker: marker,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case 'listItem':
|
|
252
|
+
collectSegments(node.content, acc, key, context);
|
|
253
|
+
break;
|
|
254
|
+
|
|
255
|
+
default:
|
|
256
|
+
if (Array.isArray(node.content) && node.content.length > 0) {
|
|
257
|
+
collectSegments(node.content, acc, key, context);
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isWhitespace(s: string): boolean {
|
|
265
|
+
return s.replace(/\s+/g, '').length === 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderSegmentBody(seg: Segment): ReactNode {
|
|
269
|
+
switch (seg.wrapper) {
|
|
270
|
+
case 'heading1':
|
|
271
|
+
return <span className={classes.headingStrong}>{seg.inline}</span>;
|
|
272
|
+
case 'heading2':
|
|
273
|
+
return <span className={classes.headingMedium}>{seg.inline}</span>;
|
|
274
|
+
case 'blockquote':
|
|
275
|
+
return <em className={classes.blockquote}>{seg.inline}</em>;
|
|
276
|
+
case 'codeBlock':
|
|
277
|
+
return <code className={classes.code}>{seg.inline}</code>;
|
|
278
|
+
default:
|
|
279
|
+
return seg.inline;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function RichTextInline({
|
|
284
|
+
value,
|
|
285
|
+
placeholder,
|
|
286
|
+
className,
|
|
287
|
+
}: RichTextInlineProps) {
|
|
288
|
+
const segments: Segment[] = [];
|
|
289
|
+
if (value?.content) {
|
|
290
|
+
collectSegments(value.content, segments, 'root');
|
|
291
|
+
}
|
|
292
|
+
const visible = segments.filter(seg => !isWhitespace(seg.rawText));
|
|
293
|
+
|
|
294
|
+
if (visible.length === 0) {
|
|
295
|
+
if (!placeholder) return null;
|
|
296
|
+
return (
|
|
297
|
+
<span className={[classes.empty, className].filter(Boolean).join(' ')}>
|
|
298
|
+
{placeholder}
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<span className={[classes.root, className].filter(Boolean).join(' ')}>
|
|
305
|
+
{visible.map((seg, idx) => (
|
|
306
|
+
<Fragment key={seg.key}>
|
|
307
|
+
{idx > 0 ? (
|
|
308
|
+
<span className={classes.separator} aria-hidden>
|
|
309
|
+
·
|
|
310
|
+
</span>
|
|
311
|
+
) : null}
|
|
312
|
+
{seg.prefix}
|
|
313
|
+
{renderSegmentBody(seg)}
|
|
314
|
+
</Fragment>
|
|
315
|
+
))}
|
|
316
|
+
</span>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bold,
|
|
3
|
+
Heading1,
|
|
4
|
+
Heading2,
|
|
5
|
+
Heading3,
|
|
6
|
+
Italic,
|
|
7
|
+
Link2,
|
|
8
|
+
List,
|
|
9
|
+
ListOrdered,
|
|
10
|
+
TextQuote,
|
|
11
|
+
Underline as UnderlineIcon,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
import type { FormattingToolbarCommand } from './formattingTypes';
|
|
15
|
+
|
|
16
|
+
import type { ChainedCommands, Editor } from '@tiptap/react';
|
|
17
|
+
|
|
18
|
+
type FormattingRunArgs = Parameters<FormattingToolbarCommand['run']>[0];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Most formatting commands share the same shape: focus the editor, drop the
|
|
22
|
+
* slash trigger range when invoked from the slash menu, apply one chained
|
|
23
|
+
* mutation, and dispatch. Centralising it here keeps the command list small
|
|
24
|
+
* and removes the copy/paste risk of forgetting `.focus()` or the range
|
|
25
|
+
* delete on a new command.
|
|
26
|
+
*/
|
|
27
|
+
function runWithChain(
|
|
28
|
+
apply: (chain: ChainedCommands, editor: Editor) => ChainedCommands,
|
|
29
|
+
) {
|
|
30
|
+
return ({ editor, range }: FormattingRunArgs) => {
|
|
31
|
+
const chain = editor.chain().focus();
|
|
32
|
+
if (range) chain.deleteRange(range);
|
|
33
|
+
apply(chain, editor).run();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Toggles a heading at the requested level. Tiptap's `toggleHeading` returns
|
|
39
|
+
* to a paragraph if the same level is already active, which matches the
|
|
40
|
+
* Notion-style "press H2 again to demote" expectation.
|
|
41
|
+
*/
|
|
42
|
+
function toggleHeading(level: 1 | 2 | 3) {
|
|
43
|
+
return runWithChain(chain => chain.toggleHeading({ level }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lightweight prompt-driven URL flow. Replace with a dedicated dialog when
|
|
48
|
+
* the design team ships one; until then this matches the historical
|
|
49
|
+
* `<RichTextToolbar>` link UX.
|
|
50
|
+
*/
|
|
51
|
+
function promptForLink({ editor, range }: FormattingRunArgs) {
|
|
52
|
+
const chain = editor.chain().focus();
|
|
53
|
+
if (range) chain.deleteRange(range);
|
|
54
|
+
chain.run();
|
|
55
|
+
|
|
56
|
+
const previousUrl = editor.getAttributes('link').href as string | undefined;
|
|
57
|
+
// eslint-disable-next-line no-alert -- interim until a dedicated link dialog ships.
|
|
58
|
+
const nextUrl = window.prompt('Enter a URL', previousUrl ?? '');
|
|
59
|
+
if (nextUrl == null) return;
|
|
60
|
+
if (nextUrl.trim() === '') {
|
|
61
|
+
editor.chain().focus().unsetLink().run();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
editor.chain().focus().setLink({ href: nextUrl.trim() }).run();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default toolbar layout — 9 buttons split into block (H1-H3, bullet,
|
|
69
|
+
* numbered, blockquote) and inline (bold, italic, underline, link) groups.
|
|
70
|
+
* Order matters: `<FormattingToolbar>` reads it left-to-right and inserts a
|
|
71
|
+
* vertical divider whenever `group` changes.
|
|
72
|
+
*/
|
|
73
|
+
export const STANDARD_FORMATTING_COMMANDS: FormattingToolbarCommand[] = [
|
|
74
|
+
{
|
|
75
|
+
id: 'h1',
|
|
76
|
+
icon: Heading1,
|
|
77
|
+
label: 'Heading 1',
|
|
78
|
+
markdown: '#',
|
|
79
|
+
group: 'block',
|
|
80
|
+
isActive: editor => editor.isActive('heading', { level: 1 }),
|
|
81
|
+
run: toggleHeading(1),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'h2',
|
|
85
|
+
icon: Heading2,
|
|
86
|
+
label: 'Heading 2',
|
|
87
|
+
markdown: '##',
|
|
88
|
+
group: 'block',
|
|
89
|
+
isActive: editor => editor.isActive('heading', { level: 2 }),
|
|
90
|
+
run: toggleHeading(2),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'h3',
|
|
94
|
+
icon: Heading3,
|
|
95
|
+
label: 'Heading 3',
|
|
96
|
+
markdown: '###',
|
|
97
|
+
group: 'block',
|
|
98
|
+
isActive: editor => editor.isActive('heading', { level: 3 }),
|
|
99
|
+
run: toggleHeading(3),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'bulletList',
|
|
103
|
+
icon: List,
|
|
104
|
+
label: 'Bulleted list',
|
|
105
|
+
markdown: '-',
|
|
106
|
+
group: 'block',
|
|
107
|
+
isActive: editor => editor.isActive('bulletList'),
|
|
108
|
+
run: runWithChain(chain => chain.toggleBulletList()),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'orderedList',
|
|
112
|
+
icon: ListOrdered,
|
|
113
|
+
label: 'Numbered list',
|
|
114
|
+
markdown: '1.',
|
|
115
|
+
group: 'block',
|
|
116
|
+
isActive: editor => editor.isActive('orderedList'),
|
|
117
|
+
run: runWithChain(chain => chain.toggleOrderedList()),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'blockquote',
|
|
121
|
+
icon: TextQuote,
|
|
122
|
+
label: 'Blockquote',
|
|
123
|
+
markdown: '>',
|
|
124
|
+
group: 'block',
|
|
125
|
+
isActive: editor => editor.isActive('blockquote'),
|
|
126
|
+
run: runWithChain(chain => chain.toggleBlockquote()),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'bold',
|
|
130
|
+
icon: Bold,
|
|
131
|
+
label: 'Bold',
|
|
132
|
+
markdown: '⌘B',
|
|
133
|
+
group: 'inline',
|
|
134
|
+
isActive: editor => editor.isActive('bold'),
|
|
135
|
+
run: runWithChain(chain => chain.toggleBold()),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'italic',
|
|
139
|
+
icon: Italic,
|
|
140
|
+
label: 'Italic',
|
|
141
|
+
markdown: '⌘I',
|
|
142
|
+
group: 'inline',
|
|
143
|
+
isActive: editor => editor.isActive('italic'),
|
|
144
|
+
run: runWithChain(chain => chain.toggleItalic()),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'underline',
|
|
148
|
+
icon: UnderlineIcon,
|
|
149
|
+
label: 'Underline',
|
|
150
|
+
markdown: '⌘U',
|
|
151
|
+
group: 'inline',
|
|
152
|
+
isActive: editor => editor.isActive('underline'),
|
|
153
|
+
run: runWithChain(chain => chain.toggleUnderline()),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'link',
|
|
157
|
+
icon: Link2,
|
|
158
|
+
label: 'Link',
|
|
159
|
+
markdown: '⌘K',
|
|
160
|
+
group: 'inline',
|
|
161
|
+
isActive: editor => editor.isActive('link'),
|
|
162
|
+
run: promptForLink,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Shorter set used when a consumer wants block-level affordances only (e.g.
|
|
168
|
+
* a comment composer where inline marks would feel out of place).
|
|
169
|
+
*/
|
|
170
|
+
export const BLOCKS_ONLY_FORMATTING_COMMANDS: FormattingToolbarCommand[] =
|
|
171
|
+
STANDARD_FORMATTING_COMMANDS.filter(c => c.group === 'block');
|
|
172
|
+
|
|
173
|
+
export type FormattingToolbarVariant = 'standard' | 'blocksOnly' | 'none';
|
|
174
|
+
|
|
175
|
+
export function resolveFormattingCommands(
|
|
176
|
+
variant: FormattingToolbarVariant,
|
|
177
|
+
): FormattingToolbarCommand[] {
|
|
178
|
+
if (variant === 'none') return [];
|
|
179
|
+
if (variant === 'blocksOnly') return BLOCKS_ONLY_FORMATTING_COMMANDS;
|
|
180
|
+
return STANDARD_FORMATTING_COMMANDS;
|
|
181
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Editor, Range } from '@tiptap/react';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single button in the canonical `FormattingToolbar`. The toolbar is
|
|
6
|
+
* shared by every rich-text surface in `@scalepad/ui`, so consumers
|
|
7
|
+
* configure their toolbar by passing an array of these descriptors —
|
|
8
|
+
* never by re-implementing the buttons themselves.
|
|
9
|
+
*/
|
|
10
|
+
export interface FormattingToolbarCommand {
|
|
11
|
+
id: string;
|
|
12
|
+
/** Lucide icon for the 26x26 button. */
|
|
13
|
+
icon: LucideIcon;
|
|
14
|
+
/** Used as `aria-label` and tooltip header. */
|
|
15
|
+
label: string;
|
|
16
|
+
/**
|
|
17
|
+
* Markdown shortcut shown in the tooltip pill (e.g. `##` for H2). When set,
|
|
18
|
+
* the same string is rendered to make the markdown path discoverable.
|
|
19
|
+
*/
|
|
20
|
+
markdown?: string;
|
|
21
|
+
/** Visual group — used to break the toolbar into segments with dividers. */
|
|
22
|
+
group: 'block' | 'inline';
|
|
23
|
+
/**
|
|
24
|
+
* Returns true when the editor reports the matching node/mark as active so
|
|
25
|
+
* the button can render a "pressed" appearance.
|
|
26
|
+
*/
|
|
27
|
+
isActive: (editor: Editor) => boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Applies the formatting. Receives the editor and an optional range from a
|
|
30
|
+
* suggestion plugin so e.g. the slash token can be removed before the
|
|
31
|
+
* formatting is applied.
|
|
32
|
+
*/
|
|
33
|
+
run: (ctx: { editor: Editor; range?: Range }) => void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rich-text foundation. Every editor + viewer in `@scalepad/ui`
|
|
3
|
+
* (RichTextEditor, SlashRichTextEditor, EditableRichText, RichTextView,
|
|
4
|
+
* RichTextInline) consumes this module so they all speak the same
|
|
5
|
+
* extension set, render the same toolbar, and share the same prose
|
|
6
|
+
* styling. Anything that's mode-specific (slash plugin, save/cancel
|
|
7
|
+
* chrome, debounced autosave) lives in the per-editor folder.
|
|
8
|
+
*/
|
|
9
|
+
export {
|
|
10
|
+
docsEqual,
|
|
11
|
+
isDocEmpty,
|
|
12
|
+
normalizeValue,
|
|
13
|
+
plainTextFromDoc,
|
|
14
|
+
richTextDocFromPlainText,
|
|
15
|
+
} from './richTextHelpers';
|
|
16
|
+
export {
|
|
17
|
+
getRichTextExtensions,
|
|
18
|
+
RICH_TEXT_FILE_ID_ATTRIBUTE,
|
|
19
|
+
RICH_TEXT_IMAGE_CLASS,
|
|
20
|
+
type GetRichTextExtensionsOptions,
|
|
21
|
+
} from './richTextExtensions';
|
|
22
|
+
export {
|
|
23
|
+
FormattingToolbar,
|
|
24
|
+
type FormattingToolbarProps,
|
|
25
|
+
} from './FormattingToolbar';
|
|
26
|
+
export {
|
|
27
|
+
STANDARD_FORMATTING_COMMANDS,
|
|
28
|
+
BLOCKS_ONLY_FORMATTING_COMMANDS,
|
|
29
|
+
resolveFormattingCommands,
|
|
30
|
+
type FormattingToolbarVariant,
|
|
31
|
+
} from './formattingCommands';
|
|
32
|
+
export type { FormattingToolbarCommand } from './formattingTypes';
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_RICH_TEXT_IMAGE_MAX_BYTES,
|
|
35
|
+
useRichTextImageUploader,
|
|
36
|
+
} from './richTextImage';
|
|
37
|
+
export type {
|
|
38
|
+
RichTextImageLoaderConfig,
|
|
39
|
+
RichTextImageLoaderHandle,
|
|
40
|
+
RichTextImageUploadConfig,
|
|
41
|
+
RichTextImageUploadError,
|
|
42
|
+
RichTextImageUploadResponse,
|
|
43
|
+
RichTextImageUploadResult,
|
|
44
|
+
} from './richTextImage';
|
|
45
|
+
export {
|
|
46
|
+
installRichTextImageHandlers,
|
|
47
|
+
useRichTextImageLoader,
|
|
48
|
+
type RichTextImageEditorConfig,
|
|
49
|
+
} from './richTextImageHandlers';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Image from '@tiptap/extension-image';
|
|
2
|
+
import Link from '@tiptap/extension-link';
|
|
3
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
4
|
+
import Underline from '@tiptap/extension-underline';
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
+
|
|
7
|
+
import type { Extensions } from '@tiptap/react';
|
|
8
|
+
|
|
9
|
+
/** Custom attribute that carries the consumer's stable file identifier on
|
|
10
|
+
* uploaded image nodes. We keep `src` empty on insert and resolve it lazily
|
|
11
|
+
* via {@link useRichTextImageLoader} so the editor doc itself only stores
|
|
12
|
+
* the file id, not the binary payload. */
|
|
13
|
+
export const RICH_TEXT_FILE_ID_ATTRIBUTE = 'data-rich-text-file-id';
|
|
14
|
+
|
|
15
|
+
/** Class applied to inserted image nodes so the loader hook can find them
|
|
16
|
+
* and so CSS can target loading / error states. */
|
|
17
|
+
export const RICH_TEXT_IMAGE_CLASS = 'rich-text-image';
|
|
18
|
+
|
|
19
|
+
export interface GetRichTextExtensionsOptions {
|
|
20
|
+
/** Placeholder text rendered when the document is empty. */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/**
|
|
23
|
+
* When true (default), `Link.openOnClick` is false so users can place the
|
|
24
|
+
* caret inside a link without navigating away. View-mode callers (e.g.
|
|
25
|
+
* `RichTextView`) pass `false` so links are clickable on the rendered page.
|
|
26
|
+
*/
|
|
27
|
+
editable?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Heading levels enabled. Defaults to `[1, 2, 3]` to match the toolbar
|
|
30
|
+
* (H1/H2/H3 buttons) and the markdown shortcuts (`#`, `##`, `###`).
|
|
31
|
+
*/
|
|
32
|
+
headingLevels?: Array<1 | 2 | 3 | 4 | 5 | 6>;
|
|
33
|
+
/**
|
|
34
|
+
* When true, the editor's schema includes an `image` node so docs
|
|
35
|
+
* authored elsewhere with images render correctly and uploads can be
|
|
36
|
+
* inserted at runtime. Defaults to `false` because most consumers don't
|
|
37
|
+
* need images and shipping the extension would otherwise bloat docs.
|
|
38
|
+
*
|
|
39
|
+
* Consumers that turn this on should also pass an `image` config to the
|
|
40
|
+
* editor component so paste / drop / toolbar flows can upload to their
|
|
41
|
+
* backend. The schema is shared regardless so a doc with images stays
|
|
42
|
+
* faithful when round-tripping through a read-only viewer.
|
|
43
|
+
*/
|
|
44
|
+
enableImages?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the canonical Tiptap extension set used by every rich-text surface
|
|
49
|
+
* in `@scalepad/ui`. Centralising the configuration here is what guarantees
|
|
50
|
+
* that a doc typed in `SlashRichTextEditor` round-trips losslessly through
|
|
51
|
+
* `RichTextEditor`, `EditableRichText`, and `RichTextView` — they all share
|
|
52
|
+
* the same nodes, marks, and input rules.
|
|
53
|
+
*
|
|
54
|
+
* Editors that need additional behaviour (e.g. `SlashRichTextEditor`'s
|
|
55
|
+
* slash-suggestion plugin) append their extras after the result of this
|
|
56
|
+
* function rather than re-defining the base extensions inline.
|
|
57
|
+
*/
|
|
58
|
+
export function getRichTextExtensions({
|
|
59
|
+
placeholder,
|
|
60
|
+
editable = true,
|
|
61
|
+
headingLevels = [1, 2, 3],
|
|
62
|
+
enableImages = false,
|
|
63
|
+
}: GetRichTextExtensionsOptions = {}): Extensions {
|
|
64
|
+
const extensions: Extensions = [
|
|
65
|
+
StarterKit.configure({
|
|
66
|
+
heading: { levels: headingLevels },
|
|
67
|
+
}),
|
|
68
|
+
Underline,
|
|
69
|
+
Link.configure({
|
|
70
|
+
openOnClick: !editable,
|
|
71
|
+
autolink: true,
|
|
72
|
+
HTMLAttributes: {
|
|
73
|
+
rel: 'noreferrer noopener',
|
|
74
|
+
target: '_blank',
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
Placeholder.configure({
|
|
78
|
+
placeholder: placeholder ?? '',
|
|
79
|
+
emptyEditorClass: 'is-editor-empty',
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (enableImages) {
|
|
84
|
+
extensions.push(
|
|
85
|
+
Image.extend({
|
|
86
|
+
addAttributes() {
|
|
87
|
+
return {
|
|
88
|
+
...this.parent?.(),
|
|
89
|
+
'data-rich-text-file-id': {
|
|
90
|
+
default: null,
|
|
91
|
+
parseHTML: element =>
|
|
92
|
+
element.getAttribute(RICH_TEXT_FILE_ID_ATTRIBUTE),
|
|
93
|
+
renderHTML: attrs => {
|
|
94
|
+
const fileId = attrs[RICH_TEXT_FILE_ID_ATTRIBUTE];
|
|
95
|
+
return fileId ? { [RICH_TEXT_FILE_ID_ATTRIBUTE]: fileId } : {};
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
}).configure({
|
|
101
|
+
inline: false,
|
|
102
|
+
allowBase64: true,
|
|
103
|
+
HTMLAttributes: {
|
|
104
|
+
class: RICH_TEXT_IMAGE_CLASS,
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return extensions;
|
|
111
|
+
}
|