@crisp-ui-kit/crisp 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/components/composewindow/composewindow.js +144 -0
- package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
- package/dist/cjs/components/composewindow/index.js +8 -0
- package/dist/cjs/components/datatable/datatable.js +224 -43
- package/dist/cjs/components/datatable/datatable.recipe.js +37 -3
- package/dist/cjs/components/datatable/index.js +6 -0
- package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
- package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
- package/dist/cjs/components/menu/menu.js +4 -2
- package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/cjs/components/select/select.js +2 -1
- package/dist/cjs/components/table/table.recipe.js +12 -3
- package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/cjs/core/caret-rect.js +73 -0
- package/dist/cjs/core/cell-date.js +11 -0
- package/dist/cjs/core/index.js +8 -1
- package/dist/cjs/core/mention.js +32 -0
- package/dist/cjs/core/use-mention-trigger.js +124 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/product/recordpage/recordpage.js +88 -10
- package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +11 -29
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +55 -17
- package/dist/cjs/product/recordstable/editable-field.js +113 -0
- package/dist/cjs/product/recordstable/index.js +4 -1
- package/dist/cjs/product/recordstable/recordstable.js +67 -11
- package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
- package/dist/cjs/product/richtext/index.js +2 -1
- package/dist/cjs/product/richtext/richtext.js +440 -23
- package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
- package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/cjs/product/sidebar/sidebar.js +15 -3
- package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/composewindow.css +24 -0
- package/dist/composewindow.d.ts +7 -0
- package/dist/composewindow.layered.css +26 -0
- package/dist/datatable.css +33 -14
- package/dist/datatable.layered.css +33 -14
- package/dist/datepickerfield.css +9 -4
- package/dist/datepickerfield.layered.css +9 -4
- package/dist/dialog.css +2 -1
- package/dist/dialog.layered.css +2 -1
- package/dist/esm/components/composewindow/composewindow.js +107 -0
- package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
- package/dist/esm/components/composewindow/index.js +2 -0
- package/dist/esm/components/datatable/datatable.js +226 -45
- package/dist/esm/components/datatable/datatable.recipe.js +37 -3
- package/dist/esm/components/datatable/index.js +6 -0
- package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
- package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/esm/components/dialog/dialog.recipe.js +9 -1
- package/dist/esm/components/menu/menu.js +4 -2
- package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/esm/components/select/select.js +2 -1
- package/dist/esm/components/table/table.recipe.js +12 -3
- package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/esm/core/caret-rect.js +70 -0
- package/dist/esm/core/cell-date.js +10 -0
- package/dist/esm/core/index.js +4 -1
- package/dist/esm/core/mention.js +29 -0
- package/dist/esm/core/use-mention-trigger.js +88 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/product/recordpage/recordpage.js +89 -11
- package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/esm/product/recordpanel/index.js +1 -1
- package/dist/esm/product/recordpanel/recordpanel.js +13 -31
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +56 -18
- package/dist/esm/product/recordstable/editable-field.js +76 -0
- package/dist/esm/product/recordstable/index.js +1 -0
- package/dist/esm/product/recordstable/recordstable.js +69 -13
- package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
- package/dist/esm/product/richtext/index.js +1 -1
- package/dist/esm/product/richtext/richtext.js +439 -22
- package/dist/esm/product/richtext/richtext.recipe.js +60 -10
- package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/esm/product/sidebar/sidebar.js +15 -3
- package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/multiselect.css +8 -8
- package/dist/multiselect.layered.css +8 -8
- package/dist/recordpage.css +25 -13
- package/dist/recordpage.layered.css +25 -13
- package/dist/recordpanel.css +43 -20
- package/dist/recordpanel.layered.css +43 -20
- package/dist/recordstable.css +9 -2
- package/dist/recordstable.layered.css +9 -2
- package/dist/richtext.css +31 -5
- package/dist/richtext.layered.css +31 -5
- package/dist/settingsshell.css +2 -2
- package/dist/settingsshell.layered.css +2 -2
- package/dist/sidebar.css +7 -2
- package/dist/sidebar.layered.css +7 -2
- package/dist/styles.css +199 -74
- package/dist/styles.layered.css +199 -74
- package/dist/table.css +4 -2
- package/dist/table.layered.css +4 -2
- package/dist/tabletoolbar.css +1 -1
- package/dist/tabletoolbar.layered.css +1 -1
- package/dist/types/components/composewindow/composewindow.d.ts +80 -0
- package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
- package/dist/types/components/composewindow/index.d.ts +2 -0
- package/dist/types/components/datatable/datatable.d.ts +58 -4
- package/dist/types/components/datatable/index.d.ts +1 -1
- package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
- package/dist/types/components/menu/menu.d.ts +10 -1
- package/dist/types/components/select/select.d.ts +6 -1
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +29 -5
- package/dist/types/core/caret-rect.d.ts +16 -0
- package/dist/types/core/cell-date.d.ts +17 -0
- package/dist/types/core/index.d.ts +4 -1
- package/dist/types/core/mention.d.ts +27 -0
- package/dist/types/core/use-mention-trigger.d.ts +41 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/product/recordpage/recordpage.d.ts +59 -1
- package/dist/types/product/recordpanel/index.d.ts +1 -1
- package/dist/types/product/recordpanel/recordpanel.d.ts +81 -17
- package/dist/types/product/recordstable/editable-field.d.ts +48 -0
- package/dist/types/product/recordstable/index.d.ts +1 -0
- package/dist/types/product/recordstable/recordstable.d.ts +29 -0
- package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
- package/dist/types/product/richtext/index.d.ts +1 -1
- package/dist/types/product/richtext/richtext.d.ts +168 -7
- package/dist/types/product/sidebar/sidebar.d.ts +60 -4
- package/package.json +6 -1
|
@@ -1,19 +1,139 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Bold, Heading1, Heading2, Italic, Link2, List, ListOrdered,
|
|
2
|
+
import { Baseline, Bold, Heading1, Heading2, Heading3, Highlighter, Image as ImageIcon, Italic, Link2, List, ListOrdered, Pilcrow, Search, Strikethrough, } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { useListboxKeyboard } from "../../components/select/index.js";
|
|
5
|
+
import { matchMentionTrigger } from "../../core/index.js";
|
|
4
6
|
import { richtextRecipe } from "./richtext.recipe.js";
|
|
7
|
+
export const RICHTEXT_COLOR_TONES = [
|
|
8
|
+
{ id: "grey", label: "Grey", text: "rgba(0,0,0,0.63)", highlight: "#F6F7F7" },
|
|
9
|
+
{ id: "red", label: "Red", text: "#EB3B3B", highlight: "#FFEBEB" },
|
|
10
|
+
{ id: "orange", label: "Orange", text: "#C95908", highlight: "#FEEEE1" },
|
|
11
|
+
{ id: "yellow", label: "Yellow", text: "#CF8300", highlight: "#FFF3CC" },
|
|
12
|
+
{
|
|
13
|
+
id: "light-green",
|
|
14
|
+
label: "Light green",
|
|
15
|
+
text: "#748700",
|
|
16
|
+
highlight: "#F4FBCB",
|
|
17
|
+
},
|
|
18
|
+
{ id: "green", label: "Green", text: "#02AD6E", highlight: "#E0FCED" },
|
|
19
|
+
{ id: "teal", label: "Teal", text: "#0D99BD", highlight: "#DDF7FF" },
|
|
20
|
+
{ id: "blue", label: "Blue", text: "#266DF0", highlight: "#E5EEFF" },
|
|
21
|
+
{ id: "purple", label: "Purple", text: "#864AFF", highlight: "#F5F0FF" },
|
|
22
|
+
{ id: "violet", label: "Violet", text: "#CD33DE", highlight: "#FDEDFF" },
|
|
23
|
+
{ id: "magenta", label: "Magenta", text: "#E0387E", highlight: "#FEECF3" },
|
|
24
|
+
{ id: "brown", label: "Brown", text: "#8C7D00", highlight: "#FDF7C4" },
|
|
25
|
+
];
|
|
26
|
+
const BLOCK_MENU_ITEMS = [
|
|
27
|
+
{ id: "h1", label: "Heading 1", description: "Large section heading" },
|
|
28
|
+
{ id: "h2", label: "Heading 2", description: "Medium section heading" },
|
|
29
|
+
{ id: "h3", label: "Heading 3", description: "Small section heading" },
|
|
30
|
+
{
|
|
31
|
+
id: "body",
|
|
32
|
+
label: "Body",
|
|
33
|
+
description: "Just start typing with plain text",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "ul",
|
|
37
|
+
label: "Bulleted list",
|
|
38
|
+
description: "Create a simple bulleted list",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "ol",
|
|
42
|
+
label: "Numbered list",
|
|
43
|
+
description: "Create a list with numbering",
|
|
44
|
+
},
|
|
45
|
+
{ id: "image", label: "Image", description: "Upload an image" },
|
|
46
|
+
];
|
|
47
|
+
const filterBlockItems = (query) => {
|
|
48
|
+
const q = query.trim().toLowerCase();
|
|
49
|
+
if (!q)
|
|
50
|
+
return BLOCK_MENU_ITEMS;
|
|
51
|
+
return BLOCK_MENU_ITEMS.filter((item) => item.label.toLowerCase().includes(q));
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Is there an active "/query" block-insert trigger ending at the caret? The
|
|
55
|
+
* same word-boundary rule as a mention trigger: `/` must start a word (text
|
|
56
|
+
* start, or preceded by whitespace) and nothing between it and the caret may
|
|
57
|
+
* contain whitespace. A pure function, kept local rather than imported from
|
|
58
|
+
* a shared primitive — this component doesn't currently depend on one.
|
|
59
|
+
*/
|
|
60
|
+
function matchSlashTrigger(textBeforeCaret, trigger = "/") {
|
|
61
|
+
const start = textBeforeCaret.lastIndexOf(trigger);
|
|
62
|
+
if (start === -1)
|
|
63
|
+
return null;
|
|
64
|
+
const before = start > 0 ? textBeforeCaret[start - 1] : undefined;
|
|
65
|
+
if (before !== undefined && !/\s/.test(before))
|
|
66
|
+
return null;
|
|
67
|
+
const query = textBeforeCaret.slice(start + trigger.length);
|
|
68
|
+
if (/\s/.test(query))
|
|
69
|
+
return null;
|
|
70
|
+
return { query, start };
|
|
71
|
+
}
|
|
5
72
|
const exec = (cmd, value) => document.execCommand(cmd, false, value);
|
|
6
73
|
/**
|
|
7
|
-
* A block rich-text editor — measured from
|
|
8
|
-
* a prose body (
|
|
9
|
-
*
|
|
74
|
+
* A block rich-text editor — measured from Attio's own notes editor. A large
|
|
75
|
+
* title, a prose body (h1/h2/h3, bulleted/numbered lists, an image block,
|
|
76
|
+
* marks), a floating selection toolbar, a searchable block-type menu, and a
|
|
77
|
+
* caret-anchored mention trigger. Built on contentEditable, dependency-free.
|
|
10
78
|
* Uncontrolled: seed with `defaultTitle`/`defaultHtml`, read via `onChange`.
|
|
79
|
+
* Imperative actions (`insertMention`, `focus`) go through a ref handle
|
|
80
|
+
* rather than a new prop each — the rest of the surface stays uncontrolled.
|
|
81
|
+
*
|
|
82
|
+
* **No underline, no inline code, no blockquote** — all three confirmed
|
|
83
|
+
* absent from Attio's live toolbar (7 controls) and its exhaustive 7-item
|
|
84
|
+
* block-type menu; the generic baseline this component used to ship
|
|
85
|
+
* overshot Attio on all three. `.crisp-rte-body blockquote` still renders
|
|
86
|
+
* correctly for content pasted in from elsewhere — only the control for it
|
|
87
|
+
* is gone.
|
|
88
|
+
*
|
|
89
|
+
* **The block menu's own chrome (356px, radius 12px, `elevation.floating`,
|
|
90
|
+
* a "Search items…" input, 7 rows at 52px with a 16px icon) was measured
|
|
91
|
+
* through the in-editor "Insert" button, not the toolbar's "Body ▾".**
|
|
92
|
+
* Clicking "Body ▾" itself was tried repeatedly, with both synthetic and
|
|
93
|
+
* real CDP-dispatched trusted clicks, single- and multi-step sessions, and
|
|
94
|
+
* consistently closed the ENTIRE note a fraction of a second after opening
|
|
95
|
+
* something (confirmed via a 0/50/150/400ms readback: the toolbar portal was
|
|
96
|
+
* still there at +50ms, everything was gone by +150ms) — a nested-popover
|
|
97
|
+
* dismissable-layer interaction synthetic input in this environment could
|
|
98
|
+
* not reliably drive. The Insert button opens the identical shape ("The
|
|
99
|
+
* same-shaped popover... is also what opens from the 'Body' button" — the
|
|
100
|
+
* source measurement's own finding, and Insert's own DOM confirms the exact
|
|
101
|
+
* same `role="dialog"`/search-input/`role="option"` structure), so the
|
|
102
|
+
* chrome below is that measurement, reused for both entry points. What's
|
|
103
|
+
* NOT independently confirmed is "Body ▾"'s own anchor side — Insert opens
|
|
104
|
+
* `data-side="right"` (it sits at the end of a line, with no room below);
|
|
105
|
+
* the toolbar sits at the TOP of the selection, so this component opens the
|
|
106
|
+
* menu below it, a reasoned default rather than a reading. The `/`-in-body
|
|
107
|
+
* trigger, and the "@" mention trigger, both anchor at the caret and do not
|
|
108
|
+
* steal focus from the body — the toolbar's own "Body ▾" click DOES focus a
|
|
109
|
+
* real search input, since there's no in-document query to read a filter
|
|
110
|
+
* from until one exists.
|
|
111
|
+
*
|
|
112
|
+
* Not built: Attio's own separate "Insert" trigger button (a third way to
|
|
113
|
+
* reach the identical menu, sitting at the end of the body's last line) —
|
|
114
|
+
* only the two entry points #426 asked for.
|
|
11
115
|
*/
|
|
12
|
-
export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", ...rest }) {
|
|
116
|
+
export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
|
|
13
117
|
const titleRef = React.useRef(null);
|
|
14
118
|
const bodyRef = React.useRef(null);
|
|
15
119
|
const [bar, setBar] = React.useState(null);
|
|
16
120
|
const [active, setActive] = React.useState({});
|
|
121
|
+
const [swatchPanel, setSwatchPanel] = React.useState(null);
|
|
122
|
+
const [blockMenu, setBlockMenu] = React.useState(null);
|
|
123
|
+
// The Range covering a live "/query" (trigger included), mirroring the
|
|
124
|
+
// mention primitive below — only set in "slash" mode.
|
|
125
|
+
const slashRangeRef = React.useRef(null);
|
|
126
|
+
const blockSearchRef = React.useRef(null);
|
|
127
|
+
const [mention, setMention] = React.useState(null);
|
|
128
|
+
// The Range covering the live "@query" text (trigger included) — kept
|
|
129
|
+
// outside state since it's DOM identity, not a value to render from.
|
|
130
|
+
const mentionRangeRef = React.useRef(null);
|
|
131
|
+
// Wraps whatever `renderMentionPicker` returns, `display: contents` so it
|
|
132
|
+
// affects no layout — exists only so `updateMention` can tell "focus moved
|
|
133
|
+
// into the consumer's own picker" apart from "focus left the editor
|
|
134
|
+
// entirely", the same distinction `updateBar` already makes for the block
|
|
135
|
+
// menu's search input below.
|
|
136
|
+
const mentionPickerRef = React.useRef(null);
|
|
17
137
|
const syncEmpty = (el) => {
|
|
18
138
|
if (el)
|
|
19
139
|
el.toggleAttribute("data-empty", (el.textContent ?? "") === "");
|
|
@@ -38,8 +158,7 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
38
158
|
const sel = window.getSelection();
|
|
39
159
|
let node = sel?.anchorNode;
|
|
40
160
|
while (node && node !== bodyRef.current) {
|
|
41
|
-
if (node.nodeType === 1 &&
|
|
42
|
-
/^(H1|H2|H3|BLOCKQUOTE|LI)$/.test(node.tagName))
|
|
161
|
+
if (node.nodeType === 1 && /^(H1|H2|H3|LI)$/.test(node.tagName))
|
|
43
162
|
return node.tagName.toLowerCase();
|
|
44
163
|
node = node.parentElement;
|
|
45
164
|
}
|
|
@@ -47,6 +166,15 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
47
166
|
};
|
|
48
167
|
// biome-ignore lint/correctness/useExhaustiveDependencies: blockTag only reads stable refs
|
|
49
168
|
const updateBar = React.useCallback(() => {
|
|
169
|
+
if (readOnly)
|
|
170
|
+
return;
|
|
171
|
+
// The block menu's own search input lives outside `body`, and opening
|
|
172
|
+
// it (a "toolbar"-mode menu autofocuses it) moves the document
|
|
173
|
+
// selection there deliberately — that is not the user clicking away
|
|
174
|
+
// from their text selection, and must not tear the toolbar (and the
|
|
175
|
+
// menu it owns) down out from under them the instant it opens.
|
|
176
|
+
if (document.activeElement === blockSearchRef.current)
|
|
177
|
+
return;
|
|
50
178
|
const sel = window.getSelection();
|
|
51
179
|
const body = bodyRef.current;
|
|
52
180
|
if (!sel ||
|
|
@@ -55,11 +183,13 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
55
183
|
!body ||
|
|
56
184
|
!body.contains(sel.anchorNode)) {
|
|
57
185
|
setBar(null);
|
|
186
|
+
setSwatchPanel(null);
|
|
58
187
|
return;
|
|
59
188
|
}
|
|
60
189
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
|
61
190
|
if (rect.width === 0 && rect.height === 0) {
|
|
62
191
|
setBar(null);
|
|
192
|
+
setSwatchPanel(null);
|
|
63
193
|
return;
|
|
64
194
|
}
|
|
65
195
|
setBar({ x: rect.left + rect.width / 2, y: rect.top - 8 });
|
|
@@ -67,20 +197,132 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
67
197
|
setActive({
|
|
68
198
|
bold: document.queryCommandState("bold"),
|
|
69
199
|
italic: document.queryCommandState("italic"),
|
|
70
|
-
underline: document.queryCommandState("underline"),
|
|
71
200
|
strikeThrough: document.queryCommandState("strikeThrough"),
|
|
72
201
|
h1: tag === "h1",
|
|
73
202
|
h2: tag === "h2",
|
|
203
|
+
h3: tag === "h3",
|
|
74
204
|
ul: document.queryCommandState("insertUnorderedList"),
|
|
75
205
|
ol: document.queryCommandState("insertOrderedList"),
|
|
76
|
-
blockquote: tag === "blockquote",
|
|
77
206
|
});
|
|
207
|
+
}, [readOnly]);
|
|
208
|
+
const closeBlockMenu = React.useCallback(() => {
|
|
209
|
+
slashRangeRef.current = null;
|
|
210
|
+
setBlockMenu(null);
|
|
211
|
+
}, []);
|
|
212
|
+
/** Mirrors the mention-trigger detection below: only handles the caret
|
|
213
|
+
* landing inside a text node (the common case while typing), and only
|
|
214
|
+
* ever auto-closes a "slash"-mode menu — a "toolbar"-mode menu's search
|
|
215
|
+
* input isn't inside the body, so focusing it moves the selection out of
|
|
216
|
+
* `body` entirely, which must not be read as "the trigger went away". */
|
|
217
|
+
const updateSlashTrigger = React.useCallback(() => {
|
|
218
|
+
if (readOnly)
|
|
219
|
+
return;
|
|
220
|
+
const body = bodyRef.current;
|
|
221
|
+
const sel = window.getSelection();
|
|
222
|
+
if (!body ||
|
|
223
|
+
!sel?.isCollapsed ||
|
|
224
|
+
sel.rangeCount === 0 ||
|
|
225
|
+
!body.contains(sel.anchorNode)) {
|
|
226
|
+
if (blockMenu?.mode === "slash")
|
|
227
|
+
closeBlockMenu();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const range = sel.getRangeAt(0);
|
|
231
|
+
const node = range.startContainer;
|
|
232
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
233
|
+
if (blockMenu?.mode === "slash")
|
|
234
|
+
closeBlockMenu();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
|
|
238
|
+
const match = matchSlashTrigger(textBefore);
|
|
239
|
+
if (!match) {
|
|
240
|
+
if (blockMenu?.mode === "slash")
|
|
241
|
+
closeBlockMenu();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const triggerRange = document.createRange();
|
|
245
|
+
triggerRange.setStart(node, match.start);
|
|
246
|
+
triggerRange.setEnd(node, range.startOffset);
|
|
247
|
+
slashRangeRef.current = triggerRange;
|
|
248
|
+
const rect = triggerRange.getBoundingClientRect();
|
|
249
|
+
setBlockMenu({
|
|
250
|
+
anchor: { x: rect.left, y: rect.bottom + 4 },
|
|
251
|
+
query: match.query,
|
|
252
|
+
highlighted: 0,
|
|
253
|
+
mode: "slash",
|
|
254
|
+
});
|
|
255
|
+
}, [readOnly, blockMenu, closeBlockMenu]);
|
|
256
|
+
const closeMention = React.useCallback(() => {
|
|
257
|
+
mentionRangeRef.current = null;
|
|
258
|
+
setMention(null);
|
|
78
259
|
}, []);
|
|
260
|
+
/**
|
|
261
|
+
* Is the caret sitting right after an "@query"? Only handles the caret
|
|
262
|
+
* landing inside a text node — the common case while typing — the same
|
|
263
|
+
* scope `updateBar`'s own selection reading keeps to. A Range spanning
|
|
264
|
+
* element boundaries (the caret at the very start of the body, a fresh
|
|
265
|
+
* empty paragraph) has no "text before it" to search and correctly finds
|
|
266
|
+
* no trigger.
|
|
267
|
+
*
|
|
268
|
+
* Does NOT auto-close while focus sits inside the consumer's own picker
|
|
269
|
+
* (`mentionPickerRef`) — a picker with its own search field would
|
|
270
|
+
* otherwise close itself the instant that field took focus, since focus
|
|
271
|
+
* moving there also moves the document selection out of `body`. This is
|
|
272
|
+
* the same distinction `updateBar` already draws for the block menu's own
|
|
273
|
+
* search input, made while merging the two features together.
|
|
274
|
+
*/
|
|
275
|
+
const updateMention = React.useCallback(() => {
|
|
276
|
+
if (readOnly)
|
|
277
|
+
return;
|
|
278
|
+
if (mentionPickerRef.current?.contains(document.activeElement))
|
|
279
|
+
return;
|
|
280
|
+
const body = bodyRef.current;
|
|
281
|
+
const sel = window.getSelection();
|
|
282
|
+
if (!body ||
|
|
283
|
+
!sel?.isCollapsed ||
|
|
284
|
+
sel.rangeCount === 0 ||
|
|
285
|
+
!body.contains(sel.anchorNode)) {
|
|
286
|
+
closeMention();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const range = sel.getRangeAt(0);
|
|
290
|
+
const node = range.startContainer;
|
|
291
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
292
|
+
closeMention();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
|
|
296
|
+
const match = matchMentionTrigger(textBefore, mentionTrigger);
|
|
297
|
+
if (!match) {
|
|
298
|
+
closeMention();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const triggerRange = document.createRange();
|
|
302
|
+
triggerRange.setStart(node, match.start);
|
|
303
|
+
triggerRange.setEnd(node, range.startOffset);
|
|
304
|
+
mentionRangeRef.current = triggerRange;
|
|
305
|
+
const rect = triggerRange.getBoundingClientRect();
|
|
306
|
+
setMention({
|
|
307
|
+
query: match.query,
|
|
308
|
+
anchor: { x: rect.left, y: rect.bottom },
|
|
309
|
+
});
|
|
310
|
+
}, [readOnly, mentionTrigger, closeMention]);
|
|
79
311
|
React.useEffect(() => {
|
|
80
|
-
|
|
312
|
+
if (readOnly)
|
|
313
|
+
return;
|
|
314
|
+
const h = () => {
|
|
315
|
+
updateBar();
|
|
316
|
+
updateSlashTrigger();
|
|
317
|
+
updateMention();
|
|
318
|
+
};
|
|
81
319
|
document.addEventListener("selectionchange", h);
|
|
82
320
|
return () => document.removeEventListener("selectionchange", h);
|
|
83
|
-
}, [updateBar]);
|
|
321
|
+
}, [updateBar, updateSlashTrigger, updateMention, readOnly]);
|
|
322
|
+
React.useEffect(() => {
|
|
323
|
+
if (blockMenu?.mode === "toolbar")
|
|
324
|
+
blockSearchRef.current?.focus();
|
|
325
|
+
}, [blockMenu?.mode]);
|
|
84
326
|
const run = (cmd, value) => {
|
|
85
327
|
bodyRef.current?.focus();
|
|
86
328
|
exec(cmd, value);
|
|
@@ -88,14 +330,140 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
88
330
|
syncEmpty(bodyRef.current);
|
|
89
331
|
emit();
|
|
90
332
|
};
|
|
91
|
-
const toggleBlock = (tag) => {
|
|
92
|
-
run("formatBlock", blockTag() === tag ? "p" : tag);
|
|
93
|
-
};
|
|
94
333
|
const addLink = () => {
|
|
95
334
|
const url = window.prompt(linkPromptLabel);
|
|
96
335
|
if (url)
|
|
97
336
|
run("createLink", url);
|
|
98
337
|
};
|
|
338
|
+
const addImage = () => {
|
|
339
|
+
const url = window.prompt(imagePromptLabel);
|
|
340
|
+
if (url)
|
|
341
|
+
run("insertImage", url);
|
|
342
|
+
};
|
|
343
|
+
/** Setting a block type FROM THE MENU is a choice, not a toggle — picking
|
|
344
|
+
* "Heading 1" always makes it a heading 1, unlike the old discrete
|
|
345
|
+
* H1 button, which flipped back to a paragraph if it already was one. */
|
|
346
|
+
const applyBlockItem = (id) => {
|
|
347
|
+
switch (id) {
|
|
348
|
+
case "h1":
|
|
349
|
+
case "h2":
|
|
350
|
+
case "h3":
|
|
351
|
+
run("formatBlock", id);
|
|
352
|
+
break;
|
|
353
|
+
case "body":
|
|
354
|
+
run("formatBlock", "p");
|
|
355
|
+
break;
|
|
356
|
+
case "ul":
|
|
357
|
+
run("insertUnorderedList");
|
|
358
|
+
break;
|
|
359
|
+
case "ol":
|
|
360
|
+
run("insertOrderedList");
|
|
361
|
+
break;
|
|
362
|
+
case "image":
|
|
363
|
+
addImage();
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const filteredBlockItems = blockMenu ? filterBlockItems(blockMenu.query) : [];
|
|
368
|
+
const pickBlockItem = (item) => {
|
|
369
|
+
if (blockMenu?.mode === "slash" && slashRangeRef.current) {
|
|
370
|
+
const range = slashRangeRef.current;
|
|
371
|
+
const sel = window.getSelection();
|
|
372
|
+
range.deleteContents();
|
|
373
|
+
sel?.removeAllRanges();
|
|
374
|
+
sel?.addRange(range);
|
|
375
|
+
}
|
|
376
|
+
applyBlockItem(item.id);
|
|
377
|
+
closeBlockMenu();
|
|
378
|
+
};
|
|
379
|
+
/** The WAI-ARIA popup-listbox model (↑/↓ wrapping, Home/End, Enter,
|
|
380
|
+
* Escape, Tab-to-dismiss) — shared with Select/MultiSelect rather than
|
|
381
|
+
* hand-rolled a third time (#198's own reasoning). Wired to both the
|
|
382
|
+
* toolbar search input and, for the "/" case where focus never leaves the
|
|
383
|
+
* body, the body's own keydown handler. */
|
|
384
|
+
const { onKeyDown: onBlockMenuKeyDown } = useListboxKeyboard({
|
|
385
|
+
enabledIndexes: filteredBlockItems.map((_, i) => i),
|
|
386
|
+
highlight: blockMenu?.highlighted ?? -1,
|
|
387
|
+
setHighlight: (index) => setBlockMenu((cur) => cur && { ...cur, highlighted: index }),
|
|
388
|
+
commit: () => {
|
|
389
|
+
const item = filteredBlockItems[blockMenu?.highlighted ?? -1];
|
|
390
|
+
if (item)
|
|
391
|
+
pickBlockItem(item);
|
|
392
|
+
},
|
|
393
|
+
close: closeBlockMenu,
|
|
394
|
+
});
|
|
395
|
+
const toggleBlockMenuFromToolbar = () => {
|
|
396
|
+
if (!bar)
|
|
397
|
+
return;
|
|
398
|
+
setBlockMenu((cur) => cur?.mode === "toolbar"
|
|
399
|
+
? null
|
|
400
|
+
: {
|
|
401
|
+
anchor: { x: bar.x, y: bar.y },
|
|
402
|
+
query: "",
|
|
403
|
+
highlighted: 0,
|
|
404
|
+
mode: "toolbar",
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: syncEmpty only reads/writes stable refs, same as run()'s own call to it just below
|
|
408
|
+
const insertMention = React.useCallback((item) => {
|
|
409
|
+
const body = bodyRef.current;
|
|
410
|
+
const sel = window.getSelection();
|
|
411
|
+
if (!body || !sel)
|
|
412
|
+
return;
|
|
413
|
+
body.focus();
|
|
414
|
+
const range = mentionRangeRef.current ??
|
|
415
|
+
(sel.rangeCount > 0 ? sel.getRangeAt(0) : null);
|
|
416
|
+
if (!range)
|
|
417
|
+
return;
|
|
418
|
+
const chip = document.createElement("span");
|
|
419
|
+
chip.className = "crisp-rte-mention";
|
|
420
|
+
chip.contentEditable = "false";
|
|
421
|
+
chip.dataset.mentionId = item.id;
|
|
422
|
+
chip.textContent = `${mentionTrigger}${item.label}`;
|
|
423
|
+
range.deleteContents();
|
|
424
|
+
range.insertNode(chip);
|
|
425
|
+
// A non-breaking space right after the chip, so the caret has plain
|
|
426
|
+
// text to land in — inserting a caret directly against a
|
|
427
|
+
// contentEditable=false node's edge is where it can get stuck jumping
|
|
428
|
+
// back INTO the atomic chip instead of past it.
|
|
429
|
+
const space = document.createTextNode(" ");
|
|
430
|
+
chip.after(space);
|
|
431
|
+
const after = document.createRange();
|
|
432
|
+
after.setStartAfter(space);
|
|
433
|
+
after.collapse(true);
|
|
434
|
+
sel.removeAllRanges();
|
|
435
|
+
sel.addRange(after);
|
|
436
|
+
closeMention();
|
|
437
|
+
syncEmpty(bodyRef.current);
|
|
438
|
+
emit();
|
|
439
|
+
}, [mentionTrigger, closeMention, emit]);
|
|
440
|
+
React.useImperativeHandle(ref, () => ({
|
|
441
|
+
insertMention,
|
|
442
|
+
focus: () => bodyRef.current?.focus(),
|
|
443
|
+
}), [insertMention]);
|
|
444
|
+
const applyTextColor = (tone) => {
|
|
445
|
+
run("foreColor", tone.text);
|
|
446
|
+
setSwatchPanel(null);
|
|
447
|
+
};
|
|
448
|
+
const applyHighlight = (tone) => {
|
|
449
|
+
run("hiliteColor", tone.highlight);
|
|
450
|
+
setSwatchPanel(null);
|
|
451
|
+
};
|
|
452
|
+
// "Clear" reads the field's own current computed color rather than a
|
|
453
|
+
// fixed literal — execCommand bakes whatever value it's given into the
|
|
454
|
+
// HTML as-is, and a literal here would silently stop matching either
|
|
455
|
+
// theme the moment the reader's isn't the one this was written in.
|
|
456
|
+
const clearTextColor = () => {
|
|
457
|
+
const current = bodyRef.current
|
|
458
|
+
? getComputedStyle(bodyRef.current).color
|
|
459
|
+
: undefined;
|
|
460
|
+
run("foreColor", current);
|
|
461
|
+
setSwatchPanel(null);
|
|
462
|
+
};
|
|
463
|
+
const clearHighlight = () => {
|
|
464
|
+
run("hiliteColor", "transparent");
|
|
465
|
+
setSwatchPanel(null);
|
|
466
|
+
};
|
|
99
467
|
// mousedown on the toolbar must not clear the selection
|
|
100
468
|
const keepSel = (e) => e.preventDefault();
|
|
101
469
|
/**
|
|
@@ -105,13 +473,62 @@ export function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlace
|
|
|
105
473
|
* screen-reader user there is a state here that does not exist.
|
|
106
474
|
*/
|
|
107
475
|
const tool = (key, label, icon, onClick, toggles = true) => (_jsx("button", { type: "button", className: "crisp-rte-tool", "aria-label": label, "aria-pressed": toggles ? !!active[key] : undefined, "data-active": active[key] ? "" : undefined, onMouseDown: keepSel, onClick: onClick, children: icon }));
|
|
476
|
+
/** Text/Highlight: a labelled tool that opens a swatch picker instead of
|
|
477
|
+
* toggling directly — `aria-expanded` says whether its panel is open, not
|
|
478
|
+
* whether a mark is applied (there's no single "on" state once 12 tones
|
|
479
|
+
* are all valid choices). */
|
|
480
|
+
const colorTool = (key, label, icon) => (_jsxs("button", { type: "button", className: "crisp-rte-tool crisp-rte-tool--labelled", "aria-label": label, "aria-expanded": swatchPanel === key, "aria-haspopup": "true", onMouseDown: keepSel, onClick: () => setSwatchPanel((cur) => (cur === key ? null : key)), children: [icon, _jsx("span", { children: label })] }));
|
|
108
481
|
const ic = { size: 15, strokeWidth: 2 };
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
482
|
+
const menuIc = { size: 16, strokeWidth: 2, "aria-hidden": true };
|
|
483
|
+
const BLOCK_MENU_ICONS = {
|
|
484
|
+
h1: _jsx(Heading1, { ...menuIc }),
|
|
485
|
+
h2: _jsx(Heading2, { ...menuIc }),
|
|
486
|
+
h3: _jsx(Heading3, { ...menuIc }),
|
|
487
|
+
body: _jsx(Pilcrow, { ...menuIc }),
|
|
488
|
+
ul: _jsx(List, { ...menuIc }),
|
|
489
|
+
ol: _jsx(ListOrdered, { ...menuIc }),
|
|
490
|
+
image: _jsx(ImageIcon, { ...menuIc }),
|
|
491
|
+
};
|
|
492
|
+
return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
|
|
493
|
+
? undefined
|
|
494
|
+
: () => {
|
|
495
|
+
syncEmpty(titleRef.current);
|
|
496
|
+
emit();
|
|
497
|
+
} }), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
|
|
498
|
+
? undefined
|
|
499
|
+
: () => {
|
|
500
|
+
syncEmpty(bodyRef.current);
|
|
501
|
+
updateSlashTrigger();
|
|
502
|
+
updateMention();
|
|
503
|
+
emit();
|
|
504
|
+
}, onMouseUp: readOnly
|
|
505
|
+
? undefined
|
|
506
|
+
: () => {
|
|
507
|
+
updateBar();
|
|
508
|
+
updateSlashTrigger();
|
|
509
|
+
updateMention();
|
|
510
|
+
}, onKeyUp: readOnly
|
|
511
|
+
? undefined
|
|
512
|
+
: () => {
|
|
513
|
+
updateBar();
|
|
514
|
+
updateSlashTrigger();
|
|
515
|
+
updateMention();
|
|
516
|
+
}, onKeyDown: readOnly || blockMenu?.mode !== "slash"
|
|
517
|
+
? undefined
|
|
518
|
+
: onBlockMenuKeyDown }), bar && (_jsxs("div", { className: "crisp-rte-toolbar", style: { left: bar.x, top: bar.y }, children: [_jsx("button", { type: "button", className: "crisp-rte-tool crisp-rte-tool--labelled", "aria-label": blockMenuLabel, "aria-expanded": blockMenu?.mode === "toolbar", "aria-haspopup": "true", onMouseDown: keepSel, onClick: toggleBlockMenuFromToolbar, children: _jsx("span", { children: blockMenuLabel }) }), _jsx("span", { className: "crisp-rte-tool-sep" }), colorTool("text", textColorLabel, _jsx(Baseline, { ...ic })), colorTool("highlight", highlightColorLabel, _jsx(Highlighter, { ...ic })), _jsx("span", { className: "crisp-rte-tool-sep" }), tool("link", "Link", _jsx(Link2, { ...ic }), addLink, false), _jsx("span", { className: "crisp-rte-tool-sep" }), tool("bold", "Bold", _jsx(Bold, { ...ic }), () => run("bold")), tool("italic", "Italic", _jsx(Italic, { ...ic }), () => run("italic")), tool("strikeThrough", "Strikethrough", _jsx(Strikethrough, { ...ic }), () => run("strikeThrough"))] })), blockMenu && (blockMenu.mode === "slash" || bar) && (_jsxs("div", { className: "crisp-rte-blockmenu", style: { left: blockMenu.anchor.x, top: blockMenu.anchor.y }, children: [blockMenu.mode === "toolbar" && (_jsxs("div", { className: "crisp-rte-blockmenu-search", children: [_jsx(Search, { size: 14, strokeWidth: 2, "aria-hidden": "true" }), _jsx("input", { ref: blockSearchRef, className: "crisp-rte-blockmenu-input", placeholder: blockMenuSearchPlaceholder, "aria-label": blockMenuSearchPlaceholder, value: blockMenu.query, onChange: (e) => setBlockMenu((cur) => cur && { ...cur, query: e.target.value, highlighted: 0 }), onKeyDown: onBlockMenuKeyDown })] })), _jsx("div", { role: "listbox", className: "crisp-rte-blockmenu-list", children: filteredBlockItems.length === 0 ? (_jsx("div", { className: "crisp-rte-blockmenu-empty", children: blockMenuEmptyLabel })) : (filteredBlockItems.map((item, i) => (_jsxs("button", { type: "button", role: "option", "aria-selected": i === blockMenu.highlighted, className: "crisp-rte-blockmenu-item", "data-highlighted": i === blockMenu.highlighted ? "" : undefined, onMouseEnter: () => setBlockMenu((cur) => cur && { ...cur, highlighted: i }), onMouseDown: keepSel, onClick: () => pickBlockItem(item), children: [BLOCK_MENU_ICONS[item.id], _jsxs("span", { className: "crisp-rte-blockmenu-item-text", children: [_jsx("span", { className: "crisp-rte-blockmenu-item-label", children: item.label }), _jsx("span", { className: "crisp-rte-blockmenu-item-desc", children: item.description })] })] }, item.id)))) })] })), bar && swatchPanel && (_jsxs("div", { className: "crisp-rte-swatchpanel", style: { left: bar.x, top: bar.y }, children: [RICHTEXT_COLOR_TONES.map((tone) => (_jsx("button", { type: "button", className: "crisp-rte-swatch", "aria-label": tone.label, style: {
|
|
519
|
+
background: swatchPanel === "highlight" ? tone.highlight : tone.text,
|
|
520
|
+
}, onMouseDown: keepSel, onClick: () => swatchPanel === "highlight"
|
|
521
|
+
? applyHighlight(tone)
|
|
522
|
+
: applyTextColor(tone) }, tone.id))), _jsx("button", { type: "button", className: "crisp-rte-swatch crisp-rte-swatch--none", "aria-label": clearColorLabel, onMouseDown: keepSel, onClick: swatchPanel === "highlight" ? clearHighlight : clearTextColor })] })), mention &&
|
|
523
|
+
renderMentionPicker &&
|
|
524
|
+
(() => {
|
|
525
|
+
const rendered = renderMentionPicker({
|
|
526
|
+
query: mention.query,
|
|
527
|
+
anchor: mention.anchor,
|
|
528
|
+
select: insertMention,
|
|
529
|
+
close: closeMention,
|
|
530
|
+
});
|
|
531
|
+
return rendered == null ? null : (_jsx("div", { ref: mentionPickerRef, style: { display: "contents" }, children: rendered }));
|
|
532
|
+
})()] }));
|
|
533
|
+
});
|
|
117
534
|
RichTextEditor.displayName = "RichTextEditor";
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import { defineRecipe, resolveRecipe } from "../../core/index.js";
|
|
2
|
-
import { contract as c } from "../../tokens/index.js";
|
|
3
|
-
// A block rich-text editor — measured from
|
|
4
|
-
// title, a 14/20 prose body (
|
|
5
|
-
// floating selection toolbar. Built on contentEditable so it stays
|
|
6
|
-
// free. Placeholders show via [data-empty]::before.
|
|
2
|
+
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
+
// A block rich-text editor — measured from Attio's own notes editor: a large
|
|
4
|
+
// 32/36 title, a 14/20 prose body (h1/h2/h3, lists, an image block, marks),
|
|
5
|
+
// and a floating selection toolbar. Built on contentEditable so it stays
|
|
6
|
+
// dependency-free. Placeholders show via [data-empty]::before.
|
|
7
|
+
//
|
|
8
|
+
// The toolbar (#426) is Attio's OWN measured chrome, not the generic dark
|
|
9
|
+
// pill this file used to ship: white, `border-radius: 10px` (a genuinely
|
|
10
|
+
// measured value with no match in the radius scale — kept raw rather than
|
|
11
|
+
// rounded to the nearest token, same reasoning as `elevation.field`'s two
|
|
12
|
+
// raw layers), and `elevation.floating`'s exact shadow — which the live
|
|
13
|
+
// toolbar's own 4-layer box-shadow matched value-for-value when re-checked.
|
|
14
|
+
// Icon color reproduces the measured `rgba(0,0,0,0.63)` via the same
|
|
15
|
+
// color-mix-over-fgPrimary idiom the placeholder rule above already uses,
|
|
16
|
+
// rather than reaching for `fgMuted` (a different, unrelated gray).
|
|
17
|
+
//
|
|
18
|
+
// `.crisp-rte-blockmenu*` is the block-type menu ("Body ▾" and "/"), also
|
|
19
|
+
// measured live: width 356px (raw — close to but not the same as the
|
|
20
|
+
// popover family's 360px max-width, so kept as its own measured number,
|
|
21
|
+
// not rounded to match), `elevation.floating` again (the identical shadow
|
|
22
|
+
// the toolbar itself carries — both are the same floating tier), radius
|
|
23
|
+
// `xl` (12px, an exact token match this time), rows at the measured 52px
|
|
24
|
+
// with 6px 8px padding and a 16px icon. See richtext.tsx's own doc comment
|
|
25
|
+
// for how this was measured (through the "Insert" button, not "Body ▾" —
|
|
26
|
+
// "Body ▾" itself closed the whole note before a submenu could render, in
|
|
27
|
+
// every attempt).
|
|
7
28
|
const config = defineRecipe({
|
|
8
29
|
name: "richtext",
|
|
9
30
|
base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${c.font.sans}; color: ${c.fgPrimary};
|
|
@@ -25,13 +46,42 @@ const config = defineRecipe({
|
|
|
25
46
|
& .crisp-rte-body code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: ${c.text.md}; padding: 1px ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
|
|
26
47
|
& .crisp-rte-body a { color: ${c.bgBrandSolid}; text-decoration: underline; text-underline-offset: 2px; }
|
|
27
48
|
& .crisp-rte-body strong { font-weight: ${c.weight.semibold}; }
|
|
49
|
+
& .crisp-rte-body img { display: block; max-width: 100%; border-radius: ${c.radius.md}; }
|
|
50
|
+
/* Not measured against Attio (#428) — nothing in a read-only pass over
|
|
51
|
+
app.attio.com had a live @mention rendered to read computed styles
|
|
52
|
+
from, and typing one to create one is off-limits there. Reused
|
|
53
|
+
verbatim from the one measured "brand" tag/chip this repo already
|
|
54
|
+
has (RecordsTable/MultiSelect's [data-tone="brand"]) rather than
|
|
55
|
+
inventing a new color or shape. */
|
|
56
|
+
& .crisp-rte-mention { display: inline; border-radius: ${c.radius.chip}; padding: 0 ${c.space["1"]}; background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); font-weight: ${c.weight.medium}; cursor: default; }
|
|
28
57
|
|
|
29
|
-
& .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding:
|
|
58
|
+
& .crisp-rte-toolbar { position: fixed; z-index: 70; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: ${c.bgSurface}; box-shadow: ${e.floating}; transform: translate(-50%, -100%); opacity: 1; transition: opacity 0.1s ease; }
|
|
30
59
|
& .crisp-rte-toolbar { @starting-style { opacity: 0; } }
|
|
31
|
-
& .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; width:
|
|
32
|
-
& .crisp-rte-tool
|
|
33
|
-
& .crisp-rte-tool
|
|
34
|
-
& .crisp-rte-tool-
|
|
60
|
+
& .crisp-rte-tool { display: inline-flex; align-items: center; justify-content: center; gap: ${c.space["1"]}; min-width: 30px; height: 28px; padding: 0 7px; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); font-family: inherit; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; cursor: pointer; transition: background-color .1s; }
|
|
61
|
+
& .crisp-rte-tool--labelled { width: auto; }
|
|
62
|
+
& .crisp-rte-tool:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
|
|
63
|
+
& .crisp-rte-tool[data-active], & .crisp-rte-tool[aria-expanded="true"] { background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; }
|
|
64
|
+
& .crisp-rte-tool-sep { width: 1px; height: 16px; margin: 0 2px; background: ${c.borderSubtle}; }
|
|
65
|
+
|
|
66
|
+
& .crisp-rte-swatchpanel { position: fixed; z-index: 71; display: grid; grid-template-columns: repeat(4, 1fr); gap: ${c.space["1"]}; width: 168px; padding: ${c.space["1.5"]}; border-radius: ${c.radius.lg}; background: ${c.bgSurface}; box-shadow: ${e.floating}; transform: translate(-50%, ${c.space["1.5"]}); opacity: 1; transition: opacity 0.1s ease; }
|
|
67
|
+
& .crisp-rte-swatchpanel { @starting-style { opacity: 0; } }
|
|
68
|
+
& .crisp-rte-swatch { width: 28px; height: 28px; padding: 0; margin: 0; border: none; border-radius: 50%; background: none; font-family: inherit; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 10%, transparent); cursor: pointer; }
|
|
69
|
+
& .crisp-rte-swatch--none { background: ${c.bgSurface}; position: relative; }
|
|
70
|
+
& .crisp-rte-swatch--none::after { content: ""; position: absolute; inset: 6px; border-top: 1.5px solid color-mix(in srgb, ${c.fgPrimary} 40%, transparent); transform: rotate(-45deg); }
|
|
71
|
+
|
|
72
|
+
& .crisp-rte-blockmenu { position: fixed; z-index: 71; box-sizing: border-box; width: 356px; padding: 0; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: ${e.floating}; opacity: 1; transition: opacity 0.1s ease; }
|
|
73
|
+
& .crisp-rte-blockmenu { @starting-style { opacity: 0; } }
|
|
74
|
+
& .crisp-rte-blockmenu-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; padding: ${c.space["2.5"]} ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
|
|
75
|
+
& .crisp-rte-blockmenu-input { flex: 1; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; height: 20px; color: ${c.fgPrimary}; }
|
|
76
|
+
& .crisp-rte-blockmenu-input::placeholder { color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
|
|
77
|
+
& .crisp-rte-blockmenu-list { display: flex; flex-direction: column; padding: ${c.space["1.5"]}; gap: ${c.space.px}; }
|
|
78
|
+
& .crisp-rte-blockmenu-item { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: 52px; padding: ${c.space["1.5"]} ${c.space["2"]}; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; font-family: inherit; text-align: start; cursor: pointer; }
|
|
79
|
+
& .crisp-rte-blockmenu-item svg { flex-shrink: 0; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
|
|
80
|
+
& .crisp-rte-blockmenu-item[data-highlighted] { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
|
|
81
|
+
& .crisp-rte-blockmenu-item-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
82
|
+
& .crisp-rte-blockmenu-item-label { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
|
|
83
|
+
& .crisp-rte-blockmenu-item-desc { font-size: ${c.text.sm}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
|
|
84
|
+
& .crisp-rte-blockmenu-empty { padding: ${c.space["4"]} ${c.space["3"]}; text-align: center; font-size: ${c.text.md}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }`,
|
|
35
85
|
variants: {},
|
|
36
86
|
defaultVariants: {},
|
|
37
87
|
compoundVariants: [],
|