@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,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
+ }