@streamsai/ui 0.1.4 → 0.1.6
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.
|
@@ -22,7 +22,7 @@ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code';
|
|
|
22
22
|
import { BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, CODE, INLINE_CODE, ITALIC_STAR, ITALIC_UNDERSCORE, LINK, ORDERED_LIST, STRIKETHROUGH, UNORDERED_LIST, } from '@lexical/markdown';
|
|
23
23
|
import { cn } from '../../lib/utils.js';
|
|
24
24
|
import { Collapse } from '../../motion/index.js';
|
|
25
|
-
import { detectTextDirection, hasStrongCharacter } from '../../lib/text-direction.js';
|
|
25
|
+
import { detectTextDirection, hasStrongCharacter, openingDirection } from '../../lib/text-direction.js';
|
|
26
26
|
import { AgentAvatar } from './agent-avatar.js';
|
|
27
27
|
import { ProviderBadge } from './provider-badge.js';
|
|
28
28
|
import { Avatar, AvatarFallback, AvatarImage } from './avatar.js';
|
|
@@ -1938,8 +1938,8 @@ const toggleHeading = (editor, tag) => editor.update(() => {
|
|
|
1938
1938
|
* underline are deliberately absent because the message AST has no representation for them.
|
|
1939
1939
|
*/
|
|
1940
1940
|
const editorTheme = {
|
|
1941
|
-
// DirectionPlugin sets `dir` for bidi;
|
|
1942
|
-
paragraph: 'm-0
|
|
1941
|
+
// DirectionPlugin sets `dir` per block for bidi; the editor wrapper owns the message alignment.
|
|
1942
|
+
paragraph: 'm-0',
|
|
1943
1943
|
text: {
|
|
1944
1944
|
bold: 'font-semibold',
|
|
1945
1945
|
italic: 'italic',
|
|
@@ -1960,7 +1960,7 @@ const editorTheme = {
|
|
|
1960
1960
|
nested: { listitem: 'list-none' },
|
|
1961
1961
|
},
|
|
1962
1962
|
link: 'text-primary underline underline-offset-2',
|
|
1963
|
-
code: 'my-1 block rounded bg-muted p-2 font-mono text-[0.9em]',
|
|
1963
|
+
code: 'my-1 block rounded bg-muted p-2 text-left font-mono text-[0.9em]',
|
|
1964
1964
|
};
|
|
1965
1965
|
const DOCUMENT_MARKDOWN_TRANSFORMERS = [
|
|
1966
1966
|
BOLD_ITALIC_STAR,
|
|
@@ -2257,17 +2257,28 @@ function LinkDialog({ draft, labels, onClose, }) {
|
|
|
2257
2257
|
}
|
|
2258
2258
|
// ── Content direction (HAC #1045) ────────────────────────────────────────────
|
|
2259
2259
|
const DIRECTION_PLUGIN_TAG = 'direction-plugin';
|
|
2260
|
-
/**
|
|
2261
|
-
|
|
2260
|
+
/**
|
|
2261
|
+
* Opener direction of the last non-empty draft. Survives an empty composer / send remount so
|
|
2262
|
+
* typing Hebrew again keeps RTL, and a Hebrew-led composer stays right-aligned while empty.
|
|
2263
|
+
*/
|
|
2264
|
+
let lastRememberedDirection = null;
|
|
2262
2265
|
/** Test-only: reset module-scoped remembered direction between cases. */
|
|
2263
2266
|
function __resetLastRememberedDirectionForTests() {
|
|
2264
|
-
lastRememberedDirection =
|
|
2267
|
+
lastRememberedDirection = null;
|
|
2265
2268
|
}
|
|
2266
2269
|
/**
|
|
2267
2270
|
* Plain text used for word-vote direction: mentions, cycle triggers, stream
|
|
2268
2271
|
* chips and link labels are excluded so `@Name` then Hebrew does not lock LTR.
|
|
2272
|
+
* A list contributes its top-level items only, so a nested sub-list cannot outvote it.
|
|
2269
2273
|
*/
|
|
2270
2274
|
const plainTextForDirection = (node) => {
|
|
2275
|
+
// Items stay separate words, as the renderer joins them: `hello` + `please` must not fuse into one token.
|
|
2276
|
+
if ($isListNode(node)) {
|
|
2277
|
+
return node
|
|
2278
|
+
.getChildren()
|
|
2279
|
+
.map((item) => ($isElementNode(item) ? plainTextForDirection(item) : ''))
|
|
2280
|
+
.join(' ');
|
|
2281
|
+
}
|
|
2271
2282
|
let out = '';
|
|
2272
2283
|
for (const child of node.getChildren()) {
|
|
2273
2284
|
if ($isMentionNode(child) ||
|
|
@@ -2276,6 +2287,8 @@ const plainTextForDirection = (node) => {
|
|
|
2276
2287
|
$isCustomEmojiNode(child) ||
|
|
2277
2288
|
$isLinkNode(child))
|
|
2278
2289
|
continue;
|
|
2290
|
+
if ($isListNode(child))
|
|
2291
|
+
continue;
|
|
2279
2292
|
if ($isTextNode(child)) {
|
|
2280
2293
|
out += child.getTextContent();
|
|
2281
2294
|
continue;
|
|
@@ -2291,9 +2304,10 @@ const plainTextForDirection = (node) => {
|
|
|
2291
2304
|
* once mention display names enter the signal.
|
|
2292
2305
|
*
|
|
2293
2306
|
* Stamps the live DOM (HAC) and Lexical `setDirection` so caret alignment and
|
|
2294
|
-
* serialized `direction` stay in sync.
|
|
2307
|
+
* serialized `direction` stay in sync. In chat mode (`onAlignmentChange` given) the first block
|
|
2308
|
+
* with a strong letter also decides one side for the whole draft, so later paragraphs never move it.
|
|
2295
2309
|
*/
|
|
2296
|
-
function DirectionPlugin({ enabled = true }) {
|
|
2310
|
+
function DirectionPlugin({ enabled = true, onAlignmentChange, }) {
|
|
2297
2311
|
const [editor] = useLexicalComposerContext();
|
|
2298
2312
|
React.useEffect(() => {
|
|
2299
2313
|
if (!enabled)
|
|
@@ -2301,6 +2315,18 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2301
2315
|
const stampDirections = () => {
|
|
2302
2316
|
const updates = [];
|
|
2303
2317
|
editor.getEditorState().read(() => {
|
|
2318
|
+
const blocks = $getRoot()
|
|
2319
|
+
.getChildren()
|
|
2320
|
+
.flatMap((child) => {
|
|
2321
|
+
if (!$isListNode(child) && !$isParagraphNode(child))
|
|
2322
|
+
return [];
|
|
2323
|
+
return [{ child, signal: plainTextForDirection(child) }];
|
|
2324
|
+
});
|
|
2325
|
+
const opener = openingDirection(blocks.map((block) => block.signal));
|
|
2326
|
+
if (opener !== null)
|
|
2327
|
+
lastRememberedDirection = opener;
|
|
2328
|
+
const draftDirection = opener ?? lastRememberedDirection;
|
|
2329
|
+
onAlignmentChange?.(draftDirection === null ? null : draftDirection === 'rtl' ? 'right' : 'left');
|
|
2304
2330
|
for (const child of $getRoot().getChildren()) {
|
|
2305
2331
|
const dom = editor.getElementByKey(child.getKey());
|
|
2306
2332
|
let dir;
|
|
@@ -2313,12 +2339,13 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2313
2339
|
// Empty / "@" / "/" / mention-only blocks keep the last typed direction (HAC remount
|
|
2314
2340
|
// memory) on the DOM only: a model update here would re-run the typeahead listener
|
|
2315
2341
|
// mid-trigger and close the menu the composer's @ and / buttons just opened.
|
|
2316
|
-
if (dom &&
|
|
2342
|
+
if (dom &&
|
|
2343
|
+
lastRememberedDirection &&
|
|
2344
|
+
dom.getAttribute('dir') !== lastRememberedDirection)
|
|
2317
2345
|
dom.setAttribute('dir', lastRememberedDirection);
|
|
2318
2346
|
continue;
|
|
2319
2347
|
}
|
|
2320
2348
|
dir = detectTextDirection(signal);
|
|
2321
|
-
lastRememberedDirection = dir;
|
|
2322
2349
|
}
|
|
2323
2350
|
else {
|
|
2324
2351
|
continue;
|
|
@@ -2340,12 +2367,16 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2340
2367
|
}, { tag: DIRECTION_PLUGIN_TAG });
|
|
2341
2368
|
};
|
|
2342
2369
|
stampDirections();
|
|
2343
|
-
|
|
2370
|
+
const unregister = editor.registerUpdateListener(({ tags }) => {
|
|
2344
2371
|
if (tags.has(DIRECTION_PLUGIN_TAG))
|
|
2345
2372
|
return;
|
|
2346
2373
|
stampDirections();
|
|
2347
2374
|
});
|
|
2348
|
-
|
|
2375
|
+
return () => {
|
|
2376
|
+
unregister();
|
|
2377
|
+
onAlignmentChange?.(null);
|
|
2378
|
+
};
|
|
2379
|
+
}, [editor, enabled, onAlignmentChange]);
|
|
2349
2380
|
return null;
|
|
2350
2381
|
}
|
|
2351
2382
|
const editorNodes = [
|
|
@@ -2374,6 +2405,7 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
|
|
|
2374
2405
|
const documentImages = useDocumentImages(editor, documentMode);
|
|
2375
2406
|
const documentContext = React.useMemo(() => (documentMode ? { options: documentMode, labels: documentLabels } : null), [documentMode, documentLabels]);
|
|
2376
2407
|
const [linkDraft, setLinkDraft] = React.useState(null);
|
|
2408
|
+
const [alignment, setAlignment] = React.useState(null);
|
|
2377
2409
|
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
|
|
2378
2410
|
const showToolbar = toolbar && !disabled;
|
|
2379
2411
|
const openLinkDialog = React.useCallback(() => setLinkDraft(readLinkDraft(editor)), [editor]);
|
|
@@ -2383,9 +2415,12 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
|
|
|
2383
2415
|
streamFallback: labels.streamFallback,
|
|
2384
2416
|
}, children: _jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex min-h-0 flex-col rounded-md border', className), children: [_jsx(Collapse, { open: showToolbar, children: _jsx(FormattingToolbar, { labels: labels, onLinkClick: openLinkDialog, documentMode: documentContext
|
|
2385
2417
|
? { ...documentContext, addFiles: documentImages.addFiles }
|
|
2386
|
-
: undefined }) }), _jsx("div", { className: "relative min-h-0 flex-1 overflow-y-auto", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { "aria-label": ariaLabel, "aria-multiline": true, role: "textbox", className:
|
|
2418
|
+
: undefined }) }), _jsx("div", { "data-align": documentMode ? undefined : (alignment ?? undefined), className: "group/align relative min-h-0 flex-1 overflow-y-auto", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { "aria-label": ariaLabel, "aria-multiline": true, role: "textbox", className: cn(
|
|
2419
|
+
// A document keeps its lines on the left; a chat draft aligns as one block by its
|
|
2420
|
+
// opener, and a right-aligned draft parks its (shrink-wrapped) lists on the right.
|
|
2421
|
+
documentMode ? 'text-left' : 'text-start', 'group-data-[align=left]/align:text-left group-data-[align=right]/align:text-right', 'group-data-[align=right]/align:[&>ol]:ml-auto group-data-[align=right]/align:[&>ul]:ml-auto group-data-[align=left]/align:[&>ol]:mr-auto group-data-[align=left]/align:[&>ul]:mr-auto', 'min-h-9 w-full resize-none px-3 py-1.5 text-sm outline-none in-data-[platform=mobile]:text-base disabled:cursor-not-allowed disabled:opacity-50'), disabled: disabled }), placeholder: placeholder ? (_jsx("div", { className: "pointer-events-none absolute top-1.5 start-3 text-sm text-muted-foreground group-data-[align=left]/align:start-auto group-data-[align=left]/align:left-3 group-data-[align=right]/align:start-auto group-data-[align=right]/align:right-3", children: placeholder })) : null, ErrorBoundary: PassthroughErrorBoundary }) }), autoFocus ? _jsx(AutoFocusPlugin, {}) : null, _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(ListTabIndentPlugin, {}), _jsx(LinkPlugin, { validateUrl: (url) => /^https?:\/\//i.test(url) }), _jsx(AutoLinkPlugin, { matchers: MATCHERS, excludeParents: AUTO_LINK_EXCLUDE_PARENTS }), _jsx(MarkdownShortcutPlugin, { transformers: documentMode ? DOCUMENT_MARKDOWN_TRANSFORMERS : MARKDOWN_TRANSFORMERS }), _jsx(PasteLinkOnSelectionPlugin, {}), linkDraft ? (_jsx(LinkDialog, { draft: linkDraft, labels: labels, onClose: () => setLinkDraft(null) })) : null, _jsx(OnChangePlugin, { onChange: (editorState) => {
|
|
2387
2422
|
onChange(editorState.toJSON());
|
|
2388
|
-
} }), _jsx(EnterSubmitPlugin, { onSubmit: onSubmit, sendOnEnter: sendOnEnter }), documentMode ? null : _jsx(CodeFenceOnThirdBacktickPlugin, {}), _jsx(InsertTextAtCaretPlugin, { request: insertTextAtCaret, onInserted: onInsertTextAtCaret }), _jsx(TypeaheadEditGatePlugin, {}), _jsx(MentionsPluginWithPortal, { searchMentions: searchMentions, onMentionSelect: onMentionSelect }), _jsx(SlashCommandsPlugin, { searchSlashCommands: searchSlashCommands, onSelectCommand: onSlashCommandSelect, onSlashTyped: onSlashTyped, labels: labels }), _jsx(EmojiTypeaheadPlugin, { searchEmojis: searchEmojis, labels: labels, onEmojiUsed: onEmojiUsed }), _jsx(EmojiShortcodeReplacePlugin, { resolveEmojiShortcode: resolveEmojiShortcode, onEmojiUsed: onEmojiUsed }), _jsx(StreamChipsTypeaheadPlugin, { searchStreams: searchStreams, labels: labels }), _jsx(CycleTriggerPlugin, { request: insertCycleTrigger, onInserted: onCycleTriggerInserted }), _jsx(CustomEmojiPlugin, { request: insertCustomEmoji, onInserted: onCustomEmojiInserted }), _jsx(SlashCommandChipPlugin, { commands: chipSlashCommands }), _jsx(DirectionPlugin, { enabled: detectContentDirection })] }) }) }));
|
|
2423
|
+
} }), _jsx(EnterSubmitPlugin, { onSubmit: onSubmit, sendOnEnter: sendOnEnter }), documentMode ? null : _jsx(CodeFenceOnThirdBacktickPlugin, {}), _jsx(InsertTextAtCaretPlugin, { request: insertTextAtCaret, onInserted: onInsertTextAtCaret }), _jsx(TypeaheadEditGatePlugin, {}), _jsx(MentionsPluginWithPortal, { searchMentions: searchMentions, onMentionSelect: onMentionSelect }), _jsx(SlashCommandsPlugin, { searchSlashCommands: searchSlashCommands, onSelectCommand: onSlashCommandSelect, onSlashTyped: onSlashTyped, labels: labels }), _jsx(EmojiTypeaheadPlugin, { searchEmojis: searchEmojis, labels: labels, onEmojiUsed: onEmojiUsed }), _jsx(EmojiShortcodeReplacePlugin, { resolveEmojiShortcode: resolveEmojiShortcode, onEmojiUsed: onEmojiUsed }), _jsx(StreamChipsTypeaheadPlugin, { searchStreams: searchStreams, labels: labels }), _jsx(CycleTriggerPlugin, { request: insertCycleTrigger, onInserted: onCycleTriggerInserted }), _jsx(CustomEmojiPlugin, { request: insertCustomEmoji, onInserted: onCustomEmojiInserted }), _jsx(SlashCommandChipPlugin, { commands: chipSlashCommands }), _jsx(DirectionPlugin, { enabled: detectContentDirection, onAlignmentChange: documentMode ? undefined : setAlignment })] }) }) }));
|
|
2389
2424
|
}
|
|
2390
2425
|
/**
|
|
2391
2426
|
* Controlled rich-text editor built on Lexical.
|
package/dist/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants
|
|
|
33
33
|
export { Button, buttonVariants } from './components/ui/button.js';
|
|
34
34
|
export { cn } from './lib/utils.js';
|
|
35
35
|
export { HOME_TILE_COUNT, homeTileIndex } from './lib/home-tile.js';
|
|
36
|
-
export { detectTextDirection, hasStrongCharacter, isNeutralToken } from './lib/text-direction.js';
|
|
36
|
+
export { detectTextDirection, hasStrongCharacter, isNeutralToken, openingDirection, } from './lib/text-direction.js';
|
|
37
37
|
export { Checkbox } from './components/ui/checkbox.js';
|
|
38
38
|
export { Clickable, clickableVariants, type ClickableProps } from './components/ui/clickable.js';
|
|
39
39
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './components/ui/dialog.js';
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants
|
|
|
38
38
|
export { Button, buttonVariants } from './components/ui/button.js';
|
|
39
39
|
export { cn } from './lib/utils.js';
|
|
40
40
|
export { HOME_TILE_COUNT, homeTileIndex } from './lib/home-tile.js';
|
|
41
|
-
export { detectTextDirection, hasStrongCharacter, isNeutralToken } from './lib/text-direction.js';
|
|
41
|
+
export { detectTextDirection, hasStrongCharacter, isNeutralToken, openingDirection, } from './lib/text-direction.js';
|
|
42
42
|
export { Checkbox } from './components/ui/checkbox.js';
|
|
43
43
|
export { Clickable, clickableVariants } from './components/ui/clickable.js';
|
|
44
44
|
// prettier-ignore
|
|
@@ -26,3 +26,8 @@ export declare const hasStrongCharacter: (text: string) => boolean;
|
|
|
26
26
|
* with a dominant-script override.
|
|
27
27
|
*/
|
|
28
28
|
export declare const detectTextDirection: (text: string) => TextDirection;
|
|
29
|
+
/**
|
|
30
|
+
* Side a whole chat message aligns to: the word vote on the first block that has a strong
|
|
31
|
+
* letter (blank, digit-only and punctuation-only openers are skipped), or `null` when no block has one.
|
|
32
|
+
*/
|
|
33
|
+
export declare const openingDirection: (blockTexts: Iterable<string>) => TextDirection | null;
|
|
@@ -117,3 +117,23 @@ export const detectTextDirection = (text) => {
|
|
|
117
117
|
return 'ltr';
|
|
118
118
|
return firstStrong;
|
|
119
119
|
};
|
|
120
|
+
const LETTER = /\p{L}/u;
|
|
121
|
+
/** Like `hasStrongCharacter`, but digits and punctuation in RTL ranges (`١٢`, `׃`) do not count. */
|
|
122
|
+
const hasStrongLetter = (text) => {
|
|
123
|
+
for (const character of text) {
|
|
124
|
+
if (strengthOf(character) !== null && LETTER.test(character))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Side a whole chat message aligns to: the word vote on the first block that has a strong
|
|
131
|
+
* letter (blank, digit-only and punctuation-only openers are skipped), or `null` when no block has one.
|
|
132
|
+
*/
|
|
133
|
+
export const openingDirection = (blockTexts) => {
|
|
134
|
+
for (const text of blockTexts) {
|
|
135
|
+
if (hasStrongLetter(text))
|
|
136
|
+
return detectTextDirection(text);
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
package/dist/styles/tokens.css
CHANGED
|
@@ -339,8 +339,9 @@
|
|
|
339
339
|
|
|
340
340
|
/* --- 2a. syntax highlighting (CodeView) ------------------------------------
|
|
341
341
|
* The variables Shiki's CSS-variables theme reads (`variablePrefix: '--code-'`). Every value is a
|
|
342
|
-
* var() of a primitive, so the `.dark` class re-tints them
|
|
343
|
-
*
|
|
342
|
+
* var() of a primitive, so the `.dark` class re-tints them. Picked for contrast on both page
|
|
343
|
+
* backgrounds: done-green-strong rather than done-green for strings. The primary blue is the one
|
|
344
|
+
* exception: 4.17:1 on the dark page, under the 4.5:1 minimum, so 2b swaps in chili-blue. */
|
|
344
345
|
:root {
|
|
345
346
|
--code-foreground: var(--foreground);
|
|
346
347
|
--code-background: transparent;
|
|
@@ -363,6 +364,9 @@
|
|
|
363
364
|
/* `--positive` / `--warning` stay pastel in dark, so the chip text keeps its light-theme shade. */
|
|
364
365
|
--accent-teal: #00858a;
|
|
365
366
|
--accent-yellow: #7a5a00;
|
|
367
|
+
/* Primary blue is 4.17:1 on --vibe-grey-background-color and 2.87:1 on --vibe-ui-background-color. */
|
|
368
|
+
--code-token-function: var(--vibe-color-chili-blue);
|
|
369
|
+
--code-token-link: var(--vibe-color-chili-blue);
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
/*
|
package/package.json
CHANGED