@streamsai/ui 0.1.4 → 0.1.5

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; `text-left` keeps lines left-aligned like Slack.
1942
- paragraph: 'm-0 text-left',
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
- /** Survives empty composer / send remount so typing Hebrew again keeps RTL. */
2261
- let lastRememberedDirection = 'ltr';
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 = 'ltr';
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 && dom.getAttribute('dir') !== lastRememberedDirection)
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
- return editor.registerUpdateListener(({ tags }) => {
2370
+ const unregister = editor.registerUpdateListener(({ tags }) => {
2344
2371
  if (tags.has(DIRECTION_PLUGIN_TAG))
2345
2372
  return;
2346
2373
  stampDirections();
2347
2374
  });
2348
- }, [editor, enabled]);
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: "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", 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) => {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamsai/ui",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Streams design system: shadcn/ui components on Base UI, design tokens (Tailwind v4) and a theme provider for React 19.",
5
5
  "license": "MIT",
6
6
  "repository": {