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