diff --git a/packages/trees/src/components/OverflowText.tsx b/packages/trees/src/components/OverflowText.tsx new file mode 100644 index 000000000..29234fd74 --- /dev/null +++ b/packages/trees/src/components/OverflowText.tsx @@ -0,0 +1,389 @@ +/** @jsxImportSource preact */ + +import type { ComponentChildren, CSSProperties } from 'preact'; + +type PropsWithChildren = T & { + children?: ComponentChildren; +}; + +export type CSSPropertiesWithVars = CSSProperties & { + [key: `--${string}`]: string | number | undefined; +}; + +export interface MarkerProps extends PropsWithChildren {} + +export type TruncateMode = 'truncate' | 'fruncate'; + +export interface OverflowTextProps extends PropsWithChildren { + mode?: TruncateMode; + style?: Omit; + className?: string; + marker?: ComponentChildren | ((props: MarkerProps) => ComponentChildren); + variant?: 'default' | 'fade'; +} + +export type MiddleTruncateProps = Omit & + AllowableContentGroups & { + minimumLength?: number; + priority?: 'start' | 'end' | 'equal'; + split?: + | 'center' + | 'extension' + | 'leaf-path' + | number + | SplitOffset + | CustomSplitFn; + }; + +export type MiddleTruncateFilteredProps = Pick< + MiddleTruncateProps, + 'priority' | 'variant' +> & { splitIndex?: number; splitOffset?: number }; + +export type CustomSplitFn = ( + contents: string, + props?: MiddleTruncateFilteredProps +) => [string, string]; +export type SplitOffsetType = 'last' | 'first'; +export type SplitOffset = [SplitOffsetType, number]; + +type AllowableContentGroups = + | { + children?: never; + contents: [ComponentChildren, ComponentChildren]; + } + | { + contents?: never; + children: string; + }; + +// Split the contents into two equal segments +export const splitCenter: CustomSplitFn = (contents) => { + if (contents.length < 2) { + return [contents, '']; + } + const splitIndex = Math.ceil(contents.length / 2); + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +// Find the last dot in the contents and split a that index +export const splitExtension: CustomSplitFn = (contents) => { + if (contents.length < 4) { + return [contents, '']; + } + const lastDotIndex = contents.lastIndexOf('.'); + const extensionIndex = lastDotIndex + 1; + const impliedExtensionLength = contents.length - extensionIndex; + const maxExtensionLength = 10; + const isTooLong = impliedExtensionLength > maxExtensionLength; + + const splitIndex = + extensionIndex >= 1 && !isTooLong + ? extensionIndex + : Math.ceil(contents.length / 2); + + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +export const splitLeafPath: CustomSplitFn = (contents) => { + if (contents.length < 4) { + return [contents, '']; + } + const lastSlashIndex = contents.lastIndexOf('/'); + const leafPathIndex = lastSlashIndex + 1; + const impliedLeafPathLength = contents.length - leafPathIndex; + const maxLeafPathLength = 25; + const isTooLong = impliedLeafPathLength > maxLeafPathLength; + const splitIndex = + leafPathIndex >= 1 && !isTooLong + ? leafPathIndex + : Math.ceil(contents.length / 2); + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +export const splitByIndex: CustomSplitFn = (contents, { splitIndex } = {}) => { + if (typeof splitIndex !== 'number') { + const centerIndex = Math.ceil(contents.length / 2); + return [contents.slice(0, centerIndex), contents.slice(centerIndex)]; + } + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +export const splitLast: CustomSplitFn = ( + contents: string, + { splitOffset } = {} +) => { + // fall back to center split if the offset is not valid + if ( + typeof splitOffset !== 'number' || + splitOffset <= 0 || + splitOffset >= contents.length + ) { + const centerIndex = Math.ceil(contents.length / 2); + return [contents.slice(0, centerIndex), contents.slice(centerIndex)]; + } + + const splitIndex = contents.length - splitOffset; + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +export const splitFirst: CustomSplitFn = ( + contents: string, + { splitOffset } = {} +) => { + // fall back to center split if the offset is not valid + if ( + typeof splitOffset !== 'number' || + splitOffset <= 0 || + splitOffset >= contents.length + ) { + const centerIndex = Math.ceil(contents.length / 2); + return [contents.slice(0, centerIndex), contents.slice(centerIndex)]; + } + + const splitIndex = splitOffset; + return [contents.slice(0, splitIndex), contents.slice(splitIndex)]; +}; + +function OverflowMarker({ + children, + marker, + variant = 'default', +}: OverflowTextProps) { + 'use no memo'; + const isFadeVariant = variant === 'fade'; + return ( +
+
+ {typeof marker === 'function' ? ( + marker({ children }) + ) : isFadeVariant ? ( + + ) : ( + marker + )} +
+
+ ); +} + +function OverflowContent(options: OverflowTextProps) { + 'use no memo'; + const { mode, children } = options; + + // The inner span wrapper here is only needed to implement + // the right aligned internals for fruncate + return ( +
+
+ {mode === 'fruncate' ? {children} : children} +
+
+ {mode === 'fruncate' ? {children} : children} +
+
+ ); +} + +export function OverflowText({ + children, + mode = 'truncate', + marker = '…', + variant = 'default', + ...props +}: OverflowTextProps) { + 'use no memo'; + const contentNode = ( + + {children} + + ); + const markerNode = ( + + ); + const fillNode =
; + + return ( +
+
+ {mode === 'truncate' + ? [contentNode, markerNode] + : [markerNode, contentNode, fillNode]} +
+
+ ); +} + +export function Truncate({ + children, + ...props +}: Omit) { + 'use no memo'; + return ( + + {children} + + ); +} + +export function Fruncate({ + children, + ...props +}: Omit) { + 'use no memo'; + return ( + + {children} + + ); +} + +export function MiddleTruncate({ + children, + contents, + priority = 'end', + split = 'center', + minimumLength = 12, + className, + style, + ...props +}: MiddleTruncateProps) { + 'use no memo'; + let firstSegment: ComponentChildren | null = null; + let secondSegment: ComponentChildren | null = null; + if (Array.isArray(contents)) { + if (contents.length !== 2) { + console.error('MiddleTruncate: contents must be an array of two items'); + return null; + } + firstSegment = {contents[0]}; + secondSegment = {contents[1]}; + } else { + // TODO: figure out how to support ReactNode children in the future + if (typeof children !== 'string') { + console.error('MiddleTruncate: children must be a string'); + return null; + } + + // In case styling relies on the presence of the component, we will return a div + if (children.length === 0) { + return
; + } + + // If the minimumLength is not met, we will still truncate the text, + // but we will not split it into two segments. + if (children.length < minimumLength) { + if (priority === 'end') { + return ( + + {children} + + ); + } else { + // 'start' and 'equal' both fall back to standard end-clipping. + return ( + + {children} + + ); + } + } + + let splitFn: CustomSplitFn | null = null; + let splitIndex: number | null = null; + let splitOffset: number | null = null; + + // A little ugly, but want to make it fast? + if (typeof split === 'string') { + if (split === 'center') { + splitFn = splitCenter; + } else if (split === 'extension') { + splitFn = splitExtension; + } else if (split === 'leaf-path') { + splitFn = splitLeafPath; + } + } else if (typeof split === 'number') { + splitFn = splitByIndex; + splitIndex = split; + } else if (Array.isArray(split)) { + const [offsetType, offsetValue] = split; + splitOffset = offsetValue; + if (offsetType === 'last') { + splitFn = splitLast; + } else if (offsetType === 'first') { + splitFn = splitFirst; + } + } else if (typeof split === 'function') { + splitFn = split; + } + + // If we can't determine the split function, use the center split + splitFn ??= splitCenter; + + const [firstHalfMessage, secondHalfMessage] = splitFn(children, { + priority, + variant: props.variant, + splitIndex: typeof splitIndex === 'number' ? splitIndex : undefined, + splitOffset: typeof splitOffset === 'number' ? splitOffset : undefined, + }); + + const firstIsLarger = firstHalfMessage.length >= secondHalfMessage.length; + const secondIsLarger = !firstIsLarger; + + const firstCanBeSimple = priority === 'equal' && secondIsLarger; + const secondCanBeSimple = priority === 'equal' && firstIsLarger; + + const firstPropOverrides: Partial = {}; + const secondPropOverrides: Partial = {}; + + if (firstCanBeSimple) { + firstPropOverrides.marker = ''; + } + if (secondCanBeSimple) { + secondPropOverrides.marker = ''; + } + + firstSegment = ( + + {firstHalfMessage} + + ); + secondSegment = ( + + {secondHalfMessage} + + ); + } + + return ( +
+
+ {firstSegment} +
+
+ {secondSegment} +
+
+ ); +} diff --git a/packages/trees/src/components/Root.tsx b/packages/trees/src/components/Root.tsx index c4fece4f2..0e5ac1a76 100644 --- a/packages/trees/src/components/Root.tsx +++ b/packages/trees/src/components/Root.tsx @@ -62,6 +62,7 @@ import type { ChildrenSortOption } from '../utils/sortChildren'; import { useContextMenuController } from './hooks/useContextMenuController'; import { useTree } from './hooks/useTree'; import { Icon } from './Icon'; +import { MiddleTruncate, Truncate } from './OverflowText'; import { VirtualizedList } from './VirtualizedList'; export interface FileTreeRootProps { @@ -139,7 +140,9 @@ function FlattenedDirectoryName({ const isLast = index === segments.length - 1; return ( - {label} + + {label} + {!isLast ? ' / ' : ''} ); @@ -294,6 +297,7 @@ function TreeItemInner({