RenderBlocks: extract hooks

This commit is contained in:
Enrico Ros
2024-08-09 01:25:46 -07:00
parent c903c7f7ed
commit e7c38c3785
2 changed files with 100 additions and 75 deletions
+15 -75
View File
@@ -3,25 +3,19 @@ import type { Diff as SanityTextDiff } from '@sanity/diff-match-patch';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { shallowEquals } from '~/common/util/hooks/useShallowObject';
import { BlocksContainer } from './BlocksContainers';
import { RenderBlockInputs } from './blocks.types';
import { RenderDangerousHtml } from './danger-html/RenderDangerousHtml';
import { RenderImageURL } from './image/RenderImageURL';
import { RenderMarkdown, RenderMarkdownMemo } from './markdown/RenderMarkdown';
import { RenderPlainChatText } from './plaintext/RenderPlainChatText';
import { RenderTextDiff } from './textdiff/RenderTextDiff';
import { ToggleExpansionButton } from './ToggleExpansionButton';
import { parseBlocksFromText } from './blocks.textparser';
import { renderCodeMemoOrNot } from './code/RenderCode';
import { useAutoBlocksMemo, useTextCollapser } from './blocks.hooks';
import { useScaledCodeSx, useScaledImageSx, useScaledTypographySx, useToggleExpansionButtonSx } from './blocks.styles';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
type BlocksRendererProps = {
// required
text: string;
@@ -58,77 +52,23 @@ type BlocksRendererProps = {
*/
export const AutoBlocksRenderer = React.forwardRef<HTMLDivElement, BlocksRendererProps>((props, ref) => {
// state
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
const prevBlocksRef = React.useRef<RenderBlockInputs>([]);
// derived state
const { text: _text, renderSanityTextDiffs } = props;
// props-derived state
const fromAssistant = props.fromRole === 'assistant';
const fromSystem = props.fromRole === 'system';
const fromUser = props.fromRole === 'user';
const isUserCommand = fromUser && _text.startsWith('/');
// Memo text, which could be 'collapsed' to a few lines in case of user messages
const { text, isTextCollapsed } = React.useMemo(() => {
if (fromUser && !forceUserExpanded) {
const textLines = _text.split('\n');
if (textLines.length > USER_COLLAPSED_LINES)
return { text: textLines.slice(0, USER_COLLAPSED_LINES).join('\n'), isTextCollapsed: true };
}
return { text: _text, isTextCollapsed: false };
}, [forceUserExpanded, fromUser, _text]);
const handleToggleExpansion = React.useCallback(() => {
setForceUserExpanded(on => !on);
}, []);
// Block splitter, with memo and special recycle of former blocks, to help React minimize render work
const autoBlocksMemo = React.useMemo(() => {
// follow outside direction, or activate the auto-splitter based on content
const newBlocks: RenderBlockInputs = [];
if (props.renderAsCodeWithTitle)
newBlocks.push({ bkt: 'code-bk', title: props.renderAsCodeWithTitle, code: text, isPartial: false });
else if (fromSystem)
newBlocks.push({ bkt: 'md-bk', content: text });
else if (renderSanityTextDiffs && renderSanityTextDiffs.length >= 1)
newBlocks.push({ bkt: 'txt-diffs-bk', sanityTextDiffs: renderSanityTextDiffs });
else
newBlocks.push(...parseBlocksFromText(text));
// recycle the previous blocks if they are the same, for stable references to React
const recycledBlocks: RenderBlockInputs = [];
for (let i = 0; i < newBlocks.length; i++) {
const newBlock = newBlocks[i];
const prevBlock = prevBlocksRef.current[i] ?? undefined;
// Check if the new block can be replaced by the previous block to maintain reference stability
if (prevBlock && shallowEquals(prevBlock, newBlock)) {
recycledBlocks.push(prevBlock);
} else {
// Once a block doesn't match, we use the new blocks from this point forward.
recycledBlocks.push(...newBlocks.slice(i));
break;
}
}
// Update prevBlocksRef with the current blocks for the next render
prevBlocksRef.current = recycledBlocks;
// Apply specialDiagramMode filter if applicable
return props.specialDiagramMode
? recycledBlocks.filter(({ bkt }) => bkt === 'code-bk' || recycledBlocks.length === 1)
: recycledBlocks;
}, [fromSystem, props.renderAsCodeWithTitle, props.specialDiagramMode, renderSanityTextDiffs, text]);
const isUserCommand = fromUser && props.text.startsWith('/');
// state
const { text, isTextCollapsed, forceTextExpanded, handleToggleExpansion } =
useTextCollapser(props.text, fromUser);
let autoBlocksStable =
useAutoBlocksMemo(text, props.renderAsCodeWithTitle, fromSystem, props.renderSanityTextDiffs);
console.log(autoBlocksStable);
// apply specialDiagramMode filter if applicable
if (props.specialDiagramMode)
autoBlocksStable = autoBlocksStable.filter(({ bkt }) => bkt === 'code-bk' || autoBlocksStable.length === 1);
// Memo the styles, to minimize re-renders
const scaledCodeSx = useScaledCodeSx(fromAssistant, props.contentScaling, !!props.specialCodePlain);
const scaledImageSx = useScaledImageSx(props.contentScaling);
const scaledTypographySx = useScaledTypographySx(props.contentScaling, !!props.showAsDanger, !!props.showAsItalic);
@@ -143,10 +83,10 @@ export const AutoBlocksRenderer = React.forwardRef<HTMLDivElement, BlocksRendere
>
{/* sequence of render components, for each Block */}
{autoBlocksMemo.map((bkInput, index) => {
{autoBlocksStable.map((bkInput, index) => {
// Optimization: only memo the non-currently-rendered components, if the message is still in flux
const optimizeMemoBeforeLastBlock = props.optiAllowSubBlocksMemo === true && index < (autoBlocksMemo.length - 1);
const optimizeMemoBeforeLastBlock = props.optiAllowSubBlocksMemo === true && index < (autoBlocksStable.length - 1);
switch (bkInput.bkt) {
@@ -212,7 +152,7 @@ export const AutoBlocksRenderer = React.forwardRef<HTMLDivElement, BlocksRendere
}
})}
{(isTextCollapsed || forceUserExpanded) && (
{(isTextCollapsed || forceTextExpanded) && (
<ToggleExpansionButton
color={props.specialCodePlain ? 'neutral' : undefined}
isCollapsed={isTextCollapsed}
+85
View File
@@ -0,0 +1,85 @@
import * as React from 'react';
import type { Diff as SanityTextDiff } from '@sanity/diff-match-patch';
import { shallowEquals } from '~/common/util/hooks/useShallowObject';
import type { RenderBlockInputs } from './blocks.types';
import { parseBlocksFromText } from './blocks.textparser';
// configuration
const USER_COLLAPSED_LINES: number = 8;
export function useTextCollapser(origText: string, enable: boolean) {
// state
const [forceTextExpanded, setForceTextExpanded] = React.useState(false);
// quick memo
const { text, isTextCollapsed } = React.useMemo(() => {
// nothing to do
if (!enable || forceTextExpanded)
return { text: origText, isTextCollapsed: false };
// count lines
const textLines = origText.split('\n');
if (textLines.length <= USER_COLLAPSED_LINES)
return { text: origText, isTextCollapsed: false };
// chop to the first few lines
return { text: textLines.slice(0, USER_COLLAPSED_LINES).join('\n'), isTextCollapsed: true };
}, [enable, forceTextExpanded, origText]);
// memo handlers
const handleToggleExpansion = React.useCallback(() => setForceTextExpanded(on => !on), []);
return {
text,
isTextCollapsed,
forceTextExpanded,
handleToggleExpansion,
};
}
export function useAutoBlocksMemo(text: string, forceCodeWithTitle: string | undefined, forceMarkdown: boolean, forceSanityTextDiffs: SanityTextDiff[] | undefined): RenderBlockInputs {
// state - previous blocks, to stabilize objects
const prevBlocksRef = React.useRef<RenderBlockInputs>([]);
return React.useMemo(() => {
// follow outside direction, or activate the auto-splitter based on content
const newBlocks: RenderBlockInputs = [];
if (forceCodeWithTitle !== undefined)
newBlocks.push({ bkt: 'code-bk', title: forceCodeWithTitle, code: text, isPartial: false });
else if (forceMarkdown)
newBlocks.push({ bkt: 'md-bk', content: text });
else if (forceSanityTextDiffs && forceSanityTextDiffs.length >= 1)
newBlocks.push({ bkt: 'txt-diffs-bk', sanityTextDiffs: forceSanityTextDiffs });
else
newBlocks.push(...parseBlocksFromText(text));
// recycle the previous blocks if they are the same, for stable references to React
const recycledBlocks: RenderBlockInputs = [];
for (let i = 0; i < newBlocks.length; i++) {
const newBlock = newBlocks[i];
const prevBlock = prevBlocksRef.current[i] ?? undefined;
// Check if the new block can be replaced by the previous block to maintain reference stability
if (prevBlock && shallowEquals(prevBlock, newBlock)) {
recycledBlocks.push(prevBlock);
} else {
// Once a block doesn't match, we use the new blocks from this point forward.
recycledBlocks.push(...newBlocks.slice(i));
break;
}
}
// Update prevBlocksRef with the current blocks for the next render
prevBlocksRef.current = recycledBlocks;
return recycledBlocks;
}, [forceCodeWithTitle, forceMarkdown, forceSanityTextDiffs, text]);
}