diff --git a/app/src/components/DialogEditor/DialogEditor.tsx b/app/src/components/DialogEditor/DialogEditor.tsx index 0eb123b..fa391ae 100644 --- a/app/src/components/DialogEditor/DialogEditor.tsx +++ b/app/src/components/DialogEditor/DialogEditor.tsx @@ -381,6 +381,10 @@ function DialogEditor({ conversationAsset, rebuild, expandAll }: { conversationA setIsContextMenuVisible(false); }, [rebuild]); + useEffect(() => { + resize(); + }, [dialogEditorSize?.width, dialogEditorSize?.height, zoomLevel]); + // On window size change useEffect(() => { setTimeout(() => { diff --git a/app/src/components/FileTree/FileTree.css b/app/src/components/FileTree/FileTree.css index d1bb72d..7857d8b 100644 --- a/app/src/components/FileTree/FileTree.css +++ b/app/src/components/FileTree/FileTree.css @@ -1,18 +1,26 @@ @import '../../css/constants.css'; .file-tree { + display: flex; + flex-direction: column; height: 100%; + min-width: 0; + min-height: 0; &__title { + flex: 0 0 auto; font-weight: bold; color: $textColourSecondary; } &__tree { - height: 97%; + flex: 1 1 auto; + min-width: 0; + min-height: 0; .file-tree__scroll { - height: calc(100% - 1px); + width: 100%; + height: 100%; overflow: auto; } @@ -34,9 +42,15 @@ } .ant-tree { + display: inline-block; + min-width: 100%; padding-right: $smallMediumSpace; } + .ant-tree-treenode { + white-space: nowrap; + } + .ant-tree .ant-tree-node-content-wrapper { margin-left: -4px; padding-inline: 2px 4px; diff --git a/app/src/components/Split/Split.tsx b/app/src/components/Split/Split.tsx index 450820d..c6ab9d2 100644 --- a/app/src/components/Split/Split.tsx +++ b/app/src/components/Split/Split.tsx @@ -1,15 +1,31 @@ import { Children, isValidElement, useRef, useState } from 'react'; -import type { CSSProperties, PointerEvent, ReactNode } from 'react'; +import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent, ReactNode } from 'react'; import classnames from 'classnames'; import './Split.css'; +export type SplitHandleDoubleClickContext = { + containerElement: HTMLDivElement; + containerSize: number; + event: ReactMouseEvent; + maxPrimarySize: number; + minPrimarySize: number; + orientation: 'vertical' | 'horizontal'; + primarySize: number; + setPrimarySize: (nextPrimarySize: number) => void; +}; + type Props = { children: ReactNode; + className?: string; horizontal?: boolean; + onHandleDoubleClick?: (context: SplitHandleDoubleClickContext) => void; + primaryCollapsed?: boolean; + primaryCollapsedSize?: string; initialPrimarySize?: string; minPrimarySize?: string; minSecondarySize?: string; + orientation?: 'vertical' | 'horizontal'; }; function parseSize(size: string | undefined, fallback: number): number { @@ -25,16 +41,30 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -export function Split({ children, horizontal = false, initialPrimarySize = '50%', minPrimarySize = '0%', minSecondarySize = '0%' }: Props) { +export function Split({ + children, + className, + horizontal = false, + onHandleDoubleClick, + primaryCollapsed = false, + primaryCollapsedSize = '40px', + initialPrimarySize = '50%', + minPrimarySize = '0%', + minSecondarySize = '0%', + orientation, +}: Props) { const containerRef = useRef(null); const [primarySize, setPrimarySize] = useState(parseSize(initialPrimarySize, 50)); const panes = Children.toArray(children).filter(isValidElement); const [primaryPane, secondaryPane] = panes; - const canResize = panes.length > 1; + const hasSecondaryPane = panes.length > 1; + const canResize = hasSecondaryPane && !primaryCollapsed; + const resolvedOrientation = orientation || (horizontal ? 'horizontal' : 'vertical'); + const isHorizontal = resolvedOrientation === 'horizontal'; const minPrimary = parseSize(minPrimarySize, 0); const minSecondary = parseSize(minSecondarySize, 0); const maxPrimary = 100 - minSecondary; - const clampedPrimarySize = canResize ? clamp(primarySize, minPrimary, maxPrimary) : 100; + const clampedPrimarySize = hasSecondaryPane ? clamp(primarySize, minPrimary, maxPrimary) : 100; const beginResize = (event: PointerEvent) => { if (!canResize || containerRef.current == null) return; @@ -46,8 +76,8 @@ export function Split({ children, horizontal = false, initialPrimarySize = '50%' if (containerRef.current == null) return; const bounds = containerRef.current.getBoundingClientRect(); - const position = horizontal ? moveEvent.clientY - bounds.top : moveEvent.clientX - bounds.left; - const availableSize = horizontal ? bounds.height : bounds.width; + const position = isHorizontal ? moveEvent.clientY - bounds.top : moveEvent.clientX - bounds.left; + const availableSize = isHorizontal ? bounds.height : bounds.width; if (availableSize <= 0) return; setPrimarySize(clamp((position / availableSize) * 100, minPrimary, maxPrimary)); @@ -63,16 +93,44 @@ export function Split({ children, horizontal = false, initialPrimarySize = '50%' window.addEventListener('pointerup', handleUp); }; - const primaryStyle = { '--split-primary-size': `${clampedPrimarySize}%` } as CSSProperties; + const handleDoubleClick = (event: ReactMouseEvent) => { + if (!canResize || onHandleDoubleClick == null || containerRef.current == null) return; + + const bounds = containerRef.current.getBoundingClientRect(); + const containerSize = isHorizontal ? bounds.height : bounds.width; + if (containerSize <= 0) return; + + onHandleDoubleClick({ + containerElement: containerRef.current, + containerSize, + event, + maxPrimarySize: maxPrimary, + minPrimarySize: minPrimary, + orientation: resolvedOrientation, + primarySize: clampedPrimarySize, + setPrimarySize: (nextPrimarySize: number) => { + setPrimarySize(clamp(nextPrimarySize, minPrimary, maxPrimary)); + }, + }); + }; + + const primaryStyle = { + '--split-primary-size': primaryCollapsed ? primaryCollapsedSize : `${clampedPrimarySize}%`, + } as CSSProperties; return ( -
+
{primaryPane}
- {canResize && ( + {hasSecondaryPane && ( <> -
+ {canResize &&
}
{secondaryPane}
)} diff --git a/app/src/components/Split/index.ts b/app/src/components/Split/index.ts index a5dae13..9a1151e 100644 --- a/app/src/components/Split/index.ts +++ b/app/src/components/Split/index.ts @@ -1 +1,2 @@ export { Split } from './Split'; +export type { SplitHandleDoubleClickContext } from './Split'; diff --git a/app/src/containers/ConversationEditor/ConversationEditor.tsx b/app/src/containers/ConversationEditor/ConversationEditor.tsx index 399def0..711b77d 100644 --- a/app/src/containers/ConversationEditor/ConversationEditor.tsx +++ b/app/src/containers/ConversationEditor/ConversationEditor.tsx @@ -208,7 +208,7 @@ function ConversationEditor({ conversationAsset }: Props) {
- + {activeNode && ( diff --git a/app/src/containers/ConversationTree/ConversationTree.css b/app/src/containers/ConversationTree/ConversationTree.css index 94bb538..7febe64 100644 --- a/app/src/containers/ConversationTree/ConversationTree.css +++ b/app/src/containers/ConversationTree/ConversationTree.css @@ -1,8 +1,4 @@ .conversation-tree { height: 100%; min-width: 125px; - - @media screen and (min-width: 1px) and (max-width: 1180px) { - display: none; - } } diff --git a/app/src/containers/ConversationTree/ConversationTree.tsx b/app/src/containers/ConversationTree/ConversationTree.tsx index de8d006..9ca836a 100644 --- a/app/src/containers/ConversationTree/ConversationTree.tsx +++ b/app/src/containers/ConversationTree/ConversationTree.tsx @@ -12,6 +12,10 @@ import { updateConversation } from 'services/api'; import './ConversationTree.css'; +type Props = { + showTitle?: boolean; +}; + function remapConversationData(conversationAssets: Map) { return sortBy( Array.from(conversationAssets.values()).map((asset) => ({ @@ -22,7 +26,7 @@ function remapConversationData(conversationAssets: Map('data'); const modalStore = useStore('modal'); @@ -70,7 +74,7 @@ function ConversationTree() {
.split__pane--primary { + box-shadow: inset -1px 0 0 $borderTertiary; } -} \ No newline at end of file +} diff --git a/app/src/containers/Conversations/Conversations.tsx b/app/src/containers/Conversations/Conversations.tsx index 3866cee..9adaa7f 100644 --- a/app/src/containers/Conversations/Conversations.tsx +++ b/app/src/containers/Conversations/Conversations.tsx @@ -1,11 +1,16 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { observer } from 'mobx-react'; +import { Button } from 'antd'; +import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; +import classnames from 'classnames'; import type { DataStore } from 'stores/dataStore/data-store'; import type { DefStore } from 'stores/defStore/def-store'; import { getConversations, getDefinitions } from 'services/api'; import { useStore } from 'hooks/useStore'; +import { Split } from 'components/Split'; +import type { SplitHandleDoubleClickContext } from 'components/Split'; import { ConversationTree } from '../ConversationTree'; import { ConversationEditor } from '../ConversationEditor'; @@ -16,6 +21,9 @@ import './Conversations.css'; const Conversations = () => { const dataStore = useStore('data'); const defStore = useStore('def'); + const conversationTreeDockRef = useRef(null); + const conversationTreeContentRef = useRef(null); + const [isConversationListOpen, setIsConversationListOpen] = useState(true); const { conversationAssets, activeConversationAsset } = dataStore; const { definitionCount } = defStore; @@ -29,13 +37,93 @@ const Conversations = () => { }, [conversationAssets.size, definitionCount]); const mainView = activeConversationAsset ? : ; + const conversationListToggleLabel = isConversationListOpen ? 'Close conversation list' : 'Open conversation list'; + + const fitConversationListToContent = ({ containerSize, setPrimarySize }: SplitHandleDoubleClickContext) => { + const treeDock = conversationTreeDockRef.current; + const treeContent = conversationTreeContentRef.current; + if (treeDock == null || treeContent == null) return; + + const treeElement = treeContent.querySelector('.ant-tree'); + if (treeElement == null) return; + + const measuredTree = treeElement.cloneNode(true) as HTMLElement; + measuredTree.style.position = 'fixed'; + measuredTree.style.top = '0'; + measuredTree.style.left = '-10000px'; + measuredTree.style.width = 'max-content'; + measuredTree.style.minWidth = '0'; + measuredTree.style.height = 'auto'; + measuredTree.style.visibility = 'hidden'; + measuredTree.style.pointerEvents = 'none'; + + document.body.appendChild(measuredTree); + const contentWidth = Math.ceil(Math.max(measuredTree.scrollWidth, measuredTree.getBoundingClientRect().width)); + document.body.removeChild(measuredTree); + + if (contentWidth <= 0) return; + + const dockStyle = window.getComputedStyle(treeDock); + const desiredWidth = + contentWidth + + Number.parseFloat(dockStyle.paddingLeft || '0') + + Number.parseFloat(dockStyle.paddingRight || '0'); + + setPrimarySize((desiredWidth / containerSize) * 100); + setIsConversationListOpen(true); + }; + + useEffect(() => { + const mediaQuery = window.matchMedia('(max-width: 1180px)'); + const updateConversationListMode = () => { + setIsConversationListOpen(!mediaQuery.matches); + }; + + updateConversationListMode(); + mediaQuery.addEventListener('change', updateConversationListMode); + + return () => mediaQuery.removeEventListener('change', updateConversationListMode); + }, []); return (
-
- -
-
{mainView}
+ +
+
+
+ {isConversationListOpen &&
Conversations
} +
+ {isConversationListOpen && ( +
+ +
+ )} +
+
+
{mainView}
+
); };