@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
|
@@ -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 =
|
|
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
|
|
44
|
-
* a prose body (
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
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
|
|
7
|
-
// title, a 14/20 prose body (
|
|
8
|
-
// floating selection toolbar. Built on contentEditable so it stays
|
|
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:
|
|
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:
|
|
35
|
-
& .crisp-rte-tool
|
|
36
|
-
& .crisp-rte-tool
|
|
37
|
-
& .crisp-rte-tool-
|
|
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: [],
|