@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.
- package/dist/components/ai/CodeBlock/CodeBlock.theme.js +2 -2
- package/dist/components/ai/PromptInput/PromptInput.js +1 -0
- package/dist/components/ai/PromptInput/VoiceRecorder.js +1 -0
- package/dist/components/ai/StreamingResponse/MarkdownRenderer.js +1 -1
- package/dist/components/ai/TokenCounter/TokenCounter.theme.js +1 -1
- package/dist/components/charts/BarChart/BarChart.js +2 -2
- package/dist/components/charts/StackedBarChart/StackedBarChart.js +1 -1
- package/dist/components/charts/shared/BarRenderer/BarRenderer.js +2 -2
- package/dist/components/core/Accordion/Accordion.theme.js +2 -2
- package/dist/components/core/Breadcrumbs/Breadcrumbs.js +1 -1
- package/dist/components/core/Breadcrumbs/Breadcrumbs.theme.js +2 -2
- package/dist/components/core/Button/Button.js +1 -1
- package/dist/components/core/Button/Button.theme.js +4 -4
- package/dist/components/core/Calendar/Calendar.js +1 -1
- package/dist/components/core/Calendar/CalendarMini/CalendarMini.js +4 -2
- package/dist/components/core/Calendar/CalendarMini/CalendarMini.theme.js +2 -2
- package/dist/components/core/ContextMenu/ContextMenu.theme.js +1 -1
- package/dist/components/core/Drawer/Drawer.theme.js +2 -2
- package/dist/components/core/Dropdown/Dropdown.theme.js +2 -2
- package/dist/components/core/Empty/Empty.js +1 -1
- package/dist/components/core/Empty/Empty.theme.js +1 -1
- package/dist/components/core/ImagePlaceholder/ImagePlaceholder.d.ts +14 -0
- package/dist/components/core/ImagePlaceholder/ImagePlaceholder.js +28 -0
- package/dist/components/core/ImagePlaceholder/ImagePlaceholder.theme.d.ts +6 -0
- package/dist/components/core/ImagePlaceholder/ImagePlaceholder.theme.js +8 -0
- package/dist/components/core/ImagePlaceholder/ImagePlaceholder.types.d.ts +32 -0
- package/dist/components/core/ImagePlaceholder/index.d.ts +2 -0
- package/dist/components/core/Link/Link.js +1 -1
- package/dist/components/core/Message/Message.theme.js +1 -1
- package/dist/components/core/Modal/Modal.theme.js +1 -1
- package/dist/components/core/NavItem/NavItem.d.ts +1 -1
- package/dist/components/core/NavItem/NavItem.js +171 -68
- package/dist/components/core/NavItem/NavItem.theme.js +20 -12
- package/dist/components/core/NavItem/NavItem.types.d.ts +17 -39
- package/dist/components/core/Pagination/Pagination.theme.js +3 -3
- package/dist/components/core/Popover/Popover.theme.js +1 -1
- package/dist/components/core/Progress/Progress.theme.js +1 -1
- package/dist/components/core/ScrollArea/ScrollArea.js +2 -2
- package/dist/components/core/ScrollArea/ScrollArea.theme.js +1 -1
- package/dist/components/core/Segmented/Segmented.js +8 -6
- package/dist/components/core/Segmented/Segmented.theme.js +3 -3
- package/dist/components/core/Sidebar/Sidebar.d.ts +1 -1
- package/dist/components/core/Sidebar/Sidebar.js +42 -13
- package/dist/components/core/Sidebar/Sidebar.theme.js +5 -7
- package/dist/components/core/Sidebar/Sidebar.types.d.ts +33 -4
- package/dist/components/core/Sidebar/SidebarContent.js +6 -6
- package/dist/components/core/Sidebar/SidebarContext.d.ts +17 -16
- package/dist/components/core/Sidebar/SidebarContext.js +42 -19
- package/dist/components/core/Sidebar/SidebarFooter.js +4 -6
- package/dist/components/core/Sidebar/SidebarHeader.js +4 -9
- package/dist/components/core/Sidebar/SidebarLogo.js +3 -7
- package/dist/components/core/Sidebar/SidebarNav.js +4 -3
- package/dist/components/core/Sidebar/SidebarNavGroup.js +4 -7
- package/dist/components/core/Sidebar/SidebarToggle.js +6 -1
- package/dist/components/core/Sidebar/SidebarUserProfile.js +33 -17
- package/dist/components/core/Sidebar/index.d.ts +2 -3
- package/dist/components/core/Skeleton/Skeleton.theme.js +1 -1
- package/dist/components/core/Spinner/Spinner.theme.js +1 -1
- package/dist/components/core/Tabs/Tabs.js +6 -1
- package/dist/components/core/Tabs/Tabs.types.d.ts +2 -0
- package/dist/components/core/Tabs/TabsBody.js +5 -3
- package/dist/components/core/Tag/Tag.theme.js +1 -1
- package/dist/components/core/index.d.ts +1 -0
- package/dist/components/core/index.js +2 -2
- package/dist/components/data-display/GanttChart/GanttTableGroupRow.js +1 -1
- package/dist/components/data-display/KPI/KPI.js +13 -3
- package/dist/components/data-display/KPI/KPI.theme.js +3 -3
- package/dist/components/data-display/Metric/Metric.theme.js +3 -3
- package/dist/components/data-display/Table/Table.js +4 -3
- package/dist/components/data-display/Table/Table.theme.js +11 -1
- package/dist/components/data-display/Table/Table.types.d.ts +15 -0
- package/dist/components/data-display/Table/TableColumnManager.js +1 -1
- package/dist/components/data-display/Table/index.d.ts +2 -2
- package/dist/components/data-display/Timeline/Timeline.animations.d.ts +0 -7
- package/dist/components/data-display/Timeline/Timeline.animations.js +1 -27
- package/dist/components/data-display/Timeline/Timeline.js +3 -4
- package/dist/components/data-display/Timeline/Timeline.types.d.ts +0 -13
- package/dist/components/data-display/Timeline/TimelineContent.js +3 -7
- package/dist/components/data-display/Timeline/TimelineItem.js +4 -17
- package/dist/components/data-display/Timeline/index.d.ts +2 -2
- package/dist/components/effects/Aurora/Aurora.js +79 -40
- package/dist/components/effects/InteractiveCharacters/InteractiveCharacters.js +3 -3
- package/dist/components/effects/InteractiveCharacters/characters/BlobCharacter.js +13 -4
- package/dist/components/effects/InteractiveCharacters/characters/SmallCharacter.js +13 -4
- package/dist/components/effects/InteractiveCharacters/characters/TallCharacter.js +14 -4
- package/dist/components/effects/MorphingText/MorphingText.js +40 -7
- package/dist/components/effects/StripePattern/StripePattern.d.ts +18 -0
- package/dist/components/effects/StripePattern/StripePattern.js +54 -0
- package/dist/components/effects/StripePattern/StripePattern.types.d.ts +53 -0
- package/dist/components/effects/StripePattern/index.d.ts +2 -0
- package/dist/components/effects/index.d.ts +9 -0
- package/dist/components/effects/index.js +4 -0
- package/dist/components/forms/Checkbox/Checkbox.theme.js +1 -1
- package/dist/components/forms/CronInput/CronInput.theme.js +16 -16
- package/dist/components/forms/DatePicker/DatePickerTrigger.js +1 -1
- package/dist/components/forms/DateRangePicker/DateRangePicker.theme.js +1 -1
- package/dist/components/forms/DateRangePicker/DateRangePickerTrigger.js +1 -1
- package/dist/components/forms/FileUpload/FileUpload.js +1 -1
- package/dist/components/forms/FileUpload/FileUploadProgress.js +27 -20
- package/dist/components/forms/FileUpload/FileUploadProgress.theme.js +2 -2
- package/dist/components/forms/Input/Input.theme.js +9 -11
- package/dist/components/forms/Mention/Mention.theme.js +3 -3
- package/dist/components/forms/Radio/Radio.theme.js +1 -1
- package/dist/components/forms/RichTextEditor/RichTextEditor.d.ts +18 -0
- package/dist/components/forms/RichTextEditor/RichTextEditor.js +1231 -236
- package/dist/components/forms/RichTextEditor/RichTextEditor.theme.d.ts +53 -1
- package/dist/components/forms/RichTextEditor/RichTextEditor.theme.js +99 -25
- package/dist/components/forms/RichTextEditor/RichTextEditor.types.d.ts +42 -3
- package/dist/components/forms/RichTextEditor/index.d.ts +2 -2
- package/dist/components/forms/Select/Select.js +2 -2
- package/dist/components/forms/Slider/Slider.theme.js +1 -1
- package/dist/components/forms/Switch/Switch.theme.js +1 -1
- package/dist/components/forms/TimePicker/TimePicker.theme.js +2 -2
- package/dist/components/forms/TimePicker/TimePickerContent.js +1 -0
- package/dist/components/forms/TimePicker/TimePickerTrigger.js +1 -1
- package/dist/components/forms/forms.theme.js +12 -12
- package/dist/components/forms/index.js +1 -0
- package/dist/components/layout/FormLayout/FormLayout.js +6 -4
- package/dist/components/layout/FormLayout/FormLayout.types.d.ts +3 -3
- package/dist/components/layout/FormLayout/FormLayoutSection.js +6 -1
- package/dist/components/layout/PageLayout/PageLayout.d.ts +2 -2
- package/dist/components/layout/PageLayout/PageLayout.js +23 -5
- package/dist/components/layout/PageLayout/PageLayout.theme.d.ts +2 -1
- package/dist/components/layout/PageLayout/PageLayout.theme.js +35 -6
- package/dist/components/layout/PageLayout/PageLayout.types.d.ts +45 -0
- package/dist/components/layout/PageLayout/PageLayoutContent.js +6 -4
- package/dist/components/layout/PageLayout/PageLayoutHeader.js +3 -3
- package/dist/components/layout/PageLayout/PageLayoutSidebar.js +5 -3
- package/dist/components/layout/PageLayout/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/styles/theme.css +127 -92
- package/dist/components/core/Sidebar/SidebarSubmenu.d.ts +0 -7
- 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,
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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("
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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("
|
|
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
|
-
}, [
|
|
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
|
-
|
|
287
|
-
|
|
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
|
|
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
|
|
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: (
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
379
|
-
|
|
983
|
+
const option = getFontSizeOption(sizeKey);
|
|
984
|
+
const selection = window.getSelection();
|
|
985
|
+
if (!selection || selection.rangeCount === 0) {
|
|
986
|
+
closeAllPickers();
|
|
987
|
+
return;
|
|
380
988
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
406
|
-
setShowColorPicker(false);
|
|
1119
|
+
closeAllPickers();
|
|
407
1120
|
}, [
|
|
1121
|
+
applyStyleToSelection,
|
|
1122
|
+
closeAllPickers,
|
|
408
1123
|
focusEditor,
|
|
1124
|
+
fontFamilies,
|
|
409
1125
|
handleContentChange,
|
|
410
1126
|
isDisabled,
|
|
411
|
-
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
545
|
-
? formattingState.
|
|
546
|
-
: tool === "
|
|
547
|
-
? formattingState.
|
|
548
|
-
: tool === "
|
|
549
|
-
? formattingState.
|
|
550
|
-
:
|
|
551
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
},
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
{
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 };
|