@flikk/ui 1.0.0-beta.24 → 1.0.0-beta.26

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 (135) hide show
  1. package/dist/components/ai/CodeBlock/CodeBlock.theme.js +2 -2
  2. package/dist/components/ai/PromptInput/PromptInput.js +1 -0
  3. package/dist/components/ai/PromptInput/VoiceRecorder.js +1 -0
  4. package/dist/components/ai/StreamingResponse/MarkdownRenderer.js +1 -1
  5. package/dist/components/ai/TokenCounter/TokenCounter.theme.js +1 -1
  6. package/dist/components/charts/BarChart/BarChart.js +2 -2
  7. package/dist/components/charts/StackedBarChart/StackedBarChart.js +1 -1
  8. package/dist/components/charts/shared/BarRenderer/BarRenderer.js +2 -2
  9. package/dist/components/core/Accordion/Accordion.theme.js +2 -2
  10. package/dist/components/core/Breadcrumbs/Breadcrumbs.js +1 -1
  11. package/dist/components/core/Breadcrumbs/Breadcrumbs.theme.js +2 -2
  12. package/dist/components/core/Button/Button.js +1 -1
  13. package/dist/components/core/Button/Button.theme.js +4 -4
  14. package/dist/components/core/Calendar/Calendar.js +1 -1
  15. package/dist/components/core/Calendar/CalendarMini/CalendarMini.js +4 -2
  16. package/dist/components/core/Calendar/CalendarMini/CalendarMini.theme.js +2 -2
  17. package/dist/components/core/ContextMenu/ContextMenu.theme.js +1 -1
  18. package/dist/components/core/Drawer/Drawer.theme.js +2 -2
  19. package/dist/components/core/Dropdown/Dropdown.theme.js +2 -2
  20. package/dist/components/core/Empty/Empty.js +1 -1
  21. package/dist/components/core/Empty/Empty.theme.js +1 -1
  22. package/dist/components/core/ImagePlaceholder/ImagePlaceholder.d.ts +14 -0
  23. package/dist/components/core/ImagePlaceholder/ImagePlaceholder.js +28 -0
  24. package/dist/components/core/ImagePlaceholder/ImagePlaceholder.theme.d.ts +6 -0
  25. package/dist/components/core/ImagePlaceholder/ImagePlaceholder.theme.js +8 -0
  26. package/dist/components/core/ImagePlaceholder/ImagePlaceholder.types.d.ts +32 -0
  27. package/dist/components/core/ImagePlaceholder/index.d.ts +2 -0
  28. package/dist/components/core/Link/Link.js +1 -1
  29. package/dist/components/core/Message/Message.theme.js +1 -1
  30. package/dist/components/core/Modal/Modal.theme.js +1 -1
  31. package/dist/components/core/NavItem/NavItem.d.ts +1 -1
  32. package/dist/components/core/NavItem/NavItem.js +171 -68
  33. package/dist/components/core/NavItem/NavItem.theme.js +20 -12
  34. package/dist/components/core/NavItem/NavItem.types.d.ts +17 -39
  35. package/dist/components/core/Pagination/Pagination.theme.js +3 -3
  36. package/dist/components/core/Popover/Popover.theme.js +1 -1
  37. package/dist/components/core/Progress/Progress.theme.js +1 -1
  38. package/dist/components/core/ScrollArea/ScrollArea.js +2 -2
  39. package/dist/components/core/ScrollArea/ScrollArea.theme.js +1 -1
  40. package/dist/components/core/Segmented/Segmented.js +8 -6
  41. package/dist/components/core/Segmented/Segmented.theme.js +3 -3
  42. package/dist/components/core/Sidebar/Sidebar.d.ts +1 -1
  43. package/dist/components/core/Sidebar/Sidebar.js +42 -13
  44. package/dist/components/core/Sidebar/Sidebar.theme.js +5 -7
  45. package/dist/components/core/Sidebar/Sidebar.types.d.ts +33 -4
  46. package/dist/components/core/Sidebar/SidebarContent.js +6 -6
  47. package/dist/components/core/Sidebar/SidebarContext.d.ts +17 -16
  48. package/dist/components/core/Sidebar/SidebarContext.js +42 -19
  49. package/dist/components/core/Sidebar/SidebarFooter.js +4 -6
  50. package/dist/components/core/Sidebar/SidebarHeader.js +4 -9
  51. package/dist/components/core/Sidebar/SidebarLogo.js +3 -7
  52. package/dist/components/core/Sidebar/SidebarNav.js +4 -3
  53. package/dist/components/core/Sidebar/SidebarNavGroup.js +4 -7
  54. package/dist/components/core/Sidebar/SidebarToggle.js +6 -1
  55. package/dist/components/core/Sidebar/SidebarUserProfile.js +33 -17
  56. package/dist/components/core/Sidebar/index.d.ts +2 -3
  57. package/dist/components/core/Skeleton/Skeleton.theme.js +1 -1
  58. package/dist/components/core/Spinner/Spinner.theme.js +1 -1
  59. package/dist/components/core/Tabs/Tabs.js +6 -1
  60. package/dist/components/core/Tabs/Tabs.types.d.ts +2 -0
  61. package/dist/components/core/Tabs/TabsBody.js +5 -3
  62. package/dist/components/core/Tag/Tag.theme.js +1 -1
  63. package/dist/components/core/index.d.ts +1 -0
  64. package/dist/components/core/index.js +2 -2
  65. package/dist/components/data-display/GanttChart/GanttTableGroupRow.js +1 -1
  66. package/dist/components/data-display/KPI/KPI.js +13 -3
  67. package/dist/components/data-display/KPI/KPI.theme.js +3 -3
  68. package/dist/components/data-display/Metric/Metric.theme.js +3 -3
  69. package/dist/components/data-display/Table/Table.js +4 -3
  70. package/dist/components/data-display/Table/Table.theme.js +11 -1
  71. package/dist/components/data-display/Table/Table.types.d.ts +15 -0
  72. package/dist/components/data-display/Table/TableColumnManager.js +1 -1
  73. package/dist/components/data-display/Table/index.d.ts +2 -2
  74. package/dist/components/data-display/Timeline/Timeline.animations.d.ts +0 -7
  75. package/dist/components/data-display/Timeline/Timeline.animations.js +1 -27
  76. package/dist/components/data-display/Timeline/Timeline.js +3 -4
  77. package/dist/components/data-display/Timeline/Timeline.types.d.ts +0 -13
  78. package/dist/components/data-display/Timeline/TimelineContent.js +3 -7
  79. package/dist/components/data-display/Timeline/TimelineItem.js +4 -17
  80. package/dist/components/data-display/Timeline/index.d.ts +2 -2
  81. package/dist/components/effects/Aurora/Aurora.js +79 -40
  82. package/dist/components/effects/InteractiveCharacters/InteractiveCharacters.js +3 -3
  83. package/dist/components/effects/InteractiveCharacters/characters/BlobCharacter.js +13 -4
  84. package/dist/components/effects/InteractiveCharacters/characters/SmallCharacter.js +13 -4
  85. package/dist/components/effects/InteractiveCharacters/characters/TallCharacter.js +14 -4
  86. package/dist/components/effects/MorphingText/MorphingText.js +40 -7
  87. package/dist/components/effects/StripePattern/StripePattern.d.ts +18 -0
  88. package/dist/components/effects/StripePattern/StripePattern.js +54 -0
  89. package/dist/components/effects/StripePattern/StripePattern.types.d.ts +53 -0
  90. package/dist/components/effects/StripePattern/index.d.ts +2 -0
  91. package/dist/components/effects/index.d.ts +9 -0
  92. package/dist/components/effects/index.js +4 -0
  93. package/dist/components/forms/Checkbox/Checkbox.theme.js +1 -1
  94. package/dist/components/forms/CronInput/CronInput.theme.js +16 -16
  95. package/dist/components/forms/DatePicker/DatePickerTrigger.js +1 -1
  96. package/dist/components/forms/DateRangePicker/DateRangePicker.theme.js +1 -1
  97. package/dist/components/forms/DateRangePicker/DateRangePickerTrigger.js +1 -1
  98. package/dist/components/forms/FileUpload/FileUpload.js +1 -1
  99. package/dist/components/forms/FileUpload/FileUploadProgress.js +27 -20
  100. package/dist/components/forms/FileUpload/FileUploadProgress.theme.js +2 -2
  101. package/dist/components/forms/Input/Input.theme.js +9 -11
  102. package/dist/components/forms/Mention/Mention.theme.js +3 -3
  103. package/dist/components/forms/Radio/Radio.theme.js +1 -1
  104. package/dist/components/forms/RichTextEditor/RichTextEditor.d.ts +18 -0
  105. package/dist/components/forms/RichTextEditor/RichTextEditor.js +1231 -236
  106. package/dist/components/forms/RichTextEditor/RichTextEditor.theme.d.ts +53 -1
  107. package/dist/components/forms/RichTextEditor/RichTextEditor.theme.js +99 -25
  108. package/dist/components/forms/RichTextEditor/RichTextEditor.types.d.ts +42 -3
  109. package/dist/components/forms/RichTextEditor/index.d.ts +2 -2
  110. package/dist/components/forms/Select/Select.js +2 -2
  111. package/dist/components/forms/Slider/Slider.theme.js +1 -1
  112. package/dist/components/forms/Switch/Switch.theme.js +1 -1
  113. package/dist/components/forms/TimePicker/TimePicker.theme.js +2 -2
  114. package/dist/components/forms/TimePicker/TimePickerContent.js +1 -0
  115. package/dist/components/forms/TimePicker/TimePickerTrigger.js +1 -1
  116. package/dist/components/forms/forms.theme.js +12 -12
  117. package/dist/components/forms/index.js +1 -0
  118. package/dist/components/layout/FormLayout/FormLayout.js +6 -4
  119. package/dist/components/layout/FormLayout/FormLayout.types.d.ts +3 -3
  120. package/dist/components/layout/FormLayout/FormLayoutSection.js +6 -1
  121. package/dist/components/layout/PageLayout/PageLayout.d.ts +2 -2
  122. package/dist/components/layout/PageLayout/PageLayout.js +23 -5
  123. package/dist/components/layout/PageLayout/PageLayout.theme.d.ts +2 -1
  124. package/dist/components/layout/PageLayout/PageLayout.theme.js +35 -6
  125. package/dist/components/layout/PageLayout/PageLayout.types.d.ts +45 -0
  126. package/dist/components/layout/PageLayout/PageLayoutContent.js +6 -4
  127. package/dist/components/layout/PageLayout/PageLayoutHeader.js +3 -3
  128. package/dist/components/layout/PageLayout/PageLayoutSidebar.js +5 -3
  129. package/dist/components/layout/PageLayout/index.d.ts +1 -1
  130. package/dist/index.js +2 -2
  131. package/dist/styles.css +1 -1
  132. package/package.json +1 -1
  133. package/src/styles/theme.css +127 -92
  134. package/dist/components/core/Sidebar/SidebarSubmenu.d.ts +0 -7
  135. package/dist/components/core/Sidebar/SidebarSubmenu.js +0 -12
@@ -3,36 +3,35 @@ import React__default, { useState, useCallback, useRef, useMemo, useEffect } fro
3
3
  import { createPortal } from 'react-dom';
4
4
  import { useReducedMotion, AnimatePresence, motion } from 'motion/react';
5
5
  import { FormLabel } from '../FormLabel/FormLabel.js';
6
- import { richTextEditorTheme, colorOptions } from './RichTextEditor.theme.js';
6
+ import { MONO_COLORS, TAILWIND_COLOR_FAMILIES, getTailwindShadeTokens, richTextEditorTheme, resolveTailwindColor } from './RichTextEditor.theme.js';
7
7
  import { floatingToolbarVariants } from './RichTextEditor.animations.js';
8
8
  import { cn } from '../../../utils/cn.js';
9
9
  import { Button } from '../../core/Button/Button.js';
10
- import { ArrowUturnRightIcon, ArrowUturnLeftIcon, Bars3BottomRightIcon, Bars3Icon, Bars3BottomLeftIcon, UnderlineIcon, ItalicIcon, BoldIcon } from '@heroicons/react/24/outline';
10
+ import { ScrollArea } from '../../core/ScrollArea/ScrollArea.js';
11
+ import '../../core/ScrollArea/smooth/SmoothScrollEngine.js';
12
+ import { ArrowUturnRightIcon, ArrowUturnLeftIcon, Bars3BottomRightIcon, Bars3Icon, Bars3BottomLeftIcon, ArrowsRightLeftIcon, ArrowsUpDownIcon, BoldIcon, UnderlineIcon, ItalicIcon } from '@heroicons/react/24/outline';
11
13
  import { useSelectPortal } from '../../../hooks/useSelectPortal.js';
12
14
  import { PaintBrushIcon } from '@heroicons/react/24/solid';
13
15
  import { createDropdownAnimations } from '../Select/Select.animations.js';
14
16
  import { useIsClient } from '../../../hooks/useIsClient.js';
15
17
 
16
18
  const FONT_SIZE_OPTIONS = [
17
- { key: "xs", label: "XS", value: "0.75rem", px: 12 },
18
- { key: "sm", label: "SM", value: "0.875rem", px: 14 },
19
- { key: "md", label: "MD", value: "1rem", px: 16 },
20
- { key: "lg", label: "LG", value: "1.125rem", px: 18 },
21
- { key: "xl", label: "XL", value: "1.25rem", px: 20 },
22
- { key: "2xl", label: "2XL", value: "1.5rem", px: 24 },
23
- { key: "3xl", label: "3XL", value: "1.875rem", px: 30 },
24
- { key: "4xl", label: "4XL", value: "2.25rem", px: 36 },
19
+ { key: "xs", label: "XS", value: "0.75rem", px: 12, className: "text-xs" },
20
+ { key: "sm", label: "SM", value: "0.875rem", px: 14, className: "text-sm" },
21
+ { key: "md", label: "MD", value: "1rem", px: 16, className: "text-base" },
22
+ { key: "lg", label: "LG", value: "1.125rem", px: 18, className: "text-lg" },
23
+ { key: "xl", label: "XL", value: "1.25rem", px: 20, className: "text-xl" },
24
+ { key: "2xl", label: "2XL", value: "1.5rem", px: 24, className: "text-2xl" },
25
+ { key: "3xl", label: "3XL", value: "1.875rem", px: 30, className: "text-3xl" },
26
+ { key: "4xl", label: "4XL", value: "2.25rem", px: 36, className: "text-4xl" },
27
+ { key: "5xl", label: "5XL", value: "3rem", px: 48, className: "text-5xl" },
28
+ { key: "6xl", label: "6XL", value: "3.75rem", px: 60, className: "text-6xl" },
29
+ { key: "7xl", label: "7XL", value: "4.5rem", px: 72, className: "text-7xl" },
30
+ { key: "8xl", label: "8XL", value: "6rem", px: 96, className: "text-8xl" },
31
+ { key: "9xl", label: "9XL", value: "8rem", px: 128, className: "text-9xl" },
25
32
  ];
26
- const FONT_SIZE_COMMAND_VALUE = {
27
- xs: "1",
28
- sm: "2",
29
- md: "3",
30
- lg: "4",
31
- xl: "5",
32
- "2xl": "6",
33
- "3xl": "7",
34
- "4xl": "7",
35
- };
33
+ /** All font-size class names — used to scrub stale classes when swapping. */
34
+ const FONT_SIZE_CLASSES = FONT_SIZE_OPTIONS.map((o) => o.className);
36
35
  const getFontSizeOption = (key) => { var _a; return (_a = FONT_SIZE_OPTIONS.find((option) => option.key === key)) !== null && _a !== void 0 ? _a : FONT_SIZE_OPTIONS[2]; };
37
36
  const getClosestFontSizeKey = (px) => {
38
37
  let closest = "md";
@@ -46,23 +45,153 @@ const getClosestFontSizeKey = (px) => {
46
45
  });
47
46
  return closest;
48
47
  };
48
+ /**
49
+ * Curated default list of font families shipped with the editor.
50
+ * The `Default` entry clears the inline font-family. The rest are popular
51
+ * Google Fonts chosen for broad weight coverage. Each stack ends in a sensible
52
+ * fallback so text stays readable while the font is loading.
53
+ */
54
+ const DEFAULT_FONT_FAMILY_OPTIONS = [
55
+ { key: "default", label: "Default", stack: "" },
56
+ { key: "inter", label: "Inter", family: "Inter", stack: "'Inter', system-ui, sans-serif" },
57
+ { key: "roboto", label: "Roboto", family: "Roboto", stack: "'Roboto', system-ui, sans-serif" },
58
+ { key: "manrope", label: "Manrope", family: "Manrope", stack: "'Manrope', system-ui, sans-serif" },
59
+ { key: "poppins", label: "Poppins", family: "Poppins", stack: "'Poppins', system-ui, sans-serif" },
60
+ { key: "lora", label: "Lora", family: "Lora", stack: "'Lora', Georgia, serif" },
61
+ { key: "playfair", label: "Playfair Display", family: "Playfair Display", stack: "'Playfair Display', Georgia, serif" },
62
+ { key: "merriweather", label: "Merriweather", family: "Merriweather", stack: "'Merriweather', Georgia, serif" },
63
+ { key: "jetbrains", label: "JetBrains Mono", family: "JetBrains Mono", stack: "'JetBrains Mono', Menlo, monospace" },
64
+ ];
65
+ // Bumped when migrating from raw hex values to Tailwind shade tokens
66
+ // (e.g. "rose-500"). Old hex entries from prior versions are ignored — the
67
+ // picker now applies color via `text-{token}` classes, not via execCommand.
68
+ const RECENT_COLORS_KEY = "flikkui-rte-recent-colors-v2";
69
+ const RECENT_COLORS_LIMIT = 10;
70
+ const DEFAULT_FONT_WEIGHT_REQUEST = [100, 200, 300, 400, 500, 600, 700, 800, 900];
71
+ const loadedGoogleFonts = new Set();
72
+ /**
73
+ * Lazily injects a Google Fonts `<link rel="stylesheet">` for the given family.
74
+ * Idempotent: each family is requested at most once per page lifetime.
75
+ * SSR-safe (no-ops when `document` is undefined).
76
+ *
77
+ * @example
78
+ * import { loadGoogleFont } from "@flikk/ui";
79
+ * loadGoogleFont("Inter");
80
+ */
81
+ const loadGoogleFont = (family, weights = DEFAULT_FONT_WEIGHT_REQUEST) => {
82
+ if (typeof document === "undefined")
83
+ return;
84
+ if (!family)
85
+ return;
86
+ const cacheKey = `${family}:${weights.join(",")}`;
87
+ if (loadedGoogleFonts.has(cacheKey))
88
+ return;
89
+ loadedGoogleFonts.add(cacheKey);
90
+ const familyParam = family.replace(/\s+/g, "+");
91
+ const weightsParam = weights.length > 0 ? `:wght@${weights.join(";")}` : "";
92
+ const href = `https://fonts.googleapis.com/css2?family=${familyParam}${weightsParam}&display=swap`;
93
+ const link = document.createElement("link");
94
+ link.rel = "stylesheet";
95
+ link.href = href;
96
+ link.setAttribute("data-flikkui-font", family);
97
+ document.head.appendChild(link);
98
+ };
99
+ const FONT_WEIGHT_OPTIONS = [
100
+ { key: "thin", label: "Thin", value: "100", weight: 100, className: "font-thin" },
101
+ { key: "extralight", label: "Extra Light", value: "200", weight: 200, className: "font-extralight" },
102
+ { key: "light", label: "Light", value: "300", weight: 300, className: "font-light" },
103
+ { key: "normal", label: "Regular", value: "400", weight: 400, className: "font-normal" },
104
+ { key: "medium", label: "Medium", value: "500", weight: 500, className: "font-medium" },
105
+ { key: "semibold", label: "Semibold", value: "600", weight: 600, className: "font-semibold" },
106
+ { key: "bold", label: "Bold", value: "700", weight: 700, className: "font-bold" },
107
+ { key: "extrabold", label: "Extra Bold", value: "800", weight: 800, className: "font-extrabold" },
108
+ { key: "black", label: "Black", value: "900", weight: 900, className: "font-black" },
109
+ ];
110
+ const FONT_WEIGHT_CLASSES = FONT_WEIGHT_OPTIONS.map((o) => o.className);
111
+ const getFontWeightOption = (key) => { var _a; return (_a = FONT_WEIGHT_OPTIONS.find((option) => option.key === key)) !== null && _a !== void 0 ? _a : FONT_WEIGHT_OPTIONS[3]; };
112
+ const getClosestFontWeightKey = (weight) => {
113
+ let closest = "normal";
114
+ let minDiff = Number.POSITIVE_INFINITY;
115
+ FONT_WEIGHT_OPTIONS.forEach((option) => {
116
+ const diff = Math.abs(option.weight - weight);
117
+ if (diff < minDiff) {
118
+ minDiff = diff;
119
+ closest = option.key;
120
+ }
121
+ });
122
+ return closest;
123
+ };
124
+ const LINE_HEIGHT_OPTIONS = [
125
+ { key: "tight", label: "Tight", value: "1.25", className: "leading-tight" },
126
+ { key: "snug", label: "Snug", value: "1.375", className: "leading-snug" },
127
+ { key: "normal", label: "Normal", value: "1.5", className: "leading-normal" },
128
+ { key: "relaxed", label: "Relaxed", value: "1.625", className: "leading-relaxed" },
129
+ { key: "loose", label: "Loose", value: "2", className: "leading-loose" },
130
+ ];
131
+ const LINE_HEIGHT_CLASSES = LINE_HEIGHT_OPTIONS.map((o) => o.className);
132
+ const getLineHeightOption = (key) => { var _a; return (_a = LINE_HEIGHT_OPTIONS.find((option) => option.key === key)) !== null && _a !== void 0 ? _a : LINE_HEIGHT_OPTIONS[2]; };
133
+ const LETTER_SPACING_OPTIONS = [
134
+ { key: "tighter", label: "Tighter", value: "-0.05em", em: -0.05, className: "tracking-tighter" },
135
+ { key: "tight", label: "Tight", value: "-0.025em", em: -0.025, className: "tracking-tight" },
136
+ { key: "normal", label: "Normal", value: "0em", em: 0, className: "tracking-normal" },
137
+ { key: "wide", label: "Wide", value: "0.025em", em: 0.025, className: "tracking-wide" },
138
+ { key: "wider", label: "Wider", value: "0.05em", em: 0.05, className: "tracking-wider" },
139
+ { key: "widest", label: "Widest", value: "0.1em", em: 0.1, className: "tracking-widest" },
140
+ ];
141
+ const LETTER_SPACING_CLASSES = LETTER_SPACING_OPTIONS.map((o) => o.className);
142
+ /**
143
+ * Every text-color class the picker can apply: `text-white`, `text-black`,
144
+ * and `text-{family}-{shade}` for each Tailwind family/shade. Used both to
145
+ * apply the new color and to scrub stale color classes off ancestor wrappers.
146
+ * Derived once at module load — list size is static.
147
+ */
148
+ const TEXT_COLOR_CLASSES = [
149
+ ...MONO_COLORS.map((c) => `text-${c.token}`),
150
+ ...TAILWIND_COLOR_FAMILIES.flatMap((family) => getTailwindShadeTokens(family).map((token) => `text-${token}`)),
151
+ ];
152
+ /** Returns the active text-color token (e.g. `"rose-500"`) walking up from the
153
+ * given element, or `""` if no `text-{token}` class is found before the editor
154
+ * boundary. */
155
+ const matchTextColorClass = (el, editor) => {
156
+ let n = el;
157
+ while (n && n !== editor) {
158
+ for (let i = 0; i < n.classList.length; i++) {
159
+ const cls = n.classList.item(i);
160
+ if (cls && TEXT_COLOR_CLASSES.includes(cls))
161
+ return cls.slice("text-".length);
162
+ }
163
+ n = n.parentElement;
164
+ }
165
+ return "";
166
+ };
167
+ const getLetterSpacingOption = (key) => { var _a; return (_a = LETTER_SPACING_OPTIONS.find((option) => option.key === key)) !== null && _a !== void 0 ? _a : LETTER_SPACING_OPTIONS[2]; };
49
168
  const DEFAULT_FORMATTING_STATE = {
50
169
  bold: false,
51
170
  italic: false,
52
171
  underline: false,
53
172
  textAlign: "left",
54
173
  fontSize: "md",
55
- textColor: "#000000",
174
+ fontWeight: "normal",
175
+ fontFamily: "default",
176
+ lineHeight: "normal",
177
+ letterSpacing: "normal",
178
+ // Token like "rose-500" / "white" / "black"; "" means inherit (no class
179
+ // applied). The picker now applies color via `text-{token}` classes
180
+ // rather than execCommand("foreColor"), so the state holds tokens.
181
+ textColor: "",
56
182
  };
57
183
  const DEFAULT_TOOLBAR = {
58
184
  show: true,
59
185
  position: "top",
60
186
  tools: [
61
- "bold",
187
+ "fontWeight",
62
188
  "italic",
63
189
  "underline",
64
190
  "separator",
191
+ "fontFamily",
65
192
  "fontSize",
193
+ "lineHeight",
194
+ "letterSpacing",
66
195
  "textColor",
67
196
  "separator",
68
197
  "alignLeft",
@@ -73,13 +202,50 @@ const DEFAULT_TOOLBAR = {
73
202
  "redo",
74
203
  ],
75
204
  };
76
- const RichTextEditor = React__default.forwardRef(({ size = "md", state = "default", className = "", label, labelClassName = "", helperText, helperTextClassName = "", value = "", onChange, placeholder = "Start typing...", id, required, minHeight = 120, maxHeight, toolbar: toolbarProp = DEFAULT_TOOLBAR, theme = {}, sanitize, ...props }, ref) => {
205
+ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "default", className = "", label, labelClassName = "", helperText, helperTextClassName = "", value = "", onChange, placeholder = "Start typing...", id, required, minHeight = 120, maxHeight, toolbar: toolbarProp = DEFAULT_TOOLBAR, theme = {}, fontFamilies = DEFAULT_FONT_FAMILY_OPTIONS, loadFontsOnDemand = true, sanitize, ...props }, ref) => {
77
206
  const [isFocused, setIsFocused] = useState(false);
78
207
  const [showToolbar, setShowToolbar] = useState(false);
79
208
  const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
80
209
  const [formattingState, setFormattingState] = useState(DEFAULT_FORMATTING_STATE);
81
210
  const [showFontSizePicker, setShowFontSizePicker] = useState(false);
211
+ const [showFontWeightPicker, setShowFontWeightPicker] = useState(false);
212
+ const [showFontFamilyPicker, setShowFontFamilyPicker] = useState(false);
213
+ const [showLineHeightPicker, setShowLineHeightPicker] = useState(false);
214
+ const [showLetterSpacingPicker, setShowLetterSpacingPicker] = useState(false);
82
215
  const [showColorPicker, setShowColorPicker] = useState(false);
216
+ // Recently used text colors — persisted to localStorage so they survive reloads.
217
+ const [recentColors, setRecentColors] = useState(() => {
218
+ if (typeof window === "undefined")
219
+ return [];
220
+ try {
221
+ const stored = window.localStorage.getItem(RECENT_COLORS_KEY);
222
+ if (!stored)
223
+ return [];
224
+ const parsed = JSON.parse(stored);
225
+ return Array.isArray(parsed)
226
+ ? parsed.filter((v) => typeof v === "string").slice(0, RECENT_COLORS_LIMIT)
227
+ : [];
228
+ }
229
+ catch (_a) {
230
+ return [];
231
+ }
232
+ });
233
+ const addRecentColor = useCallback((token) => {
234
+ if (!token)
235
+ return;
236
+ setRecentColors((prev) => {
237
+ const next = [token, ...prev.filter((c) => c !== token)].slice(0, RECENT_COLORS_LIMIT);
238
+ if (typeof window !== "undefined") {
239
+ try {
240
+ window.localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(next));
241
+ }
242
+ catch (_a) {
243
+ // localStorage unavailable (private mode, quota exceeded, etc.) — ignore.
244
+ }
245
+ }
246
+ return next;
247
+ });
248
+ }, []);
83
249
  const shouldReduceMotion = useReducedMotion();
84
250
  const isClient = useIsClient();
85
251
  // Built-in basic sanitizer: strips <script> tags and on* event handlers
@@ -94,7 +260,42 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
94
260
  const editorRef = useRef(null);
95
261
  const toolbarRef = useRef(null);
96
262
  const fontSizeTriggerRef = useRef(null);
263
+ const fontWeightTriggerRef = useRef(null);
264
+ const fontFamilyTriggerRef = useRef(null);
265
+ const lineHeightTriggerRef = useRef(null);
266
+ const letterSpacingTriggerRef = useRef(null);
97
267
  const colorTriggerRef = useRef(null);
268
+ // Single linear history covering ALL mutations: typing, execCommand
269
+ // (bold/italic/foreColor/justify/…), and direct-DOM style ops. Typing is
270
+ // coalesced into one undo entry per ~500 ms run, so users get coarse-grained
271
+ // word-level undo instead of per-keystroke. Discrete style ops always start
272
+ // a fresh entry by passing kind='op'.
273
+ const historyRef = useRef({ undo: [], redo: [], lastPushKind: null, lastPushAt: 0 });
274
+ const HISTORY_LIMIT = 100;
275
+ const TYPING_COALESCE_MS = 500;
276
+ const pushHistory = useCallback((kind = "op") => {
277
+ if (!editorRef.current)
278
+ return;
279
+ const now = Date.now();
280
+ const stack = historyRef.current;
281
+ // Coalesce successive typing events into a single undo entry while the
282
+ // user is mid-word. Skip when the previous push was also typing AND it
283
+ // happened recently — the existing entry already represents this group.
284
+ if (kind === "typing" &&
285
+ stack.lastPushKind === "typing" &&
286
+ now - stack.lastPushAt < TYPING_COALESCE_MS) {
287
+ stack.lastPushAt = now;
288
+ // Any new mutation still invalidates the redo stack.
289
+ stack.redo = [];
290
+ return;
291
+ }
292
+ stack.undo.push(editorRef.current.innerHTML);
293
+ stack.redo = [];
294
+ stack.lastPushKind = kind;
295
+ stack.lastPushAt = now;
296
+ if (stack.undo.length > HISTORY_LIMIT)
297
+ stack.undo.shift();
298
+ }, []);
98
299
  const generatedId = React__default.useId();
99
300
  const editorId = id || generatedId;
100
301
  const themeStyles = useMemo(() => {
@@ -143,6 +344,34 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
143
344
  offset: 6,
144
345
  autoWidth: true,
145
346
  });
347
+ const { position: fontWeightPortalPosition, cssVariables: fontWeightPortalCssVars, contentRef: fontWeightPortalContentRef, isReady: fontWeightPortalReady, isFlipped: fontWeightIsFlipped, } = useSelectPortal({
348
+ triggerRef: fontWeightTriggerRef,
349
+ isOpen: showFontWeightPicker,
350
+ placement: "bottom-start",
351
+ offset: 6,
352
+ autoWidth: true,
353
+ });
354
+ const { position: fontFamilyPortalPosition, cssVariables: fontFamilyPortalCssVars, contentRef: fontFamilyPortalContentRef, isReady: fontFamilyPortalReady, isFlipped: fontFamilyIsFlipped, } = useSelectPortal({
355
+ triggerRef: fontFamilyTriggerRef,
356
+ isOpen: showFontFamilyPicker,
357
+ placement: "bottom-start",
358
+ offset: 6,
359
+ autoWidth: true,
360
+ });
361
+ const { position: lineHeightPortalPosition, cssVariables: lineHeightPortalCssVars, contentRef: lineHeightPortalContentRef, isReady: lineHeightPortalReady, isFlipped: lineHeightIsFlipped, } = useSelectPortal({
362
+ triggerRef: lineHeightTriggerRef,
363
+ isOpen: showLineHeightPicker,
364
+ placement: "bottom-start",
365
+ offset: 6,
366
+ autoWidth: true,
367
+ });
368
+ const { position: letterSpacingPortalPosition, cssVariables: letterSpacingPortalCssVars, contentRef: letterSpacingPortalContentRef, isReady: letterSpacingPortalReady, isFlipped: letterSpacingIsFlipped, } = useSelectPortal({
369
+ triggerRef: letterSpacingTriggerRef,
370
+ isOpen: showLetterSpacingPicker,
371
+ placement: "bottom-start",
372
+ offset: 6,
373
+ autoWidth: true,
374
+ });
146
375
  const { position: colorPortalPosition, cssVariables: colorPortalCssVars, contentRef: colorPortalContentRef, isReady: colorPortalReady, isFlipped: colorIsFlipped, } = useSelectPortal({
147
376
  triggerRef: colorTriggerRef,
148
377
  isOpen: showColorPicker,
@@ -166,38 +395,151 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
166
395
  editorRef.current.focus();
167
396
  }
168
397
  }, []);
398
+ const closeAllPickers = useCallback(() => {
399
+ setShowFontSizePicker(false);
400
+ setShowFontWeightPicker(false);
401
+ setShowFontFamilyPicker(false);
402
+ setShowLineHeightPicker(false);
403
+ setShowLetterSpacingPicker(false);
404
+ setShowColorPicker(false);
405
+ }, []);
406
+ const togglePicker = useCallback((which) => {
407
+ setShowFontSizePicker((prev) => (which === "fontSize" ? !prev : false));
408
+ setShowFontWeightPicker((prev) => (which === "fontWeight" ? !prev : false));
409
+ setShowFontFamilyPicker((prev) => (which === "fontFamily" ? !prev : false));
410
+ setShowLineHeightPicker((prev) => (which === "lineHeight" ? !prev : false));
411
+ setShowLetterSpacingPicker((prev) => (which === "letterSpacing" ? !prev : false));
412
+ setShowColorPicker((prev) => (which === "color" ? !prev : false));
413
+ }, []);
169
414
  const getCurrentFontSize = useCallback(() => {
170
415
  if (!editorRef.current)
171
416
  return DEFAULT_FORMATTING_STATE.fontSize;
172
417
  const selection = window.getSelection();
173
- if (!selection || selection.rangeCount === 0) {
174
- const editorComputed = window.getComputedStyle(editorRef.current);
175
- const editorSize = parseFloat(editorComputed.fontSize || "");
176
- return Number.isFinite(editorSize)
177
- ? getClosestFontSizeKey(editorSize)
178
- : DEFAULT_FORMATTING_STATE.fontSize;
179
- }
180
- const range = selection.getRangeAt(0);
181
- let node = range.startContainer;
182
- if (node.nodeType === Node.TEXT_NODE) {
183
- node = node.parentElement;
418
+ let node = null;
419
+ if (selection && selection.rangeCount > 0) {
420
+ node = selection.getRangeAt(0).startContainer;
421
+ if (node && node.nodeType === Node.TEXT_NODE)
422
+ node = node.parentElement;
184
423
  }
424
+ // Walk up looking for a `text-*` class (the size classes also bundle a
425
+ // matching line-height — that's why class-based size is reliable).
185
426
  while (node && node !== editorRef.current) {
186
427
  if (node instanceof HTMLElement) {
187
- const computed = window.getComputedStyle(node);
188
- const size = parseFloat(computed.fontSize || "");
189
- if (Number.isFinite(size)) {
190
- return getClosestFontSizeKey(size);
191
- }
428
+ const match = FONT_SIZE_OPTIONS.find((o) => node && node.classList.contains(o.className));
429
+ if (match)
430
+ return match.key;
192
431
  }
193
432
  node = node ? node.parentNode : null;
194
433
  }
434
+ // No explicit class found — fall back to the editor's computed font-size
435
+ // mapped to the closest preset. Handles initial/no-selection state.
195
436
  const editorComputed = window.getComputedStyle(editorRef.current);
196
437
  const editorSize = parseFloat(editorComputed.fontSize || "");
197
438
  return Number.isFinite(editorSize)
198
439
  ? getClosestFontSizeKey(editorSize)
199
440
  : DEFAULT_FORMATTING_STATE.fontSize;
200
441
  }, []);
442
+ const getCurrentFontFamily = useCallback(() => {
443
+ if (!editorRef.current)
444
+ return "default";
445
+ const selection = window.getSelection();
446
+ let element = null;
447
+ if (selection && selection.rangeCount > 0) {
448
+ let node = selection.getRangeAt(0).startContainer;
449
+ if (node && node.nodeType === Node.TEXT_NODE)
450
+ node = node.parentElement;
451
+ if (node instanceof HTMLElement)
452
+ element = node;
453
+ }
454
+ while (element && editorRef.current.contains(element)) {
455
+ const inline = element.style.fontFamily;
456
+ if (inline) {
457
+ // Match against the first family in the inline stack
458
+ const firstFamily = inline.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
459
+ const match = fontFamilies.find((opt) => {
460
+ if (!opt.family)
461
+ return false;
462
+ return opt.family.toLowerCase() === firstFamily.toLowerCase();
463
+ });
464
+ if (match)
465
+ return match.key;
466
+ }
467
+ element = element.parentElement;
468
+ }
469
+ return "default";
470
+ }, [fontFamilies]);
471
+ const getCurrentFontWeight = useCallback(() => {
472
+ var _a;
473
+ if (!editorRef.current)
474
+ return DEFAULT_FORMATTING_STATE.fontWeight;
475
+ const selection = window.getSelection();
476
+ let node = null;
477
+ if (selection && selection.rangeCount > 0) {
478
+ node = selection.getRangeAt(0).startContainer;
479
+ if (node && node.nodeType === Node.TEXT_NODE)
480
+ node = node.parentElement;
481
+ }
482
+ while (node && node !== editorRef.current) {
483
+ if (node instanceof HTMLElement) {
484
+ const match = FONT_WEIGHT_OPTIONS.find((o) => node && node.classList.contains(o.className));
485
+ if (match)
486
+ return match.key;
487
+ }
488
+ node = node ? node.parentNode : null;
489
+ }
490
+ // Fallback: read computed font-weight (covers <strong>/<b> tags from
491
+ // the Bold toggle which produce semantic markup, not classes).
492
+ const target = (_a = (selection && selection.rangeCount > 0
493
+ ? (selection.getRangeAt(0).startContainer instanceof HTMLElement
494
+ ? selection.getRangeAt(0).startContainer
495
+ : selection.getRangeAt(0).startContainer.parentElement)
496
+ : null)) !== null && _a !== void 0 ? _a : editorRef.current;
497
+ const computed = window.getComputedStyle(target);
498
+ const weight = parseInt(computed.fontWeight, 10);
499
+ return Number.isFinite(weight)
500
+ ? getClosestFontWeightKey(weight)
501
+ : DEFAULT_FORMATTING_STATE.fontWeight;
502
+ }, []);
503
+ const getCurrentLineHeight = useCallback(() => {
504
+ if (!editorRef.current)
505
+ return DEFAULT_FORMATTING_STATE.lineHeight;
506
+ const selection = window.getSelection();
507
+ let element = null;
508
+ if (selection && selection.rangeCount > 0) {
509
+ let node = selection.getRangeAt(0).startContainer;
510
+ if (node && node.nodeType === Node.TEXT_NODE)
511
+ node = node.parentElement;
512
+ if (node instanceof HTMLElement)
513
+ element = node;
514
+ }
515
+ while (element && editorRef.current.contains(element)) {
516
+ const match = LINE_HEIGHT_OPTIONS.find((o) => element && element.classList.contains(o.className));
517
+ if (match)
518
+ return match.key;
519
+ element = element.parentElement;
520
+ }
521
+ return DEFAULT_FORMATTING_STATE.lineHeight;
522
+ }, []);
523
+ const getCurrentLetterSpacing = useCallback(() => {
524
+ if (!editorRef.current)
525
+ return DEFAULT_FORMATTING_STATE.letterSpacing;
526
+ const selection = window.getSelection();
527
+ let element = null;
528
+ if (selection && selection.rangeCount > 0) {
529
+ let node = selection.getRangeAt(0).startContainer;
530
+ if (node && node.nodeType === Node.TEXT_NODE)
531
+ node = node.parentElement;
532
+ if (node instanceof HTMLElement)
533
+ element = node;
534
+ }
535
+ while (element && editorRef.current.contains(element)) {
536
+ const match = LETTER_SPACING_OPTIONS.find((o) => element && element.classList.contains(o.className));
537
+ if (match)
538
+ return match.key;
539
+ element = element.parentElement;
540
+ }
541
+ return DEFAULT_FORMATTING_STATE.letterSpacing;
542
+ }, []);
201
543
  const updateFormattingState = useCallback(() => {
202
544
  if (!editorRef.current || isDisabled) {
203
545
  setFormattingState(DEFAULT_FORMATTING_STATE);
@@ -208,31 +550,61 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
208
550
  let underline = false;
209
551
  let textAlign = "left";
210
552
  let fontSize = DEFAULT_FORMATTING_STATE.fontSize;
553
+ let fontWeight = DEFAULT_FORMATTING_STATE.fontWeight;
554
+ let fontFamily = DEFAULT_FORMATTING_STATE.fontFamily;
555
+ let lineHeight = DEFAULT_FORMATTING_STATE.lineHeight;
556
+ let letterSpacing = DEFAULT_FORMATTING_STATE.letterSpacing;
211
557
  let textColor = DEFAULT_FORMATTING_STATE.textColor;
558
+ // Bold is class-driven — the toolbar Bold button and Weight=Bold
559
+ // dropdown share `font-bold`, so we walk class ancestors and only fall
560
+ // back to computed font-weight when no class is found (covers legacy
561
+ // <strong>/<b> tags from pasted content).
212
562
  try {
213
- bold = document.queryCommandState("bold");
563
+ const sel = window.getSelection();
564
+ let startEl = null;
565
+ if (sel && sel.rangeCount > 0) {
566
+ let node = sel.getRangeAt(0).startContainer;
567
+ if (node && node.nodeType === Node.TEXT_NODE)
568
+ node = node.parentElement;
569
+ if (node instanceof HTMLElement)
570
+ startEl = node;
571
+ }
572
+ const boldClasses = ["font-bold", "font-extrabold", "font-black"];
573
+ const notBoldClasses = ["font-thin", "font-extralight", "font-light", "font-normal", "font-medium", "font-semibold"];
574
+ let n = startEl;
575
+ let boldClassFound = null;
576
+ while (n && n !== editorRef.current) {
577
+ if (boldClasses.some((c) => n.classList.contains(c))) {
578
+ boldClassFound = true;
579
+ break;
580
+ }
581
+ if (notBoldClasses.some((c) => n.classList.contains(c))) {
582
+ boldClassFound = false;
583
+ break;
584
+ }
585
+ n = n.parentElement;
586
+ }
587
+ bold = boldClassFound !== null
588
+ ? boldClassFound
589
+ : startEl
590
+ ? parseInt(window.getComputedStyle(startEl).fontWeight, 10) >= 600
591
+ : false;
214
592
  }
215
593
  catch (error) {
216
594
  if (process.env.NODE_ENV !== 'production') {
217
- console.warn("queryCommandState bold failed", error);
595
+ console.warn("Failed to determine bold state", error);
218
596
  }
219
597
  }
598
+ // Italic and underline route through execCommand and produce semantic
599
+ // <em>/<u> tags, so queryCommandState is the canonical read.
220
600
  try {
221
601
  italic = document.queryCommandState("italic");
222
602
  }
223
- catch (error) {
224
- if (process.env.NODE_ENV !== 'production') {
225
- console.warn("queryCommandState italic failed", error);
226
- }
227
- }
603
+ catch (_a) { }
228
604
  try {
229
605
  underline = document.queryCommandState("underline");
230
606
  }
231
- catch (error) {
232
- if (process.env.NODE_ENV !== 'production') {
233
- console.warn("queryCommandState underline failed", error);
234
- }
235
- }
607
+ catch (_b) { }
236
608
  try {
237
609
  if (document.queryCommandState("justifyCenter")) {
238
610
  textAlign = "center";
@@ -261,14 +633,56 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
261
633
  }
262
634
  }
263
635
  try {
264
- const colorValue = document.queryCommandValue("foreColor");
265
- if (typeof colorValue === "string" && colorValue.trim().length > 0) {
266
- textColor = colorValue;
636
+ fontWeight = getCurrentFontWeight();
637
+ }
638
+ catch (error) {
639
+ if (process.env.NODE_ENV !== 'production') {
640
+ console.warn("Failed to determine font weight", error);
641
+ }
642
+ }
643
+ try {
644
+ fontFamily = getCurrentFontFamily();
645
+ }
646
+ catch (error) {
647
+ if (process.env.NODE_ENV !== 'production') {
648
+ console.warn("Failed to determine font family", error);
649
+ }
650
+ }
651
+ try {
652
+ lineHeight = getCurrentLineHeight();
653
+ }
654
+ catch (error) {
655
+ if (process.env.NODE_ENV !== 'production') {
656
+ console.warn("Failed to determine line height", error);
657
+ }
658
+ }
659
+ try {
660
+ letterSpacing = getCurrentLetterSpacing();
661
+ }
662
+ catch (error) {
663
+ if (process.env.NODE_ENV !== 'production') {
664
+ console.warn("Failed to determine letter spacing", error);
665
+ }
666
+ }
667
+ try {
668
+ // Walk up from the selection's start container to find the nearest
669
+ // ancestor carrying a `text-{token}` class; that token (e.g.
670
+ // "rose-500", "white") is the active color. queryCommandValue is no
671
+ // longer used because color is applied via class swaps, not foreColor.
672
+ const sel = window.getSelection();
673
+ let startEl = null;
674
+ if (sel && sel.rangeCount > 0) {
675
+ let node = sel.getRangeAt(0).startContainer;
676
+ if (node && node.nodeType === Node.TEXT_NODE)
677
+ node = node.parentElement;
678
+ if (node instanceof HTMLElement)
679
+ startEl = node;
267
680
  }
681
+ textColor = matchTextColorClass(startEl, editorRef.current);
268
682
  }
269
683
  catch (error) {
270
684
  if (process.env.NODE_ENV !== 'production') {
271
- console.warn("queryCommandValue foreColor failed", error);
685
+ console.warn("Failed to determine text color", error);
272
686
  }
273
687
  }
274
688
  setFormattingState({
@@ -277,18 +691,39 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
277
691
  underline,
278
692
  textAlign,
279
693
  fontSize,
694
+ fontWeight,
695
+ fontFamily,
696
+ lineHeight,
697
+ letterSpacing,
280
698
  textColor,
281
699
  });
282
- }, [getCurrentFontSize, isDisabled]);
700
+ }, [
701
+ getCurrentFontSize,
702
+ getCurrentFontWeight,
703
+ getCurrentFontFamily,
704
+ getCurrentLineHeight,
705
+ getCurrentLetterSpacing,
706
+ isDisabled,
707
+ ]);
283
708
  const handleContentChange = useCallback(() => {
284
709
  if (!editorRef.current || !onChange)
285
710
  return;
286
- onChange(editorRef.current.innerHTML);
287
- }, [onChange]);
711
+ const raw = editorRef.current.innerHTML;
712
+ const clean = sanitizeHtml(raw);
713
+ // If sanitizer stripped something (e.g. pasted <script> or on*= handlers),
714
+ // reconcile the DOM so what's rendered matches what we emit.
715
+ if (clean !== raw) {
716
+ editorRef.current.innerHTML = clean;
717
+ }
718
+ onChange(clean);
719
+ }, [onChange, sanitizeHtml]);
288
720
  const execEditorCommand = useCallback((command, value) => {
289
721
  if (isDisabled || !editorRef.current)
290
722
  return;
291
723
  focusEditor();
724
+ // Snapshot before mutating so bold/italic/foreColor/justify/… land on
725
+ // the same undo stack as direct-DOM style ops and typing.
726
+ pushHistory("op");
292
727
  try {
293
728
  document.execCommand(command, false, value);
294
729
  }
@@ -301,66 +736,236 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
301
736
  updateFormattingState();
302
737
  handleContentChange();
303
738
  });
304
- }, [focusEditor, handleContentChange, isDisabled, updateFormattingState]);
305
- const normalizeFontSizeSelection = useCallback((sizeKey) => {
739
+ }, [focusEditor, handleContentChange, isDisabled, pushHistory, updateFormattingState]);
740
+ const handleUndo = useCallback((event) => {
741
+ event === null || event === void 0 ? void 0 : event.preventDefault();
742
+ event === null || event === void 0 ? void 0 : event.stopPropagation();
743
+ const stack = historyRef.current;
744
+ if (stack.undo.length === 0 || !editorRef.current)
745
+ return;
746
+ const previous = stack.undo.pop();
747
+ stack.redo.push(editorRef.current.innerHTML);
748
+ // Reset the typing-coalesce window so the next keystroke after an
749
+ // undo always starts a fresh entry.
750
+ stack.lastPushKind = null;
751
+ editorRef.current.innerHTML = sanitizeHtml(previous);
752
+ requestAnimationFrame(() => {
753
+ updateFormattingState();
754
+ handleContentChange();
755
+ });
756
+ }, [handleContentChange, sanitizeHtml, updateFormattingState]);
757
+ const handleRedo = useCallback((event) => {
758
+ event === null || event === void 0 ? void 0 : event.preventDefault();
759
+ event === null || event === void 0 ? void 0 : event.stopPropagation();
760
+ const stack = historyRef.current;
761
+ if (stack.redo.length === 0 || !editorRef.current)
762
+ return;
763
+ const next = stack.redo.pop();
764
+ stack.undo.push(editorRef.current.innerHTML);
765
+ stack.lastPushKind = null;
766
+ editorRef.current.innerHTML = sanitizeHtml(next);
767
+ requestAnimationFrame(() => {
768
+ updateFormattingState();
769
+ handleContentChange();
770
+ });
771
+ }, [handleContentChange, sanitizeHtml, updateFormattingState]);
772
+ const applyStyleToSelection = useCallback((apply) => {
773
+ var _a, _b, _c;
306
774
  if (!editorRef.current)
307
775
  return;
308
- const option = getFontSizeOption(sizeKey);
309
776
  const selection = window.getSelection();
310
777
  if (!selection || selection.rangeCount === 0)
311
778
  return;
312
779
  const range = selection.getRangeAt(0);
313
- const value = option.value;
314
780
  if (selection.isCollapsed) {
315
781
  let node = selection.anchorNode;
316
- if (node && node.nodeType === Node.TEXT_NODE) {
782
+ if (node && node.nodeType === Node.TEXT_NODE)
317
783
  node = node.parentElement;
318
- }
319
784
  while (node && node !== editorRef.current) {
320
785
  if (node instanceof HTMLElement) {
321
- node.style.fontSize = value;
322
- if (node.tagName.toLowerCase() === "font") {
323
- node.removeAttribute("size");
324
- }
786
+ apply(node);
325
787
  break;
326
788
  }
327
789
  node = node.parentElement;
328
790
  }
329
791
  return;
330
792
  }
793
+ // Non-collapsed: try to wrap just the selected range in a styled span so
794
+ // only the selected text changes. surroundContents works when the range
795
+ // doesn't cross element boundaries (the common case for inline selections).
796
+ const span = document.createElement("span");
797
+ apply(span);
798
+ try {
799
+ range.surroundContents(span);
800
+ selection.removeAllRanges();
801
+ const restored = document.createRange();
802
+ restored.selectNodeContents(span);
803
+ selection.addRange(restored);
804
+ return;
805
+ }
806
+ catch (_d) {
807
+ // Range crosses element boundaries — fall through to per-parent walker.
808
+ }
809
+ // Cross-element fallback: split each intersecting text node at the
810
+ // range boundaries so only the selected characters end up wrapped.
811
+ // Collect first — splitText() mutates the tree and breaks live walking.
812
+ const startContainer = range.startContainer;
813
+ const startOffset = range.startOffset;
814
+ const endContainer = range.endContainer;
815
+ const endOffset = range.endOffset;
816
+ const textNodes = [];
331
817
  const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, {
332
- acceptNode: (node) => range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
818
+ acceptNode: (n) => range.intersectsNode(n) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
333
819
  });
334
- const updated = new Set();
335
820
  let current;
336
- while ((current = walker.nextNode())) {
337
- const parent = current.parentElement;
338
- if (!parent || !editorRef.current.contains(parent))
821
+ while ((current = walker.nextNode()))
822
+ textNodes.push(current);
823
+ const editor = editorRef.current;
824
+ let firstWrap = null;
825
+ let lastWrap = null;
826
+ for (const textNode of textNodes) {
827
+ if (!editor.contains(textNode))
828
+ continue;
829
+ // Trim leading portion when range starts inside this text node
830
+ let target = textNode;
831
+ if (textNode === startContainer && startOffset > 0) {
832
+ target = textNode.splitText(startOffset);
833
+ }
834
+ // Trim trailing portion when range ends inside this text node
835
+ if (textNode === endContainer) {
836
+ const endInTarget = textNode === startContainer ? endOffset - startOffset : endOffset;
837
+ if (endInTarget < target.length) {
838
+ target.splitText(endInTarget);
839
+ }
840
+ }
841
+ // Wrap the precise text fragment in a styled span
842
+ const wrap = document.createElement("span");
843
+ apply(wrap);
844
+ (_a = target.parentNode) === null || _a === void 0 ? void 0 : _a.insertBefore(wrap, target);
845
+ wrap.appendChild(target);
846
+ if (!firstWrap)
847
+ firstWrap = wrap;
848
+ lastWrap = wrap;
849
+ }
850
+ // Restore the visible selection across the wrapped fragments so the
851
+ // user doesn't have to re-highlight before chaining another action.
852
+ if (firstWrap && lastWrap) {
853
+ selection.removeAllRanges();
854
+ const restored = document.createRange();
855
+ restored.setStartBefore((_b = firstWrap.firstChild) !== null && _b !== void 0 ? _b : firstWrap);
856
+ restored.setEndAfter((_c = lastWrap.lastChild) !== null && _c !== void 0 ? _c : lastWrap);
857
+ selection.addRange(restored);
858
+ }
859
+ }, []);
860
+ /**
861
+ * Strip a redundant class from any element whose every non-whitespace text
862
+ * descendant already sits under a closer descendant carrying a class from
863
+ * the same group. The outer declaration is dead weight at that point —
864
+ * leaving it in place causes nested wrappers to win on properties whose
865
+ * line box height (e.g. `text-7xl`'s `line-height: 1`) is computed from the
866
+ * outer's own font-size, regardless of what the inner element declares.
867
+ * After stripping, span/font wrappers that are now attribute-less are
868
+ * unwrapped so we don't accumulate empty markup over time.
869
+ */
870
+ const flattenClassGroup = useCallback((classes) => {
871
+ if (!editorRef.current || classes.length === 0)
872
+ return;
873
+ const editor = editorRef.current;
874
+ const selector = classes.map((c) => `.${c}`).join(",");
875
+ const candidates = Array.from(editor.querySelectorAll(selector));
876
+ for (const ancestor of candidates) {
877
+ const ancestorClass = classes.find((c) => ancestor.classList.contains(c));
878
+ if (!ancestorClass)
339
879
  continue;
340
- if (updated.has(parent))
880
+ const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT);
881
+ let everyTextHasCloser = true;
882
+ let sawAnyText = false;
883
+ let textNode;
884
+ while ((textNode = walker.nextNode())) {
885
+ const data = textNode.data;
886
+ if (!data || !data.trim())
887
+ continue;
888
+ sawAnyText = true;
889
+ let p = textNode.parentElement;
890
+ let foundCloser = false;
891
+ while (p && p !== ancestor) {
892
+ if (classes.some((c) => p.classList.contains(c))) {
893
+ foundCloser = true;
894
+ break;
895
+ }
896
+ p = p.parentElement;
897
+ }
898
+ if (!foundCloser) {
899
+ everyTextHasCloser = false;
900
+ break;
901
+ }
902
+ }
903
+ if (!sawAnyText || !everyTextHasCloser)
341
904
  continue;
342
- parent.style.fontSize = value;
343
- if (parent.tagName.toLowerCase() === "font") {
344
- parent.removeAttribute("size");
905
+ ancestor.classList.remove(ancestorClass);
906
+ if (ancestor.classList.length === 0)
907
+ ancestor.removeAttribute("class");
908
+ const tag = ancestor.tagName.toLowerCase();
909
+ if ((tag === "span" || tag === "font") &&
910
+ ancestor.attributes.length === 0 &&
911
+ ancestor.parentNode) {
912
+ const parent = ancestor.parentNode;
913
+ while (ancestor.firstChild)
914
+ parent.insertBefore(ancestor.firstChild, ancestor);
915
+ parent.removeChild(ancestor);
345
916
  }
346
- updated.add(parent);
347
917
  }
348
918
  }, []);
349
- const handleUndo = useCallback((event) => {
350
- event === null || event === void 0 ? void 0 : event.preventDefault();
351
- event === null || event === void 0 ? void 0 : event.stopPropagation();
352
- execEditorCommand("undo");
353
- }, [execEditorCommand]);
354
- const handleRedo = useCallback((event) => {
355
- event === null || event === void 0 ? void 0 : event.preventDefault();
356
- event === null || event === void 0 ? void 0 : event.stopPropagation();
357
- execEditorCommand("redo");
358
- }, [execEditorCommand]);
919
+ /**
920
+ * Generic class-swap helper. Wraps the selected text in a span carrying
921
+ * `nextClass` (also stripping any sibling class from the same group on
922
+ * that wrap, so we never end up with `text-sm text-7xl`), then runs a
923
+ * flatten pass over the editor to collapse newly-redundant outer wrappers.
924
+ */
925
+ const applyClassSwap = useCallback((group, nextClass) => {
926
+ applyStyleToSelection((el) => {
927
+ for (const cls of group) {
928
+ if (cls !== nextClass)
929
+ el.classList.remove(cls);
930
+ }
931
+ el.classList.add(nextClass);
932
+ });
933
+ flattenClassGroup(group);
934
+ }, [applyStyleToSelection, flattenClassGroup]);
359
935
  const handleBold = useCallback((event) => {
360
936
  event === null || event === void 0 ? void 0 : event.preventDefault();
361
937
  event === null || event === void 0 ? void 0 : event.stopPropagation();
362
- execEditorCommand("bold");
363
- }, [execEditorCommand]);
938
+ if (isDisabled || !editorRef.current)
939
+ return;
940
+ focusEditor();
941
+ const selection = window.getSelection();
942
+ if (!selection || selection.rangeCount === 0)
943
+ return;
944
+ pushHistory();
945
+ // Bold lives in the font-weight class group: ON → font-bold,
946
+ // OFF → font-normal. The toolbar Bold button and the Weight=Bold
947
+ // dropdown entry share the same underlying class, so they stay in
948
+ // sync automatically — toggling one lights up the other.
949
+ const next = formattingState.bold ? "font-normal" : "font-bold";
950
+ applyClassSwap(FONT_WEIGHT_CLASSES, next);
951
+ requestAnimationFrame(() => {
952
+ updateFormattingState();
953
+ handleContentChange();
954
+ });
955
+ }, [
956
+ applyClassSwap,
957
+ focusEditor,
958
+ formattingState.bold,
959
+ handleContentChange,
960
+ isDisabled,
961
+ pushHistory,
962
+ updateFormattingState,
963
+ ]);
964
+ // Italic and underline route through execCommand because it produces
965
+ // semantic <em>/<u> tags, handles toggle-on/off (including unwrap) without
966
+ // a hand-rolled negation-class scheme, and the editor's CSS already
967
+ // styles those tags via descendant selectors. execEditorCommand
968
+ // pre-snapshots history so undo/redo ordering is preserved.
364
969
  const handleItalic = useCallback((event) => {
365
970
  event === null || event === void 0 ? void 0 : event.preventDefault();
366
971
  event === null || event === void 0 ? void 0 : event.stopPropagation();
@@ -375,52 +980,263 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
375
980
  if (isDisabled || !editorRef.current)
376
981
  return;
377
982
  focusEditor();
378
- try {
379
- document.execCommand("styleWithCSS", false, "true");
983
+ const option = getFontSizeOption(sizeKey);
984
+ const selection = window.getSelection();
985
+ if (!selection || selection.rangeCount === 0) {
986
+ closeAllPickers();
987
+ return;
380
988
  }
381
- catch (error) {
382
- if (process.env.NODE_ENV !== 'production') {
383
- console.warn("styleWithCSS command failed", error);
989
+ pushHistory();
990
+ // Tailwind's `text-*` utilities bundle font-size + a matching
991
+ // line-height in one rule, so swapping the size class swaps both —
992
+ // no separate flattening pass needed for the line-height.
993
+ applyClassSwap(FONT_SIZE_CLASSES, option.className);
994
+ requestAnimationFrame(() => {
995
+ updateFormattingState();
996
+ handleContentChange();
997
+ });
998
+ closeAllPickers();
999
+ }, [
1000
+ applyClassSwap,
1001
+ closeAllPickers,
1002
+ focusEditor,
1003
+ handleContentChange,
1004
+ isDisabled,
1005
+ pushHistory,
1006
+ updateFormattingState,
1007
+ ]);
1008
+ const applyToBlockAncestors = useCallback((apply) => {
1009
+ if (!editorRef.current)
1010
+ return;
1011
+ const selection = window.getSelection();
1012
+ if (!selection || selection.rangeCount === 0)
1013
+ return;
1014
+ const editor = editorRef.current;
1015
+ // Normalize: ensure no text node or inline element is a *direct*
1016
+ // child of the editor. Otherwise `findBlock` would fall back to the
1017
+ // editor itself, and any class set on it would live on the live DOM
1018
+ // but never serialize through `editor.innerHTML` — line-height
1019
+ // changes would silently disappear from saved HTML. Wrap each
1020
+ // contiguous run of inline-or-text top-level children in a fresh
1021
+ // `<div>`. Idempotent: blocks that are already block-level are left
1022
+ // alone, so calling this on every block-level mutation is cheap.
1023
+ const isInlineChild = (node) => {
1024
+ if (node.nodeType === Node.TEXT_NODE)
1025
+ return true;
1026
+ if (node instanceof HTMLElement) {
1027
+ const d = window.getComputedStyle(node).display;
1028
+ return d === "inline" || d === "inline-block";
1029
+ }
1030
+ return false;
1031
+ };
1032
+ const groups = [];
1033
+ let currentGroup = null;
1034
+ for (const child of Array.from(editor.childNodes)) {
1035
+ if (isInlineChild(child)) {
1036
+ if (!currentGroup) {
1037
+ currentGroup = [];
1038
+ groups.push(currentGroup);
1039
+ }
1040
+ currentGroup.push(child);
1041
+ }
1042
+ else {
1043
+ currentGroup = null;
384
1044
  }
385
1045
  }
386
- try {
387
- document.execCommand("fontSize", false, FONT_SIZE_COMMAND_VALUE[sizeKey]);
1046
+ for (const group of groups) {
1047
+ if (group.length === 0)
1048
+ continue;
1049
+ const wrap = document.createElement("div");
1050
+ editor.insertBefore(wrap, group[0]);
1051
+ for (const node of group)
1052
+ wrap.appendChild(node);
388
1053
  }
389
- catch (error) {
390
- if (process.env.NODE_ENV !== 'production') {
391
- console.warn("fontSize command failed", error);
1054
+ // Re-read range after potential mutations — Range objects survive
1055
+ // moves but offsets into reparented text nodes stay valid, so
1056
+ // `selection.getRangeAt(0)` still points at the same characters.
1057
+ const range = selection.getRangeAt(0);
1058
+ const findBlock = (node) => {
1059
+ let n = node;
1060
+ if (n && n.nodeType === Node.TEXT_NODE)
1061
+ n = n.parentElement;
1062
+ while (n && n !== editor && editor.contains(n)) {
1063
+ if (n instanceof HTMLElement) {
1064
+ const d = window.getComputedStyle(n).display;
1065
+ if (d === "block" || d === "list-item" || d === "table-cell" || d === "flex" || d === "grid") {
1066
+ return n;
1067
+ }
1068
+ }
1069
+ n = n.parentElement;
392
1070
  }
1071
+ // After normalization above, an inline node always has a non-editor
1072
+ // block ancestor; this branch is now only reached for malformed
1073
+ // cases. Returning null is safer than returning the editor (which
1074
+ // wouldn't serialize anyway).
1075
+ return null;
1076
+ };
1077
+ const blocks = new Set();
1078
+ if (selection.isCollapsed) {
1079
+ const block = findBlock(range.startContainer);
1080
+ if (block)
1081
+ blocks.add(block);
393
1082
  }
394
- normalizeFontSizeSelection(sizeKey);
395
- try {
396
- document.execCommand("styleWithCSS", false, "false");
1083
+ else if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
1084
+ const block = findBlock(range.commonAncestorContainer);
1085
+ if (block)
1086
+ blocks.add(block);
397
1087
  }
398
- catch (error) {
399
- // Some browsers do not support toggling this flag; ignore.
1088
+ else {
1089
+ const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, {
1090
+ acceptNode: (n) => range.intersectsNode(n) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
1091
+ });
1092
+ let current;
1093
+ while ((current = walker.nextNode())) {
1094
+ const block = findBlock(current);
1095
+ if (block)
1096
+ blocks.add(block);
1097
+ }
1098
+ }
1099
+ blocks.forEach((b) => apply(b));
1100
+ }, []);
1101
+ const handleFontFamily = useCallback((key) => {
1102
+ if (isDisabled || !editorRef.current)
1103
+ return;
1104
+ focusEditor();
1105
+ const option = fontFamilies.find((opt) => opt.key === key);
1106
+ if (!option)
1107
+ return;
1108
+ if (loadFontsOnDemand && option.family) {
1109
+ loadGoogleFont(option.family, option.weights);
400
1110
  }
1111
+ pushHistory();
1112
+ applyStyleToSelection((el) => {
1113
+ el.style.fontFamily = option.stack;
1114
+ });
401
1115
  requestAnimationFrame(() => {
402
1116
  updateFormattingState();
403
1117
  handleContentChange();
404
1118
  });
405
- setShowFontSizePicker(false);
406
- setShowColorPicker(false);
1119
+ closeAllPickers();
407
1120
  }, [
1121
+ applyStyleToSelection,
1122
+ closeAllPickers,
408
1123
  focusEditor,
1124
+ fontFamilies,
409
1125
  handleContentChange,
410
1126
  isDisabled,
411
- normalizeFontSizeSelection,
1127
+ loadFontsOnDemand,
1128
+ pushHistory,
1129
+ updateFormattingState,
1130
+ ]);
1131
+ const handleFontWeight = useCallback((key) => {
1132
+ if (isDisabled || !editorRef.current)
1133
+ return;
1134
+ focusEditor();
1135
+ const option = getFontWeightOption(key);
1136
+ pushHistory();
1137
+ applyClassSwap(FONT_WEIGHT_CLASSES, option.className);
1138
+ requestAnimationFrame(() => {
1139
+ updateFormattingState();
1140
+ handleContentChange();
1141
+ });
1142
+ closeAllPickers();
1143
+ }, [
1144
+ applyClassSwap,
1145
+ closeAllPickers,
1146
+ focusEditor,
1147
+ handleContentChange,
1148
+ isDisabled,
1149
+ pushHistory,
1150
+ updateFormattingState,
1151
+ ]);
1152
+ const handleLineHeight = useCallback((key) => {
1153
+ if (isDisabled || !editorRef.current)
1154
+ return;
1155
+ focusEditor();
1156
+ const option = getLineHeightOption(key);
1157
+ pushHistory();
1158
+ // Line height is paragraph-level, so swap the `leading-*` class on the
1159
+ // block ancestors that contain the selection rather than wrapping in a
1160
+ // span. Each block carries at most one `leading-*` class — clear any
1161
+ // existing one first, then add the new one.
1162
+ applyToBlockAncestors((el) => {
1163
+ for (const cls of LINE_HEIGHT_CLASSES) {
1164
+ if (cls !== option.className)
1165
+ el.classList.remove(cls);
1166
+ }
1167
+ el.classList.add(option.className);
1168
+ });
1169
+ requestAnimationFrame(() => {
1170
+ updateFormattingState();
1171
+ handleContentChange();
1172
+ });
1173
+ closeAllPickers();
1174
+ }, [
1175
+ applyToBlockAncestors,
1176
+ closeAllPickers,
1177
+ focusEditor,
1178
+ handleContentChange,
1179
+ isDisabled,
1180
+ pushHistory,
1181
+ updateFormattingState,
1182
+ ]);
1183
+ const handleLetterSpacing = useCallback((key) => {
1184
+ if (isDisabled || !editorRef.current)
1185
+ return;
1186
+ focusEditor();
1187
+ const option = getLetterSpacingOption(key);
1188
+ pushHistory();
1189
+ applyClassSwap(LETTER_SPACING_CLASSES, option.className);
1190
+ requestAnimationFrame(() => {
1191
+ updateFormattingState();
1192
+ handleContentChange();
1193
+ });
1194
+ closeAllPickers();
1195
+ }, [
1196
+ applyClassSwap,
1197
+ closeAllPickers,
1198
+ focusEditor,
1199
+ handleContentChange,
1200
+ isDisabled,
1201
+ pushHistory,
1202
+ updateFormattingState,
1203
+ ]);
1204
+ const handleTextColor = useCallback((token) => {
1205
+ if (isDisabled || !editorRef.current || !token)
1206
+ return;
1207
+ focusEditor();
1208
+ const selection = window.getSelection();
1209
+ if (!selection || selection.rangeCount === 0) {
1210
+ closeAllPickers();
1211
+ return;
1212
+ }
1213
+ pushHistory();
1214
+ applyClassSwap(TEXT_COLOR_CLASSES, `text-${token}`);
1215
+ addRecentColor(token);
1216
+ requestAnimationFrame(() => {
1217
+ updateFormattingState();
1218
+ handleContentChange();
1219
+ });
1220
+ closeAllPickers();
1221
+ }, [
1222
+ addRecentColor,
1223
+ applyClassSwap,
1224
+ closeAllPickers,
1225
+ focusEditor,
1226
+ handleContentChange,
1227
+ isDisabled,
1228
+ pushHistory,
412
1229
  updateFormattingState,
413
1230
  ]);
414
- const handleTextColor = useCallback((color) => {
415
- execEditorCommand("foreColor", color);
416
- setShowColorPicker(false);
417
- setShowFontSizePicker(false);
418
- }, [execEditorCommand]);
419
1231
  const handleMouseDown = useCallback((event) => {
420
1232
  event.preventDefault();
421
1233
  focusEditor();
422
1234
  }, [focusEditor]);
423
1235
  const fontSizeAnimations = useMemo(() => createDropdownAnimations(fontSizeIsFlipped), [fontSizeIsFlipped]);
1236
+ const fontWeightAnimations = useMemo(() => createDropdownAnimations(fontWeightIsFlipped), [fontWeightIsFlipped]);
1237
+ const fontFamilyAnimations = useMemo(() => createDropdownAnimations(fontFamilyIsFlipped), [fontFamilyIsFlipped]);
1238
+ const lineHeightAnimations = useMemo(() => createDropdownAnimations(lineHeightIsFlipped), [lineHeightIsFlipped]);
1239
+ const letterSpacingAnimations = useMemo(() => createDropdownAnimations(letterSpacingIsFlipped), [letterSpacingIsFlipped]);
424
1240
  const colorAnimations = useMemo(() => createDropdownAnimations(colorIsFlipped), [colorIsFlipped]);
425
1241
  const handleAlign = useCallback((alignment) => {
426
1242
  const commandMap = {
@@ -431,6 +1247,7 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
431
1247
  execEditorCommand(commandMap[alignment]);
432
1248
  }, [execEditorCommand]);
433
1249
  const handleSelection = useCallback(() => {
1250
+ var _a;
434
1251
  if (!editorRef.current || isDisabled)
435
1252
  return;
436
1253
  updateFormattingState();
@@ -452,7 +1269,12 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
452
1269
  setShowToolbar(false);
453
1270
  return;
454
1271
  }
455
- const toolbarWidth = 200;
1272
+ // Measure the actual toolbar width — the floating toolbar grows with
1273
+ // the active control set (font family, font size, …) and a static
1274
+ // 200 px constant clipped it off-canvas at the editor's right edge.
1275
+ // offsetWidth is 0 while the toolbar is unmounted via AnimatePresence;
1276
+ // fall back to a realistic default for the very first frame.
1277
+ const toolbarWidth = ((_a = toolbarRef.current) === null || _a === void 0 ? void 0 : _a.offsetWidth) || 320;
456
1278
  const left = Math.max(10, Math.min(rect.left - editorRect.left + rect.width / 2 - toolbarWidth / 2, editorRect.width - toolbarWidth - 10));
457
1279
  setToolbarPosition({ top: rect.top - editorRect.top - 50, left });
458
1280
  setShowToolbar(true);
@@ -465,11 +1287,42 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
465
1287
  handleContentChange();
466
1288
  updateFormattingState();
467
1289
  };
1290
+ // beforeinput fires synchronously *before* the DOM mutation, so
1291
+ // editorRef.current.innerHTML is still the pre-keystroke snapshot —
1292
+ // exactly what undo needs. Coalescing in pushHistory groups successive
1293
+ // keystrokes into one undo entry per ~500 ms run.
1294
+ const handleBeforeInput = () => {
1295
+ pushHistory("typing");
1296
+ };
1297
+ // Intercept native undo/redo shortcuts so the browser's contentEditable
1298
+ // stack doesn't drift out of sync with our linear history. Without this
1299
+ // the browser would still honor Cmd/Ctrl+Z on its own internal stack.
1300
+ const handleKeyDown = (event) => {
1301
+ const mod = event.metaKey || event.ctrlKey;
1302
+ if (!mod)
1303
+ return;
1304
+ const key = event.key.toLowerCase();
1305
+ if (key === "z") {
1306
+ event.preventDefault();
1307
+ if (event.shiftKey)
1308
+ handleRedo(event);
1309
+ else
1310
+ handleUndo(event);
1311
+ }
1312
+ else if (key === "y" && !event.shiftKey) {
1313
+ event.preventDefault();
1314
+ handleRedo(event);
1315
+ }
1316
+ };
468
1317
  editor.addEventListener("input", handleInput);
1318
+ editor.addEventListener("beforeinput", handleBeforeInput);
1319
+ editor.addEventListener("keydown", handleKeyDown);
469
1320
  return () => {
470
1321
  editor.removeEventListener("input", handleInput);
1322
+ editor.removeEventListener("beforeinput", handleBeforeInput);
1323
+ editor.removeEventListener("keydown", handleKeyDown);
471
1324
  };
472
- }, [handleContentChange, updateFormattingState]);
1325
+ }, [handleContentChange, handleRedo, handleUndo, pushHistory, updateFormattingState]);
473
1326
  useEffect(() => {
474
1327
  const handleSelectionChange = () => {
475
1328
  if (!editorRef.current)
@@ -492,47 +1345,73 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
492
1345
  }, [handleSelection, updateFormattingState]);
493
1346
  useEffect(() => {
494
1347
  const handleClickOutside = (event) => {
495
- var _a, _b, _c, _d;
1348
+ var _a, _b, _c, _d, _e, _f, _g, _h;
496
1349
  const target = event.target;
497
1350
  const isInEditor = (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.contains(target);
498
1351
  const isInToolbar = (_b = toolbarRef.current) === null || _b === void 0 ? void 0 : _b.contains(target);
499
1352
  const isInFontSizeDropdown = (_c = fontSizePortalContentRef.current) === null || _c === void 0 ? void 0 : _c.contains(target);
500
- const isInColorDropdown = (_d = colorPortalContentRef.current) === null || _d === void 0 ? void 0 : _d.contains(target);
501
- if (!isInEditor && !isInToolbar && !isInFontSizeDropdown && !isInColorDropdown) {
1353
+ const isInFontWeightDropdown = (_d = fontWeightPortalContentRef.current) === null || _d === void 0 ? void 0 : _d.contains(target);
1354
+ const isInFontFamilyDropdown = (_e = fontFamilyPortalContentRef.current) === null || _e === void 0 ? void 0 : _e.contains(target);
1355
+ const isInLineHeightDropdown = (_f = lineHeightPortalContentRef.current) === null || _f === void 0 ? void 0 : _f.contains(target);
1356
+ const isInLetterSpacingDropdown = (_g = letterSpacingPortalContentRef.current) === null || _g === void 0 ? void 0 : _g.contains(target);
1357
+ const isInColorDropdown = (_h = colorPortalContentRef.current) === null || _h === void 0 ? void 0 : _h.contains(target);
1358
+ const isInAnyDropdown = isInFontSizeDropdown ||
1359
+ isInFontWeightDropdown ||
1360
+ isInFontFamilyDropdown ||
1361
+ isInLineHeightDropdown ||
1362
+ isInLetterSpacingDropdown ||
1363
+ isInColorDropdown;
1364
+ if (!isInEditor && !isInToolbar && !isInAnyDropdown) {
502
1365
  if (toolbarConfig.position === "floating") {
503
1366
  setShowToolbar(false);
504
1367
  }
505
- setShowColorPicker(false);
506
- setShowFontSizePicker(false);
507
- }
508
- else if (isInToolbar || isInFontSizeDropdown || isInColorDropdown) {
509
- const colorMatch = target === null || target === void 0 ? void 0 : target.closest("[data-tool=\"textColor\"]");
510
- const isColorRelated = Boolean(colorMatch) || isInColorDropdown;
511
- const fontSizeMatch = target === null || target === void 0 ? void 0 : target.closest("[data-tool=\"fontSize\"]");
512
- const isFontSizeRelated = isInFontSizeDropdown || Boolean(fontSizeMatch);
513
- if (!isColorRelated) {
514
- setShowColorPicker(false);
515
- }
516
- if (!isFontSizeRelated) {
1368
+ closeAllPickers();
1369
+ }
1370
+ else if (isInToolbar || isInAnyDropdown) {
1371
+ const targetEl = target;
1372
+ const isFontSizeRelated = isInFontSizeDropdown || Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="fontSize"]'));
1373
+ const isFontWeightRelated = isInFontWeightDropdown || Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="fontWeight"]'));
1374
+ const isFontFamilyRelated = isInFontFamilyDropdown || Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="fontFamily"]'));
1375
+ const isLineHeightRelated = isInLineHeightDropdown || Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="lineHeight"]'));
1376
+ const isLetterSpacingRelated = isInLetterSpacingDropdown ||
1377
+ Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="letterSpacing"]'));
1378
+ const isColorRelated = isInColorDropdown || Boolean(targetEl === null || targetEl === void 0 ? void 0 : targetEl.closest('[data-tool="textColor"]'));
1379
+ if (!isFontSizeRelated)
517
1380
  setShowFontSizePicker(false);
518
- }
1381
+ if (!isFontWeightRelated)
1382
+ setShowFontWeightPicker(false);
1383
+ if (!isFontFamilyRelated)
1384
+ setShowFontFamilyPicker(false);
1385
+ if (!isLineHeightRelated)
1386
+ setShowLineHeightPicker(false);
1387
+ if (!isLetterSpacingRelated)
1388
+ setShowLetterSpacingPicker(false);
1389
+ if (!isColorRelated)
1390
+ setShowColorPicker(false);
519
1391
  }
520
1392
  else if (isInEditor) {
521
- setShowColorPicker(false);
522
- setShowFontSizePicker(false);
1393
+ closeAllPickers();
523
1394
  }
524
1395
  };
525
1396
  document.addEventListener("mousedown", handleClickOutside);
526
1397
  return () => document.removeEventListener("mousedown", handleClickOutside);
527
- }, [toolbarConfig.position]);
1398
+ }, [closeAllPickers, toolbarConfig.position]);
528
1399
  useEffect(() => {
529
1400
  if (editorRef.current && value !== editorRef.current.innerHTML) {
530
1401
  editorRef.current.innerHTML = sanitizeHtml(value);
1402
+ // External `value` change — flush undo history. Snapshots taken
1403
+ // from the previous document point at HTML that no longer matches
1404
+ // anything reachable via the new value, so leaving them in place
1405
+ // would let Undo restore content from a prior session/document.
1406
+ historyRef.current.undo = [];
1407
+ historyRef.current.redo = [];
1408
+ historyRef.current.lastPushKind = null;
1409
+ historyRef.current.lastPushAt = 0;
531
1410
  updateFormattingState();
532
1411
  }
533
1412
  }, [updateFormattingState, value, sanitizeHtml]);
534
1413
  const renderToolbarTool = (tool, toolKey) => {
535
- var _a, _b;
1414
+ var _a;
536
1415
  const isActive = tool === "bold"
537
1416
  ? formattingState.bold
538
1417
  : tool === "italic"
@@ -541,125 +1420,243 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
541
1420
  ? formattingState.underline
542
1421
  : tool === "fontSize"
543
1422
  ? formattingState.fontSize !== DEFAULT_FORMATTING_STATE.fontSize
544
- : tool === "alignLeft"
545
- ? formattingState.textAlign === "left"
546
- : tool === "alignCenter"
547
- ? formattingState.textAlign === "center"
548
- : tool === "alignRight"
549
- ? formattingState.textAlign === "right"
550
- : false;
551
- const isFloatingToolbar = toolbarConfig.position === "floating";
1423
+ : tool === "fontWeight"
1424
+ ? formattingState.fontWeight !== DEFAULT_FORMATTING_STATE.fontWeight
1425
+ : tool === "fontFamily"
1426
+ ? formattingState.fontFamily !== DEFAULT_FORMATTING_STATE.fontFamily
1427
+ : tool === "lineHeight"
1428
+ ? formattingState.lineHeight !== DEFAULT_FORMATTING_STATE.lineHeight
1429
+ : tool === "letterSpacing"
1430
+ ? formattingState.letterSpacing !== DEFAULT_FORMATTING_STATE.letterSpacing
1431
+ : tool === "alignLeft"
1432
+ ? formattingState.textAlign === "left"
1433
+ : tool === "alignCenter"
1434
+ ? formattingState.textAlign === "center"
1435
+ : tool === "alignRight"
1436
+ ? formattingState.textAlign === "right"
1437
+ : false;
552
1438
  switch (tool) {
553
1439
  case "bold":
554
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: handleBold, disabled: isDisabled, "aria-label": "Bold", "aria-pressed": isActive ? "true" : "false", children: jsx(BoldIcon, { className: "w-4 h-4" }) }, toolKey));
1440
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: handleBold, disabled: isDisabled, "aria-label": "Bold", "aria-pressed": isActive ? "true" : "false", children: jsx(BoldIcon, { className: "w-4 h-4" }) }, toolKey));
555
1441
  case "italic":
556
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: handleItalic, disabled: isDisabled, "aria-label": "Italic", "aria-pressed": isActive ? "true" : "false", children: jsx(ItalicIcon, { className: "w-4 h-4" }) }, toolKey));
1442
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: handleItalic, disabled: isDisabled, "aria-label": "Italic", "aria-pressed": isActive ? "true" : "false", children: jsx(ItalicIcon, { className: "w-4 h-4" }) }, toolKey));
557
1443
  case "underline":
558
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: handleUnderline, disabled: isDisabled, "aria-label": "Underline", "aria-pressed": isActive ? "true" : "false", children: jsx(UnderlineIcon, { className: "w-4 h-4" }) }, toolKey));
1444
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: handleUnderline, disabled: isDisabled, "aria-label": "Underline", "aria-pressed": isActive ? "true" : "false", children: jsx(UnderlineIcon, { className: "w-4 h-4" }) }, toolKey));
1445
+ case "fontFamily": {
1446
+ // The `fontFamily` tool is opt-in via toolbar config, but
1447
+ // `fontFamilies` is also a public prop — if a consumer passes an
1448
+ // empty array the dropdown has nothing to show and the trigger
1449
+ // can't compute a current option to display. Skip rendering the
1450
+ // tool entirely in that case rather than crashing on undefined.
1451
+ if (fontFamilies.length === 0) {
1452
+ if (process.env.NODE_ENV !== "production") {
1453
+ console.warn("[RichTextEditor]: `fontFamily` toolbar tool was requested but `fontFamilies` is empty — the tool will not render. Either remove `fontFamily` from toolbar.tools or supply at least one option.");
1454
+ }
1455
+ return null;
1456
+ }
1457
+ const currentOption = (_a = fontFamilies.find((opt) => opt.key === formattingState.fontFamily)) !== null && _a !== void 0 ? _a : fontFamilies[0];
1458
+ const portalStyle = {
1459
+ position: "fixed",
1460
+ top: fontFamilyPortalPosition.top,
1461
+ left: fontFamilyPortalPosition.left,
1462
+ zIndex: 1000,
1463
+ ...fontFamilyPortalCssVars,
1464
+ };
1465
+ const renderedDropdown = isClient
1466
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showFontFamilyPicker && (jsx(motion.div, { ref: (el) => {
1467
+ fontFamilyPortalContentRef.current = el;
1468
+ }, role: "listbox", className: themeStyles.dropdownStyle, style: portalStyle, variants: shouldReduceMotion ? undefined : fontFamilyAnimations, initial: "initial", animate: fontFamilyPortalReady ? "visible" : "initial", exit: "exit", children: fontFamilies.map((option) => {
1469
+ const selected = option.key === formattingState.fontFamily;
1470
+ return (jsx("button", { className: themeStyles.dropdownItemStyle, "data-active": selected ? "true" : "false", onMouseDown: (event) => {
1471
+ event.preventDefault();
1472
+ focusEditor();
1473
+ }, onClick: (event) => {
1474
+ event.preventDefault();
1475
+ event.stopPropagation();
1476
+ handleFontFamily(option.key);
1477
+ }, role: "option", "aria-selected": selected, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "text-sm", style: { fontFamily: option.stack || undefined }, children: option.label }), option.family && (jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Google" }))] }) }, option.key));
1478
+ }) })) }), document.body)
1479
+ : null;
1480
+ return (jsxs("div", { className: "relative", "data-tool": "fontFamily", children: [jsx(Button, { ref: fontFamilyTriggerRef, size: "sm", color: "neutral", variant: "soft", className: cn(themeStyles.toolbarButtonStyle, "!justify-start !w-auto px-2 min-w-[4rem] gap-1"), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
1481
+ event.preventDefault();
1482
+ event.stopPropagation();
1483
+ focusEditor();
1484
+ togglePicker("fontFamily");
1485
+ }, disabled: isDisabled, "aria-label": `Font family (${currentOption.label})`, "aria-expanded": showFontFamilyPicker, "aria-haspopup": "listbox", children: jsx("span", { className: "text-sm font-medium truncate text-left max-w-[5.5rem] block", style: { fontFamily: currentOption.stack || undefined }, children: currentOption.label }) }), renderedDropdown] }, toolKey));
1486
+ }
1487
+ case "fontWeight": {
1488
+ const currentOption = getFontWeightOption(formattingState.fontWeight);
1489
+ const portalStyle = {
1490
+ position: "fixed",
1491
+ top: fontWeightPortalPosition.top,
1492
+ left: fontWeightPortalPosition.left,
1493
+ zIndex: 1000,
1494
+ ...fontWeightPortalCssVars,
1495
+ };
1496
+ const renderedDropdown = isClient
1497
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showFontWeightPicker && (jsx(motion.div, { ref: (el) => {
1498
+ fontWeightPortalContentRef.current = el;
1499
+ }, role: "listbox", className: themeStyles.dropdownStyle, style: portalStyle, variants: shouldReduceMotion ? undefined : fontWeightAnimations, initial: "initial", animate: fontWeightPortalReady ? "visible" : "initial", exit: "exit", children: FONT_WEIGHT_OPTIONS.map((option) => {
1500
+ const selected = option.key === formattingState.fontWeight;
1501
+ return (jsx("button", { className: themeStyles.dropdownItemStyle, "data-active": selected ? "true" : "false", onMouseDown: (event) => {
1502
+ event.preventDefault();
1503
+ focusEditor();
1504
+ }, onClick: (event) => {
1505
+ event.preventDefault();
1506
+ event.stopPropagation();
1507
+ handleFontWeight(option.key);
1508
+ }, role: "option", "aria-selected": selected, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "text-sm", style: { fontWeight: option.value }, children: option.label }), jsx("span", { className: "text-sm text-[var(--color-text-muted)]", children: option.value })] }) }, option.key));
1509
+ }) })) }), document.body)
1510
+ : null;
1511
+ return (jsxs("div", { className: "relative", "data-tool": "fontWeight", children: [jsx(Button, { ref: fontWeightTriggerRef, size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
1512
+ event.preventDefault();
1513
+ event.stopPropagation();
1514
+ focusEditor();
1515
+ togglePicker("fontWeight");
1516
+ }, disabled: isDisabled, "aria-label": `Font weight (${currentOption.label})`, "aria-expanded": showFontWeightPicker, "aria-haspopup": "listbox", children: jsx(BoldIcon, { className: "w-4 h-4" }) }), renderedDropdown] }, toolKey));
1517
+ }
559
1518
  case "fontSize": {
560
1519
  const currentOption = getFontSizeOption(formattingState.fontSize);
561
- const dropdownClassName = themeStyles.dropdownStyle;
562
- const dropdownList = (jsx("div", { className: dropdownClassName, role: "listbox", children: FONT_SIZE_OPTIONS.map((option) => (jsx("button", { className: cn(themeStyles.dropdownItemStyle, option.key === formattingState.fontSize
563
- ? "bg-[var(--color-primary-50)] text-[var(--color-primary-900)]"
564
- : undefined), "data-active": option.key === formattingState.fontSize ? "true" : "false", onMouseDown: (event) => {
565
- event.preventDefault();
566
- focusEditor();
567
- }, onClick: (event) => {
568
- event.preventDefault();
569
- event.stopPropagation();
570
- handleFontSize(option.key);
571
- }, role: "option", "aria-selected": option.key === formattingState.fontSize, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "uppercase text-sm font-medium", children: option.label }), jsxs("span", { className: "text-sm text-[var(--color-text-muted)]", children: [option.px, "px"] })] }) }, option.key))) }));
572
- let renderedDropdown = null;
573
- if (isFloatingToolbar && isClient) {
574
- const triggerWidth = (_a = fontSizeTriggerRef.current) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect().width;
575
- const computedWidth = fontSizePortalPosition.width || triggerWidth || undefined;
576
- const dropdownStyle = {
577
- position: "fixed",
578
- top: fontSizePortalPosition.top,
579
- left: fontSizePortalPosition.left,
580
- zIndex: 1000,
581
- ...fontSizePortalCssVars,
582
- };
583
- if (computedWidth) {
584
- dropdownStyle.minWidth = computedWidth;
585
- dropdownStyle.width = computedWidth;
586
- }
587
- renderedDropdown = createPortal(jsx(AnimatePresence, { mode: "wait", children: showFontSizePicker && (jsx(motion.div, { ref: (el) => {
1520
+ const portalStyle = {
1521
+ position: "fixed",
1522
+ top: fontSizePortalPosition.top,
1523
+ left: fontSizePortalPosition.left,
1524
+ zIndex: 1000,
1525
+ ...fontSizePortalCssVars,
1526
+ };
1527
+ const renderedDropdown = isClient
1528
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showFontSizePicker && (jsx(motion.div, { ref: (el) => {
588
1529
  fontSizePortalContentRef.current = el;
589
- }, style: dropdownStyle, variants: shouldReduceMotion ? undefined : fontSizeAnimations, initial: "initial", animate: fontSizePortalReady ? "visible" : "initial", exit: "exit", children: dropdownList })) }), document.body);
590
- }
591
- else {
592
- renderedDropdown = showFontSizePicker ? dropdownList : null;
593
- if (!showFontSizePicker) {
594
- fontSizePortalContentRef.current = null;
595
- }
596
- }
597
- return (jsxs("div", { className: "relative", "data-tool": "fontSize", children: [jsx(Button, { ref: fontSizeTriggerRef, size: "sm", color: "neutral", variant: "soft", className: cn(themeStyles.toolbarButtonStyle, "px-2 min-w-[3rem] gap-1"), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: (event) => {
1530
+ }, role: "listbox", className: themeStyles.dropdownStyle, style: portalStyle, variants: shouldReduceMotion ? undefined : fontSizeAnimations, initial: "initial", animate: fontSizePortalReady ? "visible" : "initial", exit: "exit", children: FONT_SIZE_OPTIONS.map((option) => {
1531
+ const selected = option.key === formattingState.fontSize;
1532
+ return (jsx("button", { className: themeStyles.dropdownItemStyle, "data-active": selected ? "true" : "false", onMouseDown: (event) => {
1533
+ event.preventDefault();
1534
+ focusEditor();
1535
+ }, onClick: (event) => {
1536
+ event.preventDefault();
1537
+ event.stopPropagation();
1538
+ handleFontSize(option.key);
1539
+ }, role: "option", "aria-selected": selected, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "uppercase text-sm font-medium", children: option.label }), jsxs("span", { className: "text-sm text-[var(--color-text-muted)]", children: [option.px, "px"] })] }) }, option.key));
1540
+ }) })) }), document.body)
1541
+ : null;
1542
+ return (jsxs("div", { className: "relative", "data-tool": "fontSize", children: [jsx(Button, { ref: fontSizeTriggerRef, size: "sm", color: "neutral", variant: "soft", className: cn(themeStyles.toolbarButtonStyle, "px-2 min-w-[3rem] gap-1"), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
598
1543
  event.preventDefault();
599
1544
  event.stopPropagation();
600
1545
  focusEditor();
601
- setShowFontSizePicker((previous) => !previous);
602
- setShowColorPicker(false);
603
- }, disabled: isDisabled, "aria-label": "Font size", "aria-expanded": showFontSizePicker, "aria-haspopup": "listbox", children: jsx("span", { className: "text-sm font-medium tracking-wide uppercase", children: currentOption.label }) }), toolbarConfig.position === "floating" ? null : renderedDropdown, toolbarConfig.position === "floating" ? renderedDropdown : null] }, toolKey));
604
- }
605
- case "textColor":
606
- {
607
- const colorDropdownClassName = cn(themeStyles.dropdownStyle, themeStyles.colorPickerStyle);
608
- const colorDropdownList = (jsx("div", { className: cn(colorDropdownClassName, "p-2"), children: colorOptions.map((color) => (jsx("button", { className: themeStyles.colorSwatchStyle, style: { backgroundColor: color.value }, onMouseDown: (event) => {
1546
+ togglePicker("fontSize");
1547
+ }, disabled: isDisabled, "aria-label": "Font size", "aria-expanded": showFontSizePicker, "aria-haspopup": "listbox", children: jsx("span", { className: "text-sm font-medium tracking-wide uppercase", children: currentOption.label }) }), renderedDropdown] }, toolKey));
1548
+ }
1549
+ case "lineHeight": {
1550
+ const currentOption = getLineHeightOption(formattingState.lineHeight);
1551
+ const portalStyle = {
1552
+ position: "fixed",
1553
+ top: lineHeightPortalPosition.top,
1554
+ left: lineHeightPortalPosition.left,
1555
+ zIndex: 1000,
1556
+ ...lineHeightPortalCssVars,
1557
+ };
1558
+ const renderedDropdown = isClient
1559
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showLineHeightPicker && (jsx(motion.div, { ref: (el) => {
1560
+ lineHeightPortalContentRef.current = el;
1561
+ }, role: "listbox", className: themeStyles.dropdownStyle, style: portalStyle, variants: shouldReduceMotion ? undefined : lineHeightAnimations, initial: "initial", animate: lineHeightPortalReady ? "visible" : "initial", exit: "exit", children: LINE_HEIGHT_OPTIONS.map((option) => {
1562
+ const selected = option.key === formattingState.lineHeight;
1563
+ return (jsx("button", { className: themeStyles.dropdownItemStyle, "data-active": selected ? "true" : "false", onMouseDown: (event) => {
1564
+ event.preventDefault();
1565
+ focusEditor();
1566
+ }, onClick: (event) => {
1567
+ event.preventDefault();
1568
+ event.stopPropagation();
1569
+ handleLineHeight(option.key);
1570
+ }, role: "option", "aria-selected": selected, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "text-sm font-medium", children: option.label }), jsx("span", { className: "text-sm text-[var(--color-text-muted)]", children: option.value })] }) }, option.key));
1571
+ }) })) }), document.body)
1572
+ : null;
1573
+ return (jsxs("div", { className: "relative", "data-tool": "lineHeight", children: [jsx(Button, { ref: lineHeightTriggerRef, size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
609
1574
  event.preventDefault();
1575
+ event.stopPropagation();
610
1576
  focusEditor();
611
- }, onClick: (event) => {
1577
+ togglePicker("lineHeight");
1578
+ }, disabled: isDisabled, "aria-label": `Line height (${currentOption.label})`, "aria-expanded": showLineHeightPicker, "aria-haspopup": "listbox", children: jsx(ArrowsUpDownIcon, { className: "w-4 h-4" }) }), renderedDropdown] }, toolKey));
1579
+ }
1580
+ case "letterSpacing": {
1581
+ const currentOption = getLetterSpacingOption(formattingState.letterSpacing);
1582
+ const portalStyle = {
1583
+ position: "fixed",
1584
+ top: letterSpacingPortalPosition.top,
1585
+ left: letterSpacingPortalPosition.left,
1586
+ zIndex: 1000,
1587
+ ...letterSpacingPortalCssVars,
1588
+ };
1589
+ const renderedDropdown = isClient
1590
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showLetterSpacingPicker && (jsx(motion.div, { ref: (el) => {
1591
+ letterSpacingPortalContentRef.current = el;
1592
+ }, role: "listbox", className: themeStyles.dropdownStyle, style: portalStyle, variants: shouldReduceMotion ? undefined : letterSpacingAnimations, initial: "initial", animate: letterSpacingPortalReady ? "visible" : "initial", exit: "exit", children: LETTER_SPACING_OPTIONS.map((option) => {
1593
+ const selected = option.key === formattingState.letterSpacing;
1594
+ return (jsx("button", { className: themeStyles.dropdownItemStyle, "data-active": selected ? "true" : "false", onMouseDown: (event) => {
1595
+ event.preventDefault();
1596
+ focusEditor();
1597
+ }, onClick: (event) => {
1598
+ event.preventDefault();
1599
+ event.stopPropagation();
1600
+ handleLetterSpacing(option.key);
1601
+ }, role: "option", "aria-selected": selected, children: jsxs("span", { className: "flex items-center justify-between gap-3", children: [jsx("span", { className: "text-sm font-medium", children: option.label }), jsx("span", { className: "text-sm text-[var(--color-text-muted)]", children: option.value })] }) }, option.key));
1602
+ }) })) }), document.body)
1603
+ : null;
1604
+ return (jsxs("div", { className: "relative", "data-tool": "letterSpacing", children: [jsx(Button, { ref: letterSpacingTriggerRef, size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
612
1605
  event.preventDefault();
613
1606
  event.stopPropagation();
614
- handleTextColor(color.value);
615
- }, "aria-label": `Set text color to ${color.label}` }, color.value))) }));
616
- let renderedColorDropdown = null;
617
- if (isFloatingToolbar && isClient) {
618
- const triggerWidth = (_b = colorTriggerRef.current) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect().width;
619
- const computedWidth = colorPortalPosition.width || triggerWidth || undefined;
620
- const dropdownStyle = {
621
- position: "fixed",
622
- top: colorPortalPosition.top,
623
- left: colorPortalPosition.left,
624
- zIndex: 1000,
625
- ...colorPortalCssVars,
626
- };
627
- if (computedWidth) {
628
- dropdownStyle.minWidth = computedWidth;
629
- dropdownStyle.width = computedWidth;
630
- }
631
- renderedColorDropdown = createPortal(jsx(AnimatePresence, { mode: "wait", children: showColorPicker && (jsx(motion.div, { ref: (el) => {
632
- colorPortalContentRef.current = el;
633
- }, style: dropdownStyle, variants: shouldReduceMotion ? undefined : colorAnimations, initial: "initial", animate: colorPortalReady ? "visible" : "initial", exit: "exit", children: colorDropdownList })) }), document.body);
634
- }
635
- else {
636
- renderedColorDropdown = showColorPicker ? colorDropdownList : null;
637
- if (!showColorPicker) {
638
- colorPortalContentRef.current = null;
639
- }
640
- }
641
- return (jsxs("div", { className: "relative", "data-tool": "textColor", children: [jsx(Button, { ref: colorTriggerRef, size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), onMouseDown: handleMouseDown, onClick: (event) => {
642
- event.preventDefault();
643
- event.stopPropagation();
644
- focusEditor();
645
- setShowFontSizePicker(false);
646
- setShowColorPicker((previous) => !previous);
647
- }, disabled: isDisabled, "aria-label": "Text Color", "aria-expanded": showColorPicker, children: jsx(PaintBrushIcon, { className: "w-4 h-4" }) }), isFloatingToolbar ? null : renderedColorDropdown, isFloatingToolbar ? renderedColorDropdown : null] }, toolKey));
648
- }
1607
+ focusEditor();
1608
+ togglePicker("letterSpacing");
1609
+ }, disabled: isDisabled, "aria-label": `Letter spacing (${currentOption.label})`, "aria-expanded": showLetterSpacingPicker, "aria-haspopup": "listbox", children: jsx(ArrowsRightLeftIcon, { className: "w-4 h-4" }) }), renderedDropdown] }, toolKey));
1610
+ }
1611
+ case "textColor": {
1612
+ const portalStyle = {
1613
+ position: "fixed",
1614
+ top: colorPortalPosition.top,
1615
+ left: colorPortalPosition.left,
1616
+ zIndex: 1000,
1617
+ ...colorPortalCssVars,
1618
+ };
1619
+ const currentToken = formattingState.textColor;
1620
+ // `bgValue` paints the swatch (resolved hex/oklch); `token` is the
1621
+ // Tailwind shade fragment (`rose-500`, `white`, …) used to build
1622
+ // the `text-{token}` class applied to the editor content.
1623
+ const renderSwatch = (bgValue, token, label) => {
1624
+ const isActiveSwatch = token === currentToken;
1625
+ return (jsx("button", { className: themeStyles.colorSwatchStyle, style: { backgroundColor: bgValue }, "data-active": isActiveSwatch ? "true" : "false", onMouseDown: (event) => {
1626
+ event.preventDefault();
1627
+ focusEditor();
1628
+ }, onClick: (event) => {
1629
+ event.preventDefault();
1630
+ event.stopPropagation();
1631
+ handleTextColor(token);
1632
+ }, "aria-label": `Set text color to ${label}` }, `swatch-${label}`));
1633
+ };
1634
+ const renderedColorDropdown = isClient
1635
+ ? createPortal(jsx(AnimatePresence, { mode: "wait", children: showColorPicker && (jsx(motion.div, { ref: (el) => {
1636
+ colorPortalContentRef.current = el;
1637
+ }, className: cn(themeStyles.dropdownStyle, "!max-h-none !overflow-visible"), style: portalStyle, variants: shouldReduceMotion ? undefined : colorAnimations, initial: "initial", animate: colorPortalReady ? "visible" : "initial", exit: "exit", children: jsx(ScrollArea, { type: "none", className: "max-h-[20rem]", children: jsxs("div", { className: themeStyles.colorPickerStyle, children: [recentColors.length > 0 && (jsxs("div", { className: "flex flex-col gap-1 pb-2 mb-1 border-b border-[var(--color-border)]", children: [jsx("span", { className: "text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: "Recent" }), jsx("div", { className: "flex gap-0.5", children: recentColors.map((token) => renderSwatch(resolveTailwindColor(token), token, `recent ${token}`)) })] })), jsxs("div", { className: "flex flex-col gap-1", children: [jsx("span", { className: "text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: "Mono" }), jsx("div", { className: "flex gap-0.5", children: MONO_COLORS.map((color) => renderSwatch(color.value, color.token, color.name)) }), jsx("span", { className: "mt-2 text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: "Spectrum" }), TAILWIND_COLOR_FAMILIES.map((family) => (jsx("div", { className: "flex gap-0.5", children: getTailwindShadeTokens(family).map((token) => renderSwatch(resolveTailwindColor(token), token, token)) }, family)))] })] }) }) })) }), document.body)
1638
+ : null;
1639
+ return (jsxs("div", { className: "relative", "data-tool": "textColor", children: [jsx(Button, { ref: colorTriggerRef, size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), onMouseDown: handleMouseDown, onClick: (event) => {
1640
+ event.preventDefault();
1641
+ event.stopPropagation();
1642
+ focusEditor();
1643
+ togglePicker("color");
1644
+ }, disabled: isDisabled, "aria-label": "Text Color", "aria-expanded": showColorPicker, children: jsx(PaintBrushIcon, { className: "w-4 h-4" }) }), renderedColorDropdown] }, toolKey));
1645
+ }
649
1646
  case "alignLeft":
650
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: (event) => {
1647
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
651
1648
  event.preventDefault();
652
1649
  event.stopPropagation();
653
1650
  handleAlign("left");
654
1651
  }, disabled: isDisabled, "aria-label": "Align Left", "aria-pressed": isActive ? "true" : "false", children: jsx(Bars3BottomLeftIcon, { className: "w-4 h-4" }) }, toolKey));
655
1652
  case "alignCenter":
656
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: (event) => {
1653
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
657
1654
  event.preventDefault();
658
1655
  event.stopPropagation();
659
1656
  handleAlign("center");
660
1657
  }, disabled: isDisabled, "aria-label": "Align Center", "aria-pressed": isActive ? "true" : "false", children: jsx(Bars3Icon, { className: "w-4 h-4" }) }, toolKey));
661
1658
  case "alignRight":
662
- return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", "data-pressed": isActive ? "true" : "false", onMouseDown: handleMouseDown, onClick: (event) => {
1659
+ return (jsx(Button, { size: "sm", color: "neutral", variant: "soft", iconOnly: true, className: cn(themeStyles.toolbarButtonStyle), "data-state": isActive ? "active" : "inactive", onMouseDown: handleMouseDown, onClick: (event) => {
663
1660
  event.preventDefault();
664
1661
  event.stopPropagation();
665
1662
  handleAlign("right");
@@ -677,12 +1674,11 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
677
1674
  const renderToolbar = () => {
678
1675
  if (!toolbarConfig.show || !toolbarConfig.tools)
679
1676
  return null;
680
- const toolbarClass = cn(themeStyles.toolbarStyle, toolbarConfig.className);
681
- const toolbarContent = (jsx("div", { ref: toolbarRef, className: toolbarClass, children: toolbarConfig.tools.map((tool) => renderToolbarTool(tool, tool)) }));
1677
+ const tools = toolbarConfig.tools.map((tool) => renderToolbarTool(tool, tool));
682
1678
  if (toolbarConfig.position === "floating") {
683
- return (jsx(AnimatePresence, { mode: "wait", children: showToolbar && (jsx(motion.div, { variants: shouldReduceMotion ? undefined : floatingToolbarVariants, initial: "initial", animate: "animate", exit: "exit", className: "bg-white/70 backdrop-blur-sm shadow-2xl rounded-xl border border-[var(--color-border)] overflow-hidden", style: { position: "absolute", top: toolbarPosition.top, left: toolbarPosition.left, zIndex: 50 }, children: toolbarContent }, "floating-toolbar")) }));
1679
+ return (jsx(AnimatePresence, { mode: "wait", children: showToolbar && (jsx(motion.div, { ref: toolbarRef, variants: shouldReduceMotion ? undefined : floatingToolbarVariants, initial: "initial", animate: "animate", exit: "exit", className: cn(themeStyles.floatingToolbarStyle, toolbarConfig.className), style: { position: "absolute", top: toolbarPosition.top, left: toolbarPosition.left, zIndex: 50 }, children: tools }, "floating-toolbar")) }));
684
1680
  }
685
- return toolbarContent;
1681
+ return (jsx("div", { ref: toolbarRef, className: cn(themeStyles.toolbarStyle, toolbarConfig.className), children: tools }));
686
1682
  };
687
1683
  return (jsxs("div", { className: cn(themeStyles.wrapperStyle, className), ...props, children: [label && (typeof label === 'string' ? (jsx("div", { className: "mb-1", children: jsx(FormLabel, { htmlFor: editorId, className: labelClassName, state: state, required: required, children: label }) })) : (label)), jsxs("div", { className: "relative", children: [jsxs("div", { className: cn(themeStyles.containerStyle, themeStyles.states[state], themeStyles.sizes[size]), "data-state": getDataState(), children: [toolbarConfig.position === "top" && renderToolbar(), jsx("div", { ref: (el) => {
688
1684
  if (el) {
@@ -702,10 +1698,9 @@ const RichTextEditor = React__default.forwardRef(({ size = "md", state = "defaul
702
1698
  if (toolbarConfig.position === "floating") {
703
1699
  setShowToolbar(false);
704
1700
  }
705
- setShowColorPicker(false);
706
- setShowFontSizePicker(false);
1701
+ closeAllPickers();
707
1702
  }, "aria-invalid": isInvalid, "aria-required": required, role: "textbox", "aria-multiline": "true", "aria-label": typeof label === 'string' ? label : "Rich text editor", suppressContentEditableWarning: true })] }), toolbarConfig.position === "floating" && renderToolbar()] }), helperText && (typeof helperText === 'string' ? (jsx("div", { className: cn(themeStyles.helperText, helperTextClassName), children: helperText })) : (helperText))] }));
708
1703
  });
709
1704
  RichTextEditor.displayName = "RichTextEditor";
710
1705
 
711
- export { RichTextEditor };
1706
+ export { DEFAULT_FONT_FAMILY_OPTIONS, RichTextEditor, loadGoogleFont };