@streamsai/ui 0.1.3 → 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.
|
@@ -73,8 +73,6 @@ export type SlashCommandCandidate = {
|
|
|
73
73
|
insertText?: string | null;
|
|
74
74
|
/** Menu section; the menu lists `cycles` before `commands`. Ungrouped candidates show under one title. */
|
|
75
75
|
group?: 'cycles' | 'commands';
|
|
76
|
-
/** Draws a soft sweeping highlight behind the row, to point a first-time user at it. */
|
|
77
|
-
emphasis?: boolean;
|
|
78
76
|
};
|
|
79
77
|
/** Host-supplied emoji for the `:` shortcode typeahead (Slack-style `:smile:`). */
|
|
80
78
|
export type EmojiCandidate = {
|
|
@@ -21,9 +21,8 @@ import { $createLinkNode, $isLinkNode, LinkNode, AutoLinkNode } from '@lexical/l
|
|
|
21
21
|
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
|
-
import { Collapse
|
|
25
|
-
import {
|
|
26
|
-
import { detectTextDirection, hasStrongCharacter } from '../../lib/text-direction.js';
|
|
24
|
+
import { Collapse } from '../../motion/index.js';
|
|
25
|
+
import { detectTextDirection, hasStrongCharacter, openingDirection } from '../../lib/text-direction.js';
|
|
27
26
|
import { AgentAvatar } from './agent-avatar.js';
|
|
28
27
|
import { ProviderBadge } from './provider-badge.js';
|
|
29
28
|
import { Avatar, AvatarFallback, AvatarImage } from './avatar.js';
|
|
@@ -1403,22 +1402,13 @@ function SlashCommandsPlugin({ searchSlashCommands, onSelectCommand, onSlashType
|
|
|
1403
1402
|
: labels.slashTitle;
|
|
1404
1403
|
return (_jsxs(React.Fragment, { children: [startsGroup ? _jsx(SlashGroupHeader, { label: title, first: index === 0 }) : null, _jsxs("button", { id: `slash-typeahead-item-${index}`, type: "button", role: "option", tabIndex: -1, "aria-selected": selected,
|
|
1405
1404
|
// Lexical scrolls the highlighted row into the max-h menu via this ref.
|
|
1406
|
-
ref: option.setRefElement, className: cn('
|
|
1405
|
+
ref: option.setRefElement, className: cn('flex w-full flex-col rounded-lg p-2 text-start text-sm in-data-[platform=mobile]:mb-0.5 in-data-[platform=mobile]:text-base', selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'), onClick: () => {
|
|
1407
1406
|
setHighlightedIndex(index);
|
|
1408
1407
|
selectOptionAndCleanUp(option);
|
|
1409
|
-
}, onMouseEnter: () => setHighlightedIndex(index), children: [
|
|
1408
|
+
}, onMouseEnter: () => setHighlightedIndex(index), children: [_jsxs("span", { children: ["/", option.candidate.name] }), _jsx("span", { className: "text-muted-foreground", children: option.candidate.description })] })] }, option.key));
|
|
1410
1409
|
})) }), document.body);
|
|
1411
1410
|
} }));
|
|
1412
1411
|
}
|
|
1413
|
-
/** A tinted wash with a light band sweeping across it; still (tint only) under reduced motion. */
|
|
1414
|
-
function SlashEmphasis() {
|
|
1415
|
-
const reduceMotion = useReducedMotion();
|
|
1416
|
-
return (_jsx(motion.span, { "aria-hidden": true, "data-slot": "slash-emphasis", className: "pointer-events-none absolute inset-0 -z-10 rounded-lg bg-primary/8", style: {
|
|
1417
|
-
backgroundImage: 'linear-gradient(100deg, transparent 30%, color-mix(in oklab, var(--primary) 22%, transparent) 50%, transparent 70%)',
|
|
1418
|
-
backgroundSize: '220% 100%',
|
|
1419
|
-
backgroundRepeat: 'no-repeat',
|
|
1420
|
-
}, initial: { backgroundPosition: '160% 0' }, animate: reduceMotion === true ? undefined : { backgroundPosition: ['160% 0', '-60% 0'] }, transition: { ...loops.shimmer, duration: 1.8 } }));
|
|
1421
|
-
}
|
|
1422
1412
|
function SlashGroupHeader({ label, first }) {
|
|
1423
1413
|
return (_jsxs("div", { className: cn('flex items-center gap-2.5 px-2 pb-1 text-sm text-muted-foreground', first ? 'pt-1.5' : 'pt-3'), children: [_jsx("span", { children: label }), _jsx("span", { className: "h-px flex-1 bg-border", "aria-hidden": true })] }));
|
|
1424
1414
|
}
|
|
@@ -1948,8 +1938,8 @@ const toggleHeading = (editor, tag) => editor.update(() => {
|
|
|
1948
1938
|
* underline are deliberately absent because the message AST has no representation for them.
|
|
1949
1939
|
*/
|
|
1950
1940
|
const editorTheme = {
|
|
1951
|
-
// DirectionPlugin sets `dir` for bidi;
|
|
1952
|
-
paragraph: 'm-0
|
|
1941
|
+
// DirectionPlugin sets `dir` per block for bidi; the editor wrapper owns the message alignment.
|
|
1942
|
+
paragraph: 'm-0',
|
|
1953
1943
|
text: {
|
|
1954
1944
|
bold: 'font-semibold',
|
|
1955
1945
|
italic: 'italic',
|
|
@@ -1970,7 +1960,7 @@ const editorTheme = {
|
|
|
1970
1960
|
nested: { listitem: 'list-none' },
|
|
1971
1961
|
},
|
|
1972
1962
|
link: 'text-primary underline underline-offset-2',
|
|
1973
|
-
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]',
|
|
1974
1964
|
};
|
|
1975
1965
|
const DOCUMENT_MARKDOWN_TRANSFORMERS = [
|
|
1976
1966
|
BOLD_ITALIC_STAR,
|
|
@@ -2267,17 +2257,28 @@ function LinkDialog({ draft, labels, onClose, }) {
|
|
|
2267
2257
|
}
|
|
2268
2258
|
// ── Content direction (HAC #1045) ────────────────────────────────────────────
|
|
2269
2259
|
const DIRECTION_PLUGIN_TAG = 'direction-plugin';
|
|
2270
|
-
/**
|
|
2271
|
-
|
|
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;
|
|
2272
2265
|
/** Test-only: reset module-scoped remembered direction between cases. */
|
|
2273
2266
|
function __resetLastRememberedDirectionForTests() {
|
|
2274
|
-
lastRememberedDirection =
|
|
2267
|
+
lastRememberedDirection = null;
|
|
2275
2268
|
}
|
|
2276
2269
|
/**
|
|
2277
2270
|
* Plain text used for word-vote direction: mentions, cycle triggers, stream
|
|
2278
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.
|
|
2279
2273
|
*/
|
|
2280
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
|
+
}
|
|
2281
2282
|
let out = '';
|
|
2282
2283
|
for (const child of node.getChildren()) {
|
|
2283
2284
|
if ($isMentionNode(child) ||
|
|
@@ -2286,6 +2287,8 @@ const plainTextForDirection = (node) => {
|
|
|
2286
2287
|
$isCustomEmojiNode(child) ||
|
|
2287
2288
|
$isLinkNode(child))
|
|
2288
2289
|
continue;
|
|
2290
|
+
if ($isListNode(child))
|
|
2291
|
+
continue;
|
|
2289
2292
|
if ($isTextNode(child)) {
|
|
2290
2293
|
out += child.getTextContent();
|
|
2291
2294
|
continue;
|
|
@@ -2301,9 +2304,10 @@ const plainTextForDirection = (node) => {
|
|
|
2301
2304
|
* once mention display names enter the signal.
|
|
2302
2305
|
*
|
|
2303
2306
|
* Stamps the live DOM (HAC) and Lexical `setDirection` so caret alignment and
|
|
2304
|
-
* 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.
|
|
2305
2309
|
*/
|
|
2306
|
-
function DirectionPlugin({ enabled = true }) {
|
|
2310
|
+
function DirectionPlugin({ enabled = true, onAlignmentChange, }) {
|
|
2307
2311
|
const [editor] = useLexicalComposerContext();
|
|
2308
2312
|
React.useEffect(() => {
|
|
2309
2313
|
if (!enabled)
|
|
@@ -2311,6 +2315,18 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2311
2315
|
const stampDirections = () => {
|
|
2312
2316
|
const updates = [];
|
|
2313
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');
|
|
2314
2330
|
for (const child of $getRoot().getChildren()) {
|
|
2315
2331
|
const dom = editor.getElementByKey(child.getKey());
|
|
2316
2332
|
let dir;
|
|
@@ -2323,12 +2339,13 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2323
2339
|
// Empty / "@" / "/" / mention-only blocks keep the last typed direction (HAC remount
|
|
2324
2340
|
// memory) on the DOM only: a model update here would re-run the typeahead listener
|
|
2325
2341
|
// mid-trigger and close the menu the composer's @ and / buttons just opened.
|
|
2326
|
-
if (dom &&
|
|
2342
|
+
if (dom &&
|
|
2343
|
+
lastRememberedDirection &&
|
|
2344
|
+
dom.getAttribute('dir') !== lastRememberedDirection)
|
|
2327
2345
|
dom.setAttribute('dir', lastRememberedDirection);
|
|
2328
2346
|
continue;
|
|
2329
2347
|
}
|
|
2330
2348
|
dir = detectTextDirection(signal);
|
|
2331
|
-
lastRememberedDirection = dir;
|
|
2332
2349
|
}
|
|
2333
2350
|
else {
|
|
2334
2351
|
continue;
|
|
@@ -2350,12 +2367,16 @@ function DirectionPlugin({ enabled = true }) {
|
|
|
2350
2367
|
}, { tag: DIRECTION_PLUGIN_TAG });
|
|
2351
2368
|
};
|
|
2352
2369
|
stampDirections();
|
|
2353
|
-
|
|
2370
|
+
const unregister = editor.registerUpdateListener(({ tags }) => {
|
|
2354
2371
|
if (tags.has(DIRECTION_PLUGIN_TAG))
|
|
2355
2372
|
return;
|
|
2356
2373
|
stampDirections();
|
|
2357
2374
|
});
|
|
2358
|
-
|
|
2375
|
+
return () => {
|
|
2376
|
+
unregister();
|
|
2377
|
+
onAlignmentChange?.(null);
|
|
2378
|
+
};
|
|
2379
|
+
}, [editor, enabled, onAlignmentChange]);
|
|
2359
2380
|
return null;
|
|
2360
2381
|
}
|
|
2361
2382
|
const editorNodes = [
|
|
@@ -2384,6 +2405,7 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
|
|
|
2384
2405
|
const documentImages = useDocumentImages(editor, documentMode);
|
|
2385
2406
|
const documentContext = React.useMemo(() => (documentMode ? { options: documentMode, labels: documentLabels } : null), [documentMode, documentLabels]);
|
|
2386
2407
|
const [linkDraft, setLinkDraft] = React.useState(null);
|
|
2408
|
+
const [alignment, setAlignment] = React.useState(null);
|
|
2387
2409
|
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
|
|
2388
2410
|
const showToolbar = toolbar && !disabled;
|
|
2389
2411
|
const openLinkDialog = React.useCallback(() => setLinkDraft(readLinkDraft(editor)), [editor]);
|
|
@@ -2393,9 +2415,12 @@ function RichTextEditorInner({ value: _value, onChange, placeholder, searchMenti
|
|
|
2393
2415
|
streamFallback: labels.streamFallback,
|
|
2394
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
|
|
2395
2417
|
? { ...documentContext, addFiles: documentImages.addFiles }
|
|
2396
|
-
: 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) => {
|
|
2397
2422
|
onChange(editorState.toJSON());
|
|
2398
|
-
} }), _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 })] }) }) }));
|
|
2399
2424
|
}
|
|
2400
2425
|
/**
|
|
2401
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