@vvedantb/ui 0.3.1 → 0.4.0-rc.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.
@@ -0,0 +1,504 @@
1
+ import type { ChangeEvent, ReactNode } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
5
+ import { IconArrowUp, IconLoader2, IconPlayerStop } from "@tabler/icons-react";
6
+ import { cn } from "../../utils/cn";
7
+ import { Button } from "../../ui/button";
8
+ import { InputGroupAddon } from "../../ui/input-group";
9
+ import {
10
+ Tooltip,
11
+ TooltipContent,
12
+ TooltipProvider,
13
+ TooltipTrigger,
14
+ } from "../../ui/tooltip";
15
+ import {
16
+ PromptInput,
17
+ PromptInputBody,
18
+ PromptInputFooter,
19
+ PromptInputTextarea,
20
+ PromptInputTools,
21
+ } from "../../ai-elements/prompt-input";
22
+ import { SpeechInput } from "../../ai-elements/speech-input";
23
+ import { chatInputContainerClass } from "../../ui/surface-classes";
24
+ import { ComposerSlashMenu } from "./ComposerSlashMenu";
25
+ import { FadePresence } from "./FadePresence";
26
+ import { InputActions } from "./InputActions";
27
+ import { motionFast } from "./motion";
28
+ import type { ComposerSlashItem } from "./composerSlashItems";
29
+ import { useComposerCompact } from "./useComposerCompact";
30
+ import { useComposerSlashMenu } from "./useComposerSlashMenu";
31
+ import { useInputAreaPrediction } from "./useInputAreaPrediction";
32
+
33
+ const COMPACT_EDITOR =
34
+ "flex min-h-9 max-h-9 min-w-0 w-auto flex-1 items-center self-center overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5";
35
+ const COMPACT_START_ADDON =
36
+ "order-first shrink-0 justify-start gap-1 py-0 pl-1.5 pr-0 has-[>button]:ml-0";
37
+ const COMPACT_END_ADDON =
38
+ "order-last shrink-0 justify-end gap-1 py-0 pr-1.5 pl-1 has-[>button]:mr-0";
39
+
40
+ export const DEFAULT_ATTACH_ACCEPT =
41
+ "image/png,image/jpeg,image/jpg,image/webp,application/pdf";
42
+
43
+ export type InputAreaSlashContext = {
44
+ /** Opens the file picker, as the `attach` slash action does. */
45
+ openFilePicker: () => void;
46
+ };
47
+
48
+ export type InputAreaProps = {
49
+ value?: string;
50
+ onChange?: (value: string) => void;
51
+ /** Initial text when `value` is omitted (uncontrolled). */
52
+ defaultValue?: string;
53
+ placeholder?: string;
54
+
55
+ /** Called with the trimmed text. The input clears first, through `onChange`. */
56
+ onSubmit?: (text: string) => void;
57
+ onStop?: () => void;
58
+ isStreaming?: boolean;
59
+ isSending?: boolean;
60
+ isUploading?: boolean;
61
+ /** When omitted: the draft has text, or `fileCount` or `chipCount` is above 0. */
62
+ canSubmit?: boolean;
63
+
64
+ /** Inputs to the compact pill. */
65
+ fileCount?: number;
66
+ chipCount?: number;
67
+
68
+ showDictate?: boolean;
69
+ showFileUpload?: boolean;
70
+ /** Replaces the built-in file picker click. */
71
+ onAttachFile?: () => void;
72
+ /** Receives files chosen in the picker or dropped on the input. */
73
+ onFilesSelected?: (files: FileList) => void;
74
+ attachAccept?: string;
75
+ /** Receives dictated text. Default: append it to the draft. */
76
+ onDictateTranscription?: (text: string) => void;
77
+
78
+ banners?: ReactNode;
79
+ promoBanner?: ReactNode;
80
+ queue?: ReactNode;
81
+ /** Attachment strip. Shown inside the input, above the text. */
82
+ attachments?: ReactNode;
83
+ /** Chip row. Shown inside the input, above the text. */
84
+ chips?: ReactNode;
85
+ /** Extra rows in the plus menu after the attach row. Include your own separators. */
86
+ plusMenuExtra?: ReactNode;
87
+
88
+ /** Items for the `/` menu. Omit to turn the menu off. */
89
+ slashItems?: ComposerSlashItem[];
90
+ onSlashSelect?: (item: ComposerSlashItem, context: InputAreaSlashContext) => void;
91
+ /** Wrap a slash row, for example in a hover card. Return `row` for the default. */
92
+ renderSlashItem?: (item: ComposerSlashItem, row: ReactNode) => ReactNode;
93
+
94
+ /** Returns a continuation for the draft. Omit to turn ghost text off. */
95
+ getPrediction?: (text: string) => Promise<string>;
96
+ predictionEnabled?: boolean;
97
+
98
+ /** Wrapper around banners, queue and the input. */
99
+ className?: string;
100
+ /** Extra classes on the input container. */
101
+ containerClassName?: string;
102
+ /** Border and ring classes for the input container. */
103
+ borderClassName?: string;
104
+ submitButtonClassName?: string;
105
+ submitIconClassName?: string;
106
+
107
+ /** Called on Backspace at the start of the text while `chipCount` is above 0. */
108
+ onRemoveLastChip?: () => void;
109
+ };
110
+
111
+ export function InputArea({
112
+ value: controlledValue,
113
+ onChange,
114
+ defaultValue = "",
115
+ placeholder = "Ask anything...",
116
+ onSubmit,
117
+ onStop,
118
+ isStreaming = false,
119
+ isSending = false,
120
+ isUploading = false,
121
+ canSubmit,
122
+ fileCount = 0,
123
+ chipCount = 0,
124
+ showDictate = false,
125
+ showFileUpload = true,
126
+ onAttachFile,
127
+ onFilesSelected,
128
+ attachAccept = DEFAULT_ATTACH_ACCEPT,
129
+ onDictateTranscription,
130
+ banners,
131
+ promoBanner,
132
+ queue,
133
+ attachments,
134
+ chips,
135
+ plusMenuExtra,
136
+ slashItems,
137
+ onSlashSelect,
138
+ renderSlashItem,
139
+ getPrediction,
140
+ predictionEnabled = true,
141
+ className,
142
+ containerClassName,
143
+ borderClassName,
144
+ submitButtonClassName,
145
+ submitIconClassName,
146
+ onRemoveLastChip,
147
+ }: InputAreaProps) {
148
+ const isControlled = controlledValue !== undefined;
149
+ const [internalValue, setInternalValue] = useState(defaultValue);
150
+ const input = isControlled ? controlledValue : internalValue;
151
+ const setInput = (next: string) => {
152
+ if (!isControlled) setInternalValue(next);
153
+ onChange?.(next);
154
+ };
155
+ const inputRef = useRef<HTMLTextAreaElement>(null);
156
+ const fileInputRef = useRef<HTMLInputElement>(null);
157
+ const hasFiles = fileCount > 0;
158
+
159
+ const openFilePicker = () => {
160
+ if (onAttachFile) onAttachFile();
161
+ else fileInputRef.current?.click();
162
+ };
163
+
164
+ const { chromeRef, compact } = useComposerCompact({
165
+ value: input,
166
+ fileCount,
167
+ chipCount,
168
+ });
169
+
170
+ const items = slashItems ?? [];
171
+ const slashMenu = useComposerSlashMenu({
172
+ value: input,
173
+ onValueChange: setInput,
174
+ items,
175
+ textareaRef: inputRef,
176
+ onSelect: (item) => onSlashSelect?.(item, { openFilePicker }),
177
+ enabled: items.length > 0,
178
+ });
179
+ const prediction = useInputAreaPrediction({
180
+ value: input,
181
+ suspended: slashMenu.showPopup,
182
+ enabled: predictionEnabled,
183
+ getPrediction,
184
+ });
185
+
186
+ const textareaClassName = cn(
187
+ "bg-transparent! shadow-none border-none",
188
+ "text-sm md:text-[15px] leading-5 placeholder:text-muted-foreground",
189
+ "smooth-typing-input field-sizing-content focus-visible:outline-hidden",
190
+ compact
191
+ ? COMPACT_EDITOR
192
+ : "min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left",
193
+ );
194
+
195
+ const submitDisabled =
196
+ isUploading ||
197
+ isSending ||
198
+ !(canSubmit ?? (input.trim() !== "" || hasFiles || chipCount > 0));
199
+
200
+ const handleSubmit = (textOverride?: string) => {
201
+ if (isSending) return;
202
+ const text = (textOverride || input).trim();
203
+ if (!(canSubmit ?? (text !== "" || hasFiles || chipCount > 0))) return;
204
+ setInput("");
205
+ onSubmit?.(text);
206
+ };
207
+
208
+ const handleFileInputChange = (event: ChangeEvent<HTMLInputElement>) => {
209
+ if (!event.target.files) return;
210
+ onFilesSelected?.(event.target.files);
211
+ event.target.value = "";
212
+ };
213
+
214
+ useEffect(() => {
215
+ const handleKeyDown = (e: KeyboardEvent) => {
216
+ if (e.ctrlKey || e.altKey || e.metaKey || e.key.length !== 1) return;
217
+ const activeElement = document.activeElement;
218
+ const isInputFocused =
219
+ activeElement instanceof HTMLInputElement ||
220
+ activeElement instanceof HTMLTextAreaElement;
221
+ if (!isInputFocused && inputRef.current) {
222
+ const textarea = inputRef.current;
223
+ textarea.focus();
224
+ const length = textarea.value.length;
225
+ textarea.setSelectionRange(length, length);
226
+ }
227
+ };
228
+ window.addEventListener("keydown", handleKeyDown);
229
+ return () => window.removeEventListener("keydown", handleKeyDown);
230
+ }, []);
231
+
232
+ const plusButton = (
233
+ <InputActions
234
+ showFileUpload={showFileUpload}
235
+ onAttachFile={openFilePicker}
236
+ compact={compact}
237
+ extra={plusMenuExtra}
238
+ />
239
+ );
240
+
241
+ const sendTools = (
242
+ <div className="flex shrink-0 items-center gap-0.5">
243
+ <FadePresence show={showDictate}>
244
+ <SpeechInput
245
+ size="icon-sm"
246
+ variant="ghost"
247
+ className="rounded-full bg-transparent text-muted-foreground hover:bg-surface-secondary hover:text-foreground max-sm:hit-target"
248
+ onTranscriptionChange={(text) =>
249
+ onDictateTranscription
250
+ ? onDictateTranscription(text)
251
+ : setInput(input + (input ? " " : "") + text)
252
+ }
253
+ lang="en-US"
254
+ />
255
+ </FadePresence>
256
+ <AnimatePresence initial={false}>
257
+ {isStreaming ? (
258
+ <m.div
259
+ key="composer-stop"
260
+ className="inline-flex"
261
+ initial={{ opacity: 0, scale: 0.96 }}
262
+ animate={{ opacity: 1, scale: 1 }}
263
+ exit={{ opacity: 0, scale: 0.96 }}
264
+ transition={motionFast}
265
+ >
266
+ <Tooltip>
267
+ <TooltipTrigger asChild>
268
+ <Button
269
+ size="icon-sm"
270
+ type="button"
271
+ variant="destructive"
272
+ className="rounded-full max-sm:hit-target"
273
+ onClick={onStop}
274
+ aria-label="Stop generating"
275
+ >
276
+ <IconPlayerStop className="size-4" />
277
+ </Button>
278
+ </TooltipTrigger>
279
+ <TooltipContent>Stop generating</TooltipContent>
280
+ </Tooltip>
281
+ </m.div>
282
+ ) : null}
283
+ </AnimatePresence>
284
+ <Tooltip>
285
+ <TooltipTrigger asChild>
286
+ <Button
287
+ size="icon-sm"
288
+ type="submit"
289
+ disabled={submitDisabled}
290
+ className={cn(
291
+ "size-9 rounded-full max-sm:hit-target",
292
+ submitButtonClassName,
293
+ )}
294
+ >
295
+ <span className="relative inline-flex size-5 items-center justify-center">
296
+ <span
297
+ className={cn(
298
+ "absolute inset-0 flex items-center justify-center transition-opacity duration-150 ease-out",
299
+ isUploading || isSending ? "opacity-100" : "opacity-0",
300
+ )}
301
+ >
302
+ <IconLoader2 className="size-4 animate-spin" />
303
+ </span>
304
+ <span
305
+ className={cn(
306
+ "flex items-center justify-center transition-opacity duration-150 ease-out",
307
+ isUploading || isSending ? "opacity-0" : "opacity-100",
308
+ )}
309
+ >
310
+ <IconArrowUp className={submitIconClassName} width={18} />
311
+ </span>
312
+ </span>
313
+ </Button>
314
+ </TooltipTrigger>
315
+ <TooltipContent>
316
+ {isStreaming ? "Queue message" : "Send message"}
317
+ </TooltipContent>
318
+ </Tooltip>
319
+ </div>
320
+ );
321
+
322
+ return (
323
+ <LazyMotion features={domAnimation}>
324
+ <TooltipProvider>
325
+ <div className="min-w-0 shrink-0 overflow-x-clip">
326
+ {showFileUpload && onFilesSelected ? (
327
+ <input
328
+ ref={fileInputRef}
329
+ type="file"
330
+ accept={attachAccept}
331
+ onChange={handleFileInputChange}
332
+ multiple
333
+ className="hidden"
334
+ />
335
+ ) : null}
336
+ <div className={className}>
337
+ {promoBanner}
338
+ {banners}
339
+ {queue}
340
+ <div className="flex w-full flex-col">
341
+ <div
342
+ ref={chromeRef}
343
+ data-composer-slash-anchor=""
344
+ className="w-full"
345
+ >
346
+ <PromptInput
347
+ onSubmit={(message) => {
348
+ handleSubmit(message.text);
349
+ }}
350
+ className="w-full"
351
+ containerClassName={cn(
352
+ "flex w-full min-w-0 [interpolate-size:allow-keywords] transition-[color,box-shadow,border-color,border-radius,height] duration-200",
353
+ chatInputContainerClass,
354
+ compact
355
+ ? "h-12 flex-row flex-nowrap items-center rounded-full py-1 has-[>textarea]:h-12"
356
+ : "h-auto flex-col items-start rounded-surface",
357
+ borderClassName,
358
+ containerClassName,
359
+ )}
360
+ onDragOver={
361
+ showFileUpload && onFilesSelected
362
+ ? (event) => {
363
+ event.preventDefault();
364
+ event.dataTransfer.dropEffect = "copy";
365
+ }
366
+ : undefined
367
+ }
368
+ onDrop={
369
+ showFileUpload && onFilesSelected
370
+ ? (event) => {
371
+ event.preventDefault();
372
+ if (event.dataTransfer.files.length > 0) {
373
+ onFilesSelected(event.dataTransfer.files);
374
+ }
375
+ }
376
+ : undefined
377
+ }
378
+ >
379
+ {showFileUpload ? attachments : null}
380
+ {chips}
381
+ {compact ? (
382
+ <InputGroupAddon
383
+ align="inline-start"
384
+ className={COMPACT_START_ADDON}
385
+ >
386
+ {plusButton}
387
+ </InputGroupAddon>
388
+ ) : null}
389
+ <PromptInputBody>
390
+ <div
391
+ className={cn(
392
+ "relative min-w-0",
393
+ compact ? "flex-1" : "w-full self-stretch",
394
+ )}
395
+ >
396
+ {prediction.suggestion ? (
397
+ <div
398
+ aria-hidden
399
+ className={cn(
400
+ textareaClassName,
401
+ "pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-foreground",
402
+ )}
403
+ >
404
+ {input}
405
+ <span className="text-muted-foreground">
406
+ {prediction.suggestion}
407
+ </span>
408
+ </div>
409
+ ) : null}
410
+ <PromptInputTextarea
411
+ ref={inputRef}
412
+ value={input}
413
+ placeholder={placeholder}
414
+ className={cn(
415
+ textareaClassName,
416
+ "relative z-10",
417
+ prediction.suggestion &&
418
+ "caret-current text-transparent",
419
+ )}
420
+ rows={1}
421
+ role={slashMenu.comboboxProps.role}
422
+ aria-multiline={slashMenu.comboboxProps["aria-multiline"]}
423
+ aria-haspopup={slashMenu.comboboxProps["aria-haspopup"]}
424
+ aria-autocomplete={
425
+ slashMenu.comboboxProps["aria-autocomplete"]
426
+ }
427
+ aria-expanded={slashMenu.comboboxProps["aria-expanded"]}
428
+ aria-controls={slashMenu.comboboxProps["aria-controls"]}
429
+ aria-activedescendant={
430
+ slashMenu.comboboxProps["aria-activedescendant"]
431
+ }
432
+ onKeyDown={(event) => {
433
+ if (slashMenu.onKeyDown(event)) return;
434
+ if (prediction.suggestion) {
435
+ if (event.key === "Tab" && !event.shiftKey) {
436
+ event.preventDefault();
437
+ setInput(input + prediction.suggestion);
438
+ return;
439
+ }
440
+ if (event.key === "Escape") {
441
+ event.preventDefault();
442
+ prediction.dismiss();
443
+ return;
444
+ }
445
+ }
446
+ if (
447
+ event.key === "Backspace" &&
448
+ event.currentTarget.selectionStart === 0 &&
449
+ event.currentTarget.selectionEnd === 0 &&
450
+ chipCount > 0
451
+ ) {
452
+ event.preventDefault();
453
+ onRemoveLastChip?.();
454
+ }
455
+ }}
456
+ onBlur={slashMenu.onBlur}
457
+ onChange={(e) => {
458
+ setInput(e.currentTarget.value);
459
+ }}
460
+ />
461
+ </div>
462
+ </PromptInputBody>
463
+ {compact ? (
464
+ <InputGroupAddon
465
+ align="inline-end"
466
+ className={COMPACT_END_ADDON}
467
+ >
468
+ {sendTools}
469
+ </InputGroupAddon>
470
+ ) : (
471
+ <PromptInputFooter className="w-full min-w-0 flex-nowrap px-3 pb-3 pt-0">
472
+ <PromptInputTools>{plusButton}</PromptInputTools>
473
+ {sendTools}
474
+ </PromptInputFooter>
475
+ )}
476
+ </PromptInput>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ {typeof document !== "undefined"
481
+ ? createPortal(
482
+ <AnimatePresence>
483
+ {slashMenu.showPopup && slashMenu.placement ? (
484
+ <ComposerSlashMenu
485
+ key="slash"
486
+ listboxId={slashMenu.listboxId}
487
+ placement={slashMenu.placement}
488
+ items={items}
489
+ query={slashMenu.trigger?.query ?? ""}
490
+ selectedIndex={slashMenu.selectedIndex}
491
+ onHoverIndex={slashMenu.setSelectedIndex}
492
+ onSelectItem={slashMenu.onSelectItem}
493
+ renderItem={renderSlashItem}
494
+ />
495
+ ) : null}
496
+ </AnimatePresence>,
497
+ document.body,
498
+ )
499
+ : null}
500
+ </div>
501
+ </TooltipProvider>
502
+ </LazyMotion>
503
+ );
504
+ }
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from "react";
2
+ import { IconLock } from "@tabler/icons-react";
3
+ import { cn } from "../../utils/cn";
4
+ import { FadePresence } from "./FadePresence";
5
+ import type { BannerFeatures } from "./types";
6
+
7
+ export type InputBannerStyles = {
8
+ privacy: { wrapper: string; icon: string; title: string; detail: string };
9
+ };
10
+
11
+ export const defaultInputBannerStyles: InputBannerStyles = {
12
+ privacy: {
13
+ wrapper: "",
14
+ icon: "",
15
+ title: "",
16
+ detail: "text-muted-foreground",
17
+ },
18
+ };
19
+
20
+ export type InputBannersProps = {
21
+ features: BannerFeatures;
22
+ /** Class strings for the privacy banner. The app passes its own for an exact match. */
23
+ styles?: InputBannerStyles;
24
+ privacyTitle?: ReactNode;
25
+ privacyDetail?: ReactNode;
26
+ };
27
+
28
+ function BannerWrapper({
29
+ children,
30
+ className,
31
+ }: {
32
+ children: ReactNode;
33
+ className: string;
34
+ }) {
35
+ return (
36
+ <div className="mx-auto w-full max-w-xs md:max-w-2xl">
37
+ <div className={cn("feature-banner", className)}>{children}</div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function InputBanners({
43
+ features,
44
+ styles = defaultInputBannerStyles,
45
+ privacyTitle = "Private mode",
46
+ privacyDetail = "Hidden from history, search, and memories",
47
+ }: InputBannersProps) {
48
+ return (
49
+ <FadePresence show={features.privacy}>
50
+ <BannerWrapper className={styles.privacy.wrapper}>
51
+ <IconLock className={styles.privacy.icon} width={16} />
52
+ <p className={cn("text-sm font-medium", styles.privacy.title)}>
53
+ {privacyTitle}
54
+ </p>
55
+ <p className={cn("ml-auto text-xs", styles.privacy.detail)}>
56
+ {privacyDetail}
57
+ </p>
58
+ </BannerWrapper>
59
+ </FadePresence>
60
+ );
61
+ }
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from "react";
2
+ import { IconSparkles, IconX } from "@tabler/icons-react";
3
+ import { Button } from "../../ui/button";
4
+ import { FadePresence } from "./FadePresence";
5
+
6
+ export type PromoBannerProps = {
7
+ show: boolean;
8
+ onUpgrade?: () => void;
9
+ onDismiss?: () => void;
10
+ /** Banner text. */
11
+ children?: ReactNode;
12
+ actionLabel?: ReactNode;
13
+ icon?: ReactNode;
14
+ };
15
+
16
+ /** Promo strip above the input. The app owns timing, dismissal state and billing. */
17
+ export function PromoBanner({
18
+ show,
19
+ onUpgrade,
20
+ onDismiss,
21
+ children = "Unlock unlimited messages, advanced features, and more",
22
+ actionLabel = "Upgrade",
23
+ icon,
24
+ }: PromoBannerProps) {
25
+ return (
26
+ <FadePresence show={show} className="mb-2 w-full">
27
+ <div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-xl bg-surface-secondary text-foreground">
28
+ <div className="flex items-center gap-2">
29
+ {icon ?? <IconSparkles className="w-4 h-4 text-foreground" />}
30
+ <p className="text-sm text-foreground">{children}</p>
31
+ </div>
32
+ <div className="flex items-center gap-2">
33
+ {onUpgrade ? (
34
+ <Button size="sm" onClick={onUpgrade}>
35
+ {actionLabel}
36
+ </Button>
37
+ ) : null}
38
+ {onDismiss ? (
39
+ <Button
40
+ size="icon"
41
+ variant="ghost"
42
+ className="text-muted-foreground min-w-6 w-6 h-6"
43
+ onClick={onDismiss}
44
+ aria-label="Dismiss"
45
+ >
46
+ <IconX className="w-4 h-4" />
47
+ </Button>
48
+ ) : null}
49
+ </div>
50
+ </div>
51
+ </FadePresence>
52
+ );
53
+ }
@@ -0,0 +1,52 @@
1
+ let measureCtx: CanvasRenderingContext2D | null = null;
2
+
3
+ function getMeasureContext(): CanvasRenderingContext2D | null {
4
+ if (measureCtx) return measureCtx;
5
+ if (typeof document === "undefined") return null;
6
+ const context = document.createElement("canvas").getContext("2d");
7
+ if (context === null) return null;
8
+ measureCtx = context;
9
+ return context;
10
+ }
11
+
12
+ export function isComposerCompact({
13
+ value,
14
+ fileCount,
15
+ chipCount,
16
+ exceedsPill,
17
+ }: {
18
+ value: string;
19
+ fileCount: number;
20
+ chipCount: number;
21
+ exceedsPill: boolean;
22
+ }): boolean {
23
+ if (fileCount > 0) return false;
24
+ if (chipCount > 0) return false;
25
+ if (value.trim().length === 0) return true;
26
+ if (value.includes("\n")) return false;
27
+ return !exceedsPill;
28
+ }
29
+
30
+ export function editorOverflowsHorizontally(editor: HTMLElement): boolean {
31
+ return editor.scrollWidth > editor.clientWidth + 1;
32
+ }
33
+
34
+ export function readEditorInnerWidth(editor: HTMLElement): number {
35
+ const style = getComputedStyle(editor);
36
+ const pad =
37
+ (Number.parseFloat(style.paddingLeft) || 0) +
38
+ (Number.parseFloat(style.paddingRight) || 0);
39
+ return Math.max(0, editor.clientWidth - pad);
40
+ }
41
+
42
+ export function draftExceedsPillWidth(
43
+ text: string,
44
+ widthPx: number,
45
+ font: string,
46
+ ): boolean {
47
+ if (widthPx <= 0 || text.length === 0) return false;
48
+ const ctx = getMeasureContext();
49
+ if (ctx === null) return false;
50
+ ctx.font = font;
51
+ return ctx.measureText(text).width > widthPx;
52
+ }