From ddebbdd5252bad12fe5dc5f8bfc0f325102156b4 Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Thu, 11 Jun 2026 20:06:59 -0700 Subject: [PATCH 1/6] feat(layout): introduce layout components with aside and floating states --- packages/components/src/index.ts | 11 + packages/components/src/layout/Layout.vue | 384 ++++++++++++++++++ .../src/layout/LayoutAsideToggle.vue | 55 +++ packages/components/src/layout/LayoutMain.vue | 95 +++++ .../src/layout/components/AsideContent.vue | 170 ++++++++ .../layout/components/AsideResizeTrigger.vue | 120 ++++++ .../components/FloatingResizeTrigger.vue | 191 +++++++++ .../layout/composables/createLayoutContext.ts | 223 ++++++++++ .../composables/useLayoutAsideInteractions.ts | 214 ++++++++++ .../layout/composables/useLayoutContext.ts | 14 + .../composables/useLayoutFloatingSurface.ts | 336 +++++++++++++++ .../composables/useLayoutMainScrollbar.ts | 242 +++++++++++ .../src/layout/composables/useLayoutPanel.ts | 51 +++ .../composables/useLayoutRenderState.ts | 88 ++++ .../layout/composables/useLayoutRootState.ts | 216 ++++++++++ packages/components/src/layout/index.ts | 54 +++ packages/components/src/layout/index.type.ts | 119 ++++++ .../components/src/layout/internal.type.ts | 96 +++++ .../src/layout/utils/asideDefaults.ts | 28 ++ .../components/src/layout/utils/cssLength.ts | 74 ++++ .../src/layout/utils/domInteraction.ts | 21 + packages/components/src/layout/utils/math.ts | 3 + packages/components/src/layout/utils/slots.ts | 43 ++ .../src/layout/utils/surfaceGeometry.ts | 381 +++++++++++++++++ .../src/layout/utils/surfaceResize.ts | 61 +++ .../src/shared/composables/index.ts | 1 + .../composables/useControllableState.ts | 36 ++ .../src/styles/components/index.css | 1 + .../src/styles/components/layout.less | 155 +++++++ 29 files changed, 3483 insertions(+) create mode 100644 packages/components/src/layout/Layout.vue create mode 100644 packages/components/src/layout/LayoutAsideToggle.vue create mode 100644 packages/components/src/layout/LayoutMain.vue create mode 100644 packages/components/src/layout/components/AsideContent.vue create mode 100644 packages/components/src/layout/components/AsideResizeTrigger.vue create mode 100644 packages/components/src/layout/components/FloatingResizeTrigger.vue create mode 100644 packages/components/src/layout/composables/createLayoutContext.ts create mode 100644 packages/components/src/layout/composables/useLayoutAsideInteractions.ts create mode 100644 packages/components/src/layout/composables/useLayoutContext.ts create mode 100644 packages/components/src/layout/composables/useLayoutFloatingSurface.ts create mode 100644 packages/components/src/layout/composables/useLayoutMainScrollbar.ts create mode 100644 packages/components/src/layout/composables/useLayoutPanel.ts create mode 100644 packages/components/src/layout/composables/useLayoutRenderState.ts create mode 100644 packages/components/src/layout/composables/useLayoutRootState.ts create mode 100644 packages/components/src/layout/index.ts create mode 100644 packages/components/src/layout/index.type.ts create mode 100644 packages/components/src/layout/internal.type.ts create mode 100644 packages/components/src/layout/utils/asideDefaults.ts create mode 100644 packages/components/src/layout/utils/cssLength.ts create mode 100644 packages/components/src/layout/utils/domInteraction.ts create mode 100644 packages/components/src/layout/utils/math.ts create mode 100644 packages/components/src/layout/utils/slots.ts create mode 100644 packages/components/src/layout/utils/surfaceGeometry.ts create mode 100644 packages/components/src/layout/utils/surfaceResize.ts create mode 100644 packages/components/src/shared/composables/useControllableState.ts create mode 100644 packages/components/src/styles/components/layout.less diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index ce670e579..0116e1fa3 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -12,6 +12,7 @@ import DropdownMenu from './dropdown-menu' import Feedback from './feedback' import History from './history' import IconButton from './icon-button' +import { Layout, LayoutAsideToggle, LayoutMain } from './layout' import { Prompt, Prompts } from './prompts' import Sender from './sender' import SenderCompat from './sender-compat' @@ -43,6 +44,7 @@ export * from './dropdown-menu/index.type' export * from './feedback/index.type' export * from './history/index.type' export * from './icon-button/index.type' +export * from './layout/index.type' export * from './prompts/index.type' export * from './sender/index.type' export * from './sender-actions/index.type' @@ -81,6 +83,9 @@ const components = [ Feedback, History, IconButton, + Layout, + LayoutMain, + LayoutAsideToggle, Prompt, Prompts, Sender, @@ -135,6 +140,12 @@ export { History as TrHistory, IconButton, IconButton as TrIconButton, + Layout, + Layout as TrLayout, + LayoutMain, + LayoutMain as TrLayoutMain, + LayoutAsideToggle, + LayoutAsideToggle as TrLayoutAsideToggle, Prompt, Prompt as TrPrompt, Prompts, diff --git a/packages/components/src/layout/Layout.vue b/packages/components/src/layout/Layout.vue new file mode 100644 index 000000000..6676fdd8b --- /dev/null +++ b/packages/components/src/layout/Layout.vue @@ -0,0 +1,384 @@ + + + + + diff --git a/packages/components/src/layout/LayoutAsideToggle.vue b/packages/components/src/layout/LayoutAsideToggle.vue new file mode 100644 index 000000000..c0b32146a --- /dev/null +++ b/packages/components/src/layout/LayoutAsideToggle.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/components/src/layout/LayoutMain.vue b/packages/components/src/layout/LayoutMain.vue new file mode 100644 index 000000000..52083945f --- /dev/null +++ b/packages/components/src/layout/LayoutMain.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/components/src/layout/components/AsideContent.vue b/packages/components/src/layout/components/AsideContent.vue new file mode 100644 index 000000000..68f084c51 --- /dev/null +++ b/packages/components/src/layout/components/AsideContent.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/packages/components/src/layout/components/AsideResizeTrigger.vue b/packages/components/src/layout/components/AsideResizeTrigger.vue new file mode 100644 index 000000000..661f217dc --- /dev/null +++ b/packages/components/src/layout/components/AsideResizeTrigger.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/packages/components/src/layout/components/FloatingResizeTrigger.vue b/packages/components/src/layout/components/FloatingResizeTrigger.vue new file mode 100644 index 000000000..5b8ee93ef --- /dev/null +++ b/packages/components/src/layout/components/FloatingResizeTrigger.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/packages/components/src/layout/composables/createLayoutContext.ts b/packages/components/src/layout/composables/createLayoutContext.ts new file mode 100644 index 000000000..dd6f59bc5 --- /dev/null +++ b/packages/components/src/layout/composables/createLayoutContext.ts @@ -0,0 +1,223 @@ +import { toValue } from 'vue' +import type { LayoutPlacement } from '../index.type' +import type { LayoutContext, LayoutPanelApi, LayoutPanelState } from '../internal.type' +import { getDefaultAsideMaxWidth, getDefaultAsideMinWidth } from '../utils/asideDefaults' + +function createDefaultPanelState(placement: LayoutPlacement): LayoutPanelState { + const minWidth = getDefaultAsideMinWidth(placement) + const maxWidth = getDefaultAsideMaxWidth(placement) + + return { + placement, + layoutMode: 'dock', + isOpen: false, + width: undefined, + collapsedWidth: undefined, + collapseEffect: 'overlay', + minWidth, + maxWidth, + resizable: false, + setOpen: () => {}, + setWidth: () => {}, + } +} + +const DEFAULT_PANEL_STATE = { + left: createDefaultPanelState('left'), + right: createDefaultPanelState('right'), +} as const + +export function createLayoutContext(leftState?: LayoutPanelState, rightState?: LayoutPanelState): LayoutContext { + const panels = {} as Record + + function getSiblingPanel(placement: LayoutPlacement): LayoutPanelApi { + return panels[placement === 'left' ? 'right' : 'left'] + } + + function isVisibleDrawer(panel: LayoutPanelApi): boolean { + return panel.isDrawer && panel.isOpen + } + + function createPanelApi(placement: LayoutPlacement, panelState: LayoutPanelState | undefined): LayoutPanelApi { + const source = panelState ?? DEFAULT_PANEL_STATE[placement] + const isRegistered = panelState !== undefined + const defaultMinWidth = getDefaultAsideMinWidth(placement) + const defaultMaxWidth = getDefaultAsideMaxWidth(placement) + + function getLayoutMode(): LayoutPanelApi['layoutMode'] { + return toValue(source.layoutMode) + } + + function getIsOpen(): boolean { + return toValue(source.isOpen) + } + + function getWidth(): number | undefined { + return toValue(source.width) + } + + function getCollapsedWidth(): number | undefined { + return toValue(source.collapsedWidth) + } + + function getCollapseEffect(): LayoutPanelApi['collapseEffect'] { + return toValue(source.collapseEffect) + } + + function getMinWidth(): number { + return toValue(source.minWidth) ?? defaultMinWidth + } + + function getMaxWidth(): number { + return toValue(source.maxWidth) ?? defaultMaxWidth + } + + function getResizable(): boolean { + return toValue(source.resizable) + } + + function getIsDock(): boolean { + return getLayoutMode() === 'dock' + } + + function getIsDrawer(): boolean { + return getLayoutMode() === 'drawer' + } + + function getIsRail(): boolean { + return getIsDock() && !getIsOpen() && (getCollapsedWidth() ?? 0) > 0 + } + + function getIsHidden(): boolean { + return !getIsOpen() && (getIsDrawer() || !getIsRail()) + } + + function getCanResize(): boolean { + return getIsDock() && getIsOpen() && getResizable() + } + + function open(): void { + if (!isRegistered) { + return + } + + if (getIsDrawer()) { + const sibling = getSiblingPanel(placement) + if (sibling.isDrawer && sibling.isOpen) { + sibling.close() + } + } + + source.setOpen(true) + } + + function close(): void { + if (!isRegistered) { + return + } + + source.setOpen(false) + } + + function setOpen(nextOpen: boolean): void { + if (nextOpen) { + open() + return + } + + close() + } + + function toggle(): void { + if (toValue(source.isOpen)) { + close() + return + } + + open() + } + + function setWidth(nextWidth: number): void { + if (!isRegistered) { + return + } + + source.setWidth(nextWidth) + } + + return { + get placement() { + return placement + }, + get isRegistered() { + return isRegistered + }, + get layoutMode() { + return getLayoutMode() + }, + get isOpen() { + return getIsOpen() + }, + get isDock() { + return getIsDock() + }, + get isDrawer() { + return getIsDrawer() + }, + get isRail() { + return getIsRail() + }, + get isHidden() { + return getIsHidden() + }, + get canResize() { + return getCanResize() + }, + get width() { + return getWidth() + }, + get collapsedWidth() { + return getCollapsedWidth() + }, + get collapseEffect() { + return getCollapseEffect() + }, + get minWidth() { + return getMinWidth() + }, + get maxWidth() { + return getMaxWidth() + }, + get resizable() { + return getResizable() + }, + open, + close, + toggle, + setOpen, + setWidth, + } + } + + panels.left = createPanelApi('left', leftState) + panels.right = createPanelApi('right', rightState) + + function closeDrawers(): void { + if (isVisibleDrawer(panels.left)) { + panels.left.close() + } + + if (isVisibleDrawer(panels.right)) { + panels.right.close() + } + } + + return { + left: panels.left, + right: panels.right, + get isDrawerVisible() { + return isVisibleDrawer(panels.left) || isVisibleDrawer(panels.right) + }, + closeDrawers, + } +} diff --git a/packages/components/src/layout/composables/useLayoutAsideInteractions.ts b/packages/components/src/layout/composables/useLayoutAsideInteractions.ts new file mode 100644 index 000000000..3df47c13a --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutAsideInteractions.ts @@ -0,0 +1,214 @@ +import { useEventListener } from '@vueuse/core' +import { computed, onBeforeUnmount, shallowRef, toValue, type MaybeRefOrGetter, type Ref } from 'vue' +import type { LayoutAsideResizeEventDetail, LayoutPlacement } from '../index.type' +import type { LayoutPanelApi } from '../internal.type' +import { resolveCssLengthToPx } from '../utils/cssLength' +import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction' +import { clamp } from '../utils/math' + +interface UseLayoutAsideInteractionsOptions { + rootRef: Ref + leftAsideRef: Ref + rightAsideRef: Ref + left: LayoutPanelApi + right: LayoutPanelApi + isDrawerVisible: MaybeRefOrGetter + closeDrawers: () => void + onResizeStart?: (detail: LayoutAsideResizeEventDetail) => void + onResize?: (detail: LayoutAsideResizeEventDetail) => void + onResizeEnd?: (detail: LayoutAsideResizeEventDetail) => void +} + +interface ResizeState { + pointerId: number + handleEl: HTMLElement + panel: LayoutPanelApi + placement: LayoutPlacement + startX: number + startWidth: number + currentWidth: number + minWidth: number + effectiveMax: number + pendingWidth: number | null + frameId: number | null + bodyState: BodyInteractionState +} + +function getDockedAsideWidth(panel: LayoutPanelApi, asideEl: HTMLElement | null | undefined): number { + if (!panel.isDock || panel.isHidden || !asideEl) { + return 0 + } + + return asideEl.getBoundingClientRect().width +} + +export function useLayoutAsideInteractions(options: UseLayoutAsideInteractionsOptions) { + const activeResize = shallowRef(null) + const isResizing = computed(() => activeResize.value !== null) + const draggingPlacement = computed(() => activeResize.value?.placement ?? null) + const pointerTarget = typeof window === 'undefined' ? undefined : window + const keyboardTarget = typeof window === 'undefined' ? undefined : window + + function scheduleWidth(nextWidth: number): void { + const state = activeResize.value + if (!state || nextWidth === state.currentWidth) { + return + } + + state.pendingWidth = nextWidth + state.currentWidth = nextWidth + + if (state.frameId !== null || typeof window === 'undefined') { + return + } + + state.frameId = window.requestAnimationFrame(() => { + const current = activeResize.value + if (!current) { + return + } + + current.frameId = null + + if (current.pendingWidth === null) { + return + } + + current.panel.setWidth(current.pendingWidth) + options.onResize?.({ + placement: current.placement, + width: current.pendingWidth, + }) + current.pendingWidth = null + }) + } + + function stopResize(pointerId?: number): void { + const state = activeResize.value + if (!state || (pointerId !== undefined && state.pointerId !== pointerId)) { + return + } + + if (state.frameId !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(state.frameId) + state.frameId = null + } + + if (state.pendingWidth !== null) { + state.panel.setWidth(state.pendingWidth) + options.onResize?.({ + placement: state.placement, + width: state.pendingWidth, + }) + state.pendingWidth = null + } + + if (state.handleEl.hasPointerCapture(state.pointerId)) { + state.handleEl.releasePointerCapture(state.pointerId) + } + + restoreBodyInteraction(state.handleEl.ownerDocument.body, state.bodyState) + + options.onResizeEnd?.({ + placement: state.placement, + width: state.currentWidth, + }) + + activeResize.value = null + } + + function startResize(panel: LayoutPanelApi, event: PointerEvent): void { + if (activeResize.value || !event.isPrimary || event.button !== 0 || !panel.canResize) { + return + } + + const rootEl = options.rootRef.value + const handleEl = event.currentTarget instanceof HTMLElement ? event.currentTarget : null + const asideEl = panel.placement === 'left' ? options.leftAsideRef.value : options.rightAsideRef.value + + if (!rootEl || !handleEl || !asideEl) { + return + } + + const oppositePanel = panel.placement === 'left' ? options.right : options.left + const oppositeAsideEl = panel.placement === 'left' ? options.rightAsideRef.value : options.leftAsideRef.value + const rootRect = rootEl.getBoundingClientRect() + const startWidth = asideEl.getBoundingClientRect().width + const maxWidth = panel.maxWidth + const minWidth = panel.minWidth + const mainMinWidth = resolveCssLengthToPx( + getComputedStyle(rootEl).getPropertyValue('--tr-layout-main-min-width').trim() || '320px', + rootEl, + 320, + ) + const oppositeDockWidth = getDockedAsideWidth(oppositePanel, oppositeAsideEl) + const effectiveMax = Math.max(minWidth, Math.min(maxWidth, rootRect.width - mainMinWidth - oppositeDockWidth)) + + event.preventDefault() + handleEl.setPointerCapture(event.pointerId) + + activeResize.value = { + pointerId: event.pointerId, + handleEl, + panel, + placement: panel.placement, + startX: event.clientX, + startWidth, + currentWidth: startWidth, + minWidth, + effectiveMax, + pendingWidth: null, + frameId: null, + bodyState: lockBodyInteraction(rootEl.ownerDocument.body, 'col-resize'), + } + + options.onResizeStart?.({ + placement: panel.placement, + width: startWidth, + }) + } + + useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => { + const state = activeResize.value + if (!state || event.pointerId !== state.pointerId) { + return + } + + const deltaX = event.clientX - state.startX + const rawWidth = state.placement === 'left' ? state.startWidth + deltaX : state.startWidth - deltaX + scheduleWidth(clamp(rawWidth, state.minWidth, state.effectiveMax)) + }) + + useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => { + stopResize(event.pointerId) + }) + + useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => { + stopResize(event.pointerId) + }) + + useEventListener(keyboardTarget, 'keydown', (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== 'Escape' || !toValue(options.isDrawerVisible)) { + return + } + + event.preventDefault() + event.stopPropagation() + options.closeDrawers() + }) + + onBeforeUnmount(() => { + stopResize() + }) + + return { + isResizing, + draggingPlacement, + leftHandleProps: { + onPointerdown: (event: PointerEvent) => startResize(options.left, event), + }, + rightHandleProps: { + onPointerdown: (event: PointerEvent) => startResize(options.right, event), + }, + } +} diff --git a/packages/components/src/layout/composables/useLayoutContext.ts b/packages/components/src/layout/composables/useLayoutContext.ts new file mode 100644 index 000000000..76c35315c --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutContext.ts @@ -0,0 +1,14 @@ +import { inject, provide, type InjectionKey } from 'vue' +import type { LayoutContext } from '../internal.type' +import { createLayoutContext } from './createLayoutContext' + +const layoutContextKey: InjectionKey = Symbol('LayoutContext') +const fallbackLayoutContext = createLayoutContext() + +export function provideLayoutContext(context: LayoutContext): void { + provide(layoutContextKey, context) +} + +export function useLayoutContext(): LayoutContext { + return inject(layoutContextKey, fallbackLayoutContext) +} diff --git a/packages/components/src/layout/composables/useLayoutFloatingSurface.ts b/packages/components/src/layout/composables/useLayoutFloatingSurface.ts new file mode 100644 index 000000000..cf6a86b4d --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutFloatingSurface.ts @@ -0,0 +1,336 @@ +import { useDraggable, useEventListener, useWindowSize } from '@vueuse/core' +import { + computed, + onBeforeUnmount, + shallowRef, + toValue, + watch, + type CSSProperties, + type MaybeRefOrGetter, + type Ref, +} from 'vue' +import type { + LayoutFloatingDragEventDetail, + LayoutFloatingResizeEventDetail, + LayoutFloatingResizeHandle, + LayoutFloatingState, + LayoutMode, +} from '../index.type' +import type { LayoutFloatingRect, LayoutResolvedFloating } from '../internal.type' +import { + areFloatingGeometryEqual, + clampFloatingRect, + clampFloatingRectByHandle, + DEFAULT_FLOATING_GAP, + DEFAULT_FLOATING_HEIGHT, + DEFAULT_FLOATING_TOP, + DEFAULT_FLOATING_WIDTH, + normalizeFloatingRect, + resolveFloatingSnapshot, + toCommittedFloatingState, +} from '../utils/surfaceGeometry' +import { resolveFloatingResizeRect } from '../utils/surfaceResize' +import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction' + +interface UseLayoutFloatingSurfaceOptions { + mode: MaybeRefOrGetter + floatingState: MaybeRefOrGetter + floating: MaybeRefOrGetter + commitFloatingState: (nextFloating: LayoutFloatingState) => void + initializeFloatingState: (nextFloating: LayoutFloatingState) => void + frameRef: Ref + dragHandleRef: Ref + onFloatingDragStart?: (detail: LayoutFloatingDragEventDetail) => void + onFloatingDrag?: (detail: LayoutFloatingDragEventDetail) => void + onFloatingDragEnd?: (detail: LayoutFloatingDragEventDetail) => void + onFloatingResizeStart?: (detail: LayoutFloatingResizeEventDetail) => void + onFloatingResize?: (detail: LayoutFloatingResizeEventDetail) => void + onFloatingResizeEnd?: (detail: LayoutFloatingResizeEventDetail) => void +} + +interface FloatingResizeState { + pointerId: number + handleEl: HTMLElement + handle: LayoutFloatingResizeHandle + currentRect: LayoutFloatingRect + lastPointerX: number + lastPointerY: number + bodyState: BodyInteractionState +} + +const RESIZE_HANDLES: LayoutFloatingResizeHandle[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] + +function resolveResizeCursor(handle: LayoutFloatingResizeHandle): string { + if (handle === 'n' || handle === 's') { + return 'ns-resize' + } + + if (handle === 'e' || handle === 'w') { + return 'ew-resize' + } + + if (handle === 'ne' || handle === 'sw') { + return 'nesw-resize' + } + + return 'nwse-resize' +} + +export function useLayoutFloatingSurface(options: UseLayoutFloatingSurfaceOptions) { + const activeResize = shallowRef(null) + const pointerTarget = typeof window === 'undefined' ? undefined : window + + const { width: viewportWidth, height: viewportHeight } = useWindowSize({ + type: 'visual', + initialWidth: DEFAULT_FLOATING_WIDTH + DEFAULT_FLOATING_GAP * 2, + initialHeight: DEFAULT_FLOATING_HEIGHT + DEFAULT_FLOATING_GAP * 2, + }) + + const mode = computed(() => toValue(options.mode)) + const isFloating = computed(() => mode.value === 'floating') + const isNormal = computed(() => mode.value === 'normal') + const floatingStateValue = computed(() => toValue(options.floatingState)) + const floatingValue = computed(() => toValue(options.floating)) + const floatingRect = computed(() => normalizeFloatingRect(floatingValue.value)) + const isFloatingDraggable = computed(() => floatingRect.value.draggable ?? true) + const isFloatingResizable = computed(() => floatingRect.value.resizable === true) + const isResizing = computed(() => activeResize.value !== null) + const activeResizeHandle = computed(() => activeResize.value?.handle ?? null) + const canDragFloating = computed(() => isFloating.value && isFloatingDraggable.value && !isResizing.value) + + function toFloatingState(rect: LayoutFloatingRect, options?: { normalizeCenter?: boolean }): LayoutFloatingState { + return toCommittedFloatingState( + resolveFloatingSnapshot(rect, floatingValue.value), + floatingStateValue.value, + options, + ) + } + + function toDragDetail( + rect: LayoutFloatingRect, + options?: { normalizeCenter?: boolean }, + ): LayoutFloatingDragEventDetail { + return toFloatingState(rect, options) + } + + function toResizeDetail( + handle: LayoutFloatingResizeHandle, + rect: LayoutFloatingRect, + options?: { normalizeCenter?: boolean }, + ): LayoutFloatingResizeEventDetail { + return { + ...toDragDetail(rect, options), + handle, + } + } + + function commitRect(nextRect: LayoutFloatingRect): LayoutFloatingRect { + const normalizedRect = clampFloatingRect(nextRect) + + if (areFloatingGeometryEqual(floatingRect.value, normalizedRect)) { + return normalizedRect + } + + options.commitFloatingState(toFloatingState(normalizedRect, { normalizeCenter: true })) + + return normalizedRect + } + + function syncFloatingRect(): void { + if (!isFloating.value || isDragging.value || isResizing.value) { + return + } + + if (!floatingStateValue.value) { + options.initializeFloatingState(toFloatingState(floatingRect.value)) + } + + const nextRect = commitRect(floatingRect.value) + x.value = nextRect.x + y.value = nextRect.y + } + + function applyDraggedPosition(nextX: number, nextY: number) { + const nextRect = commitRect({ + ...floatingRect.value, + x: nextX, + y: nextY, + }) + + x.value = nextRect.x + y.value = nextRect.y + + return nextRect + } + + const { x, y, isDragging } = useDraggable(options.frameRef, { + handle: options.dragHandleRef, + initialValue: { x: DEFAULT_FLOATING_GAP, y: DEFAULT_FLOATING_TOP }, + preventDefault: true, + buttons: [0], + disabled: computed(() => !canDragFloating.value), + onStart: () => { + if (!canDragFloating.value) { + return false + } + + const rect = floatingRect.value + x.value = rect.x + y.value = rect.y + options.onFloatingDragStart?.(toDragDetail(rect, { normalizeCenter: true })) + }, + onMove: (position) => { + const nextRect = applyDraggedPosition(position.x, position.y) + options.onFloatingDrag?.(toDragDetail(nextRect, { normalizeCenter: true })) + }, + onEnd: (position) => { + const nextRect = applyDraggedPosition(position.x, position.y) + options.onFloatingDragEnd?.(toDragDetail(nextRect, { normalizeCenter: true })) + }, + }) + + const canResizeFloating = computed(() => isFloating.value && isFloatingResizable.value && !isDragging.value) + + function stopResize(pointerId?: number): void { + const state = activeResize.value + + if (!state || (pointerId !== undefined && state.pointerId !== pointerId)) { + return + } + + if (state.handleEl.hasPointerCapture(state.pointerId)) { + state.handleEl.releasePointerCapture(state.pointerId) + } + + restoreBodyInteraction(state.handleEl.ownerDocument.body, state.bodyState) + options.onFloatingResizeEnd?.(toResizeDetail(state.handle, state.currentRect, { normalizeCenter: true })) + activeResize.value = null + } + + function startResize(handle: LayoutFloatingResizeHandle, event: PointerEvent): void { + if (activeResize.value || isDragging.value || !event.isPrimary || event.button !== 0 || !canResizeFloating.value) { + return + } + + const handleEl = event.currentTarget instanceof HTMLElement ? event.currentTarget : null + + if (!handleEl) { + return + } + + const startRect = floatingRect.value + + event.preventDefault() + handleEl.setPointerCapture(event.pointerId) + + activeResize.value = { + pointerId: event.pointerId, + handleEl, + handle, + currentRect: startRect, + lastPointerX: event.clientX, + lastPointerY: event.clientY, + bodyState: lockBodyInteraction(handleEl.ownerDocument.body, resolveResizeCursor(handle)), + } + + options.onFloatingResizeStart?.(toResizeDetail(handle, startRect, { normalizeCenter: true })) + } + + function applyResize(state: FloatingResizeState, pointerX: number, pointerY: number): void { + const nextRect = commitRect( + clampFloatingRectByHandle( + resolveFloatingResizeRect({ + handle: state.handle, + deltaX: pointerX - state.lastPointerX, + deltaY: pointerY - state.lastPointerY, + startRect: state.currentRect, + }), + state.handle, + ), + ) + + state.currentRect = nextRect + state.lastPointerX = pointerX + state.lastPointerY = pointerY + + options.onFloatingResize?.(toResizeDetail(state.handle, nextRect, { normalizeCenter: true })) + } + + useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => { + const state = activeResize.value + + if (!state || event.pointerId !== state.pointerId) { + return + } + + if (event.clientX === state.lastPointerX && event.clientY === state.lastPointerY) { + return + } + + applyResize(state, event.clientX, event.clientY) + }) + + useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => { + stopResize(event.pointerId) + }) + + useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => { + stopResize(event.pointerId) + }) + + onBeforeUnmount(() => { + stopResize() + }) + + watch( + [mode, floatingValue, viewportWidth, viewportHeight], + () => { + syncFloatingRect() + }, + { immediate: true }, + ) + + const frameClass = computed(() => ({ + 'tr-layout-frame--floating': isFloating.value, + 'tr-layout-frame--floating-dragging': isDragging.value, + 'tr-layout-frame--floating-resizing': isResizing.value, + })) + + const frameStyle = computed(() => { + if (isNormal.value) { + return {} + } + + return { + left: `${floatingRect.value.x}px`, + top: `${floatingRect.value.y}px`, + width: `${floatingRect.value.width}px`, + height: `${floatingRect.value.height}px`, + } + }) + + const dragBarClass = computed(() => ({ + 'tr-layout-frame__drag-bar--draggable': canDragFloating.value, + })) + + const resizeHandles = computed(() => { + if (!isFloating.value || !isFloatingResizable.value) { + return [] + } + + return RESIZE_HANDLES.map((handle) => ({ + handle, + active: activeResizeHandle.value === handle, + onPointerdown: (event: PointerEvent) => startResize(handle, event), + })) + }) + + return { + isFloating, + showDragBar: computed(() => isFloating.value), + frameClass, + frameStyle, + dragBarClass, + resizeHandles, + } +} diff --git a/packages/components/src/layout/composables/useLayoutMainScrollbar.ts b/packages/components/src/layout/composables/useLayoutMainScrollbar.ts new file mode 100644 index 000000000..656c6df8d --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutMainScrollbar.ts @@ -0,0 +1,242 @@ +import { useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core' +import { computed, onBeforeUnmount, shallowRef, watch, type CSSProperties, type Ref } from 'vue' +import { resolveCssLengthToPx } from '../utils/cssLength' +import { lockBodyInteraction, restoreBodyInteraction, type BodyInteractionState } from '../utils/domInteraction' +import { clamp } from '../utils/math' + +interface UseLayoutMainScrollbarOptions { + scrollHostRef: Ref +} + +interface ScrollMetrics { + clientHeight: number + scrollHeight: number + scrollTop: number + trackHeight: number + thumbHeight: number + thumbOffset: number + isScrollable: boolean +} + +interface ThumbDragState { + pointerId: number + startY: number + startScrollTop: number + bodyEl: HTMLBodyElement + bodyState: BodyInteractionState +} + +const MIN_THUMB_HEIGHT = 36 + +function createEmptyMetrics(): ScrollMetrics { + return { + clientHeight: 0, + scrollHeight: 0, + scrollTop: 0, + trackHeight: 0, + thumbHeight: 0, + thumbOffset: 0, + isScrollable: false, + } +} + +function resolveTrackHeight(scrollHost: HTMLElement, clientHeight: number): number { + const mainEl = scrollHost.closest('.tr-layout-main') + + if (!(mainEl instanceof HTMLElement)) { + return clientHeight + } + + const styles = window.getComputedStyle(mainEl) + const insetBlock = resolveCssLengthToPx( + styles.getPropertyValue('--tr-layout-inner-padding-block').trim(), + mainEl, + 0, + 'height', + ) + return Math.max(clientHeight - insetBlock * 2, 0) +} + +export function useLayoutMainScrollbar(options: UseLayoutMainScrollbarOptions) { + const metrics = shallowRef(createEmptyMetrics()) + const thumbDragState = shallowRef(null) + const isHovering = shallowRef(false) + const pointerTarget = typeof window === 'undefined' ? undefined : window + let frameId: number | null = null + + const showScrollbar = computed(() => metrics.value.isScrollable) + const isDraggingThumb = computed(() => thumbDragState.value !== null) + const scrollbarVisible = computed(() => showScrollbar.value && (isHovering.value || isDraggingThumb.value)) + + function syncMetrics(): void { + frameId = null + + const scrollHost = options.scrollHostRef.value + if (!scrollHost) { + metrics.value = createEmptyMetrics() + return + } + + const clientHeight = scrollHost.clientHeight + const scrollHeight = scrollHost.scrollHeight + const scrollTop = scrollHost.scrollTop + const trackHeight = resolveTrackHeight(scrollHost, clientHeight) + const isScrollable = scrollHeight - clientHeight > 1 + + if (!isScrollable) { + metrics.value = { + clientHeight, + scrollHeight, + scrollTop, + trackHeight, + thumbHeight: trackHeight, + thumbOffset: 0, + isScrollable: false, + } + return + } + + const scrollRange = scrollHeight - clientHeight + const thumbHeight = clamp((clientHeight / scrollHeight) * trackHeight, MIN_THUMB_HEIGHT, trackHeight) + const thumbTravel = Math.max(0, trackHeight - thumbHeight) + const thumbOffset = scrollRange > 0 ? (scrollTop / scrollRange) * thumbTravel : 0 + + metrics.value = { + clientHeight, + scrollHeight, + scrollTop, + trackHeight, + thumbHeight, + thumbOffset, + isScrollable: true, + } + } + + function scheduleSync(): void { + if (typeof window === 'undefined') { + syncMetrics() + return + } + + if (frameId !== null) { + return + } + + frameId = window.requestAnimationFrame(syncMetrics) + } + + function stopThumbDrag(pointerId?: number): void { + const dragState = thumbDragState.value + if (!dragState || (pointerId !== undefined && dragState.pointerId !== pointerId)) { + return + } + + restoreBodyInteraction(dragState.bodyEl, dragState.bodyState) + thumbDragState.value = null + } + + function startThumbDrag(event: PointerEvent): void { + const scrollHost = options.scrollHostRef.value + if (!scrollHost || !metrics.value.isScrollable || event.button !== 0 || !event.isPrimary) { + return + } + + const bodyEl = scrollHost.ownerDocument.body + if (!(bodyEl instanceof HTMLBodyElement)) { + return + } + + event.preventDefault() + thumbDragState.value = { + pointerId: event.pointerId, + startY: event.clientY, + startScrollTop: scrollHost.scrollTop, + bodyEl, + bodyState: lockBodyInteraction(bodyEl, 'grabbing'), + } + } + + useEventListener(options.scrollHostRef, 'scroll', () => { + scheduleSync() + }) + + useEventListener(options.scrollHostRef, 'wheel', () => { + scheduleSync() + }) + + useEventListener(pointerTarget, 'pointermove', (event: PointerEvent) => { + const dragState = thumbDragState.value + const scrollHost = options.scrollHostRef.value + const currentMetrics = metrics.value + if (!dragState || !scrollHost || event.pointerId !== dragState.pointerId || !currentMetrics.isScrollable) { + return + } + + const deltaY = event.clientY - dragState.startY + const scrollRange = currentMetrics.scrollHeight - currentMetrics.clientHeight + const thumbTravel = currentMetrics.trackHeight - currentMetrics.thumbHeight + const ratio = thumbTravel > 0 ? scrollRange / thumbTravel : 0 + scrollHost.scrollTop = dragState.startScrollTop + deltaY * ratio + scheduleSync() + }) + + useEventListener(pointerTarget, 'pointerup', (event: PointerEvent) => { + stopThumbDrag(event.pointerId) + }) + + useEventListener(pointerTarget, 'pointercancel', (event: PointerEvent) => { + stopThumbDrag(event.pointerId) + }) + + useResizeObserver(options.scrollHostRef, () => { + scheduleSync() + }) + + useMutationObserver( + options.scrollHostRef, + () => { + scheduleSync() + }, + { childList: true, subtree: true }, + ) + + watch( + options.scrollHostRef, + (nextHost, prevHost) => { + stopThumbDrag() + prevHost?.removeAttribute('data-tr-layout-scroll-host') + nextHost?.setAttribute('data-tr-layout-scroll-host', '') + scheduleSync() + }, + { immediate: true }, + ) + + onBeforeUnmount(() => { + if (frameId !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(frameId) + } + + stopThumbDrag() + options.scrollHostRef.value?.removeAttribute('data-tr-layout-scroll-host') + }) + + const thumbStyle = computed(() => ({ + height: `${metrics.value.thumbHeight}px`, + transform: `translateY(${metrics.value.thumbOffset}px)`, + })) + + const rootClass = computed(() => ({ + 'tr-layout-main--scrollbar-visible': scrollbarVisible.value, + 'tr-layout-main--dragging-thumb': isDraggingThumb.value, + })) + + return { + showScrollbar, + rootClass, + thumbStyle, + setHovering: (value: boolean) => { + isHovering.value = value + }, + startThumbDrag, + } +} diff --git a/packages/components/src/layout/composables/useLayoutPanel.ts b/packages/components/src/layout/composables/useLayoutPanel.ts new file mode 100644 index 000000000..8e075ceab --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutPanel.ts @@ -0,0 +1,51 @@ +import { computed, toValue, type MaybeRefOrGetter } from 'vue' +import { useLayoutContext } from './useLayoutContext' +import type { LayoutPlacement } from '../index.type' +import type { LayoutPanelApi } from '../internal.type' + +export function useLayoutPanel(placement: MaybeRefOrGetter) { + const context = useLayoutContext() + const resolvedPlacement = computed(() => toValue(placement)) + const panel = computed(() => (resolvedPlacement.value === 'left' ? context.left : context.right)) + + function open(): void { + panel.value.open() + } + + function close(): void { + panel.value.close() + } + + function toggle(): void { + panel.value.toggle() + } + + function setOpen(nextOpen: boolean): void { + panel.value.setOpen(nextOpen) + } + + function setWidth(nextWidth: number): void { + panel.value.setWidth(nextWidth) + } + + return { + placement: computed(() => panel.value.placement), + layoutMode: computed(() => panel.value.layoutMode), + isOpen: computed(() => panel.value.isOpen), + isDock: computed(() => panel.value.isDock), + isDrawer: computed(() => panel.value.isDrawer), + isRail: computed(() => panel.value.isRail), + isHidden: computed(() => panel.value.isHidden), + canResize: computed(() => panel.value.canResize), + width: computed(() => panel.value.width), + collapsedWidth: computed(() => panel.value.collapsedWidth), + collapseEffect: computed(() => panel.value.collapseEffect), + resizable: computed(() => panel.value.resizable), + open, + close, + toggle, + setOpen, + setWidth, + setExpandedWidth: setWidth, + } +} diff --git a/packages/components/src/layout/composables/useLayoutRenderState.ts b/packages/components/src/layout/composables/useLayoutRenderState.ts new file mode 100644 index 000000000..0fe6b9c24 --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutRenderState.ts @@ -0,0 +1,88 @@ +import { computed, toValue, type MaybeRefOrGetter, type Slots } from 'vue' +import type { LayoutAsideSlotProps } from '../index.type' +import type { LayoutPanelApi } from '../internal.type' +import { toPx } from '../utils/cssLength' +import { hasRenderableSlot } from '../utils/slots' + +interface UseLayoutRenderStateOptions { + slots: Slots + left: LayoutPanelApi + right: LayoutPanelApi + isResizing: MaybeRefOrGetter +} + +function createAsideSlotProps(panel: LayoutPanelApi, placement: 'left' | 'right'): LayoutAsideSlotProps { + return { + placement, + mode: panel.layoutMode, + open: panel.isOpen, + expandedWidth: panel.width, + collapsedWidth: panel.collapsedWidth, + resizable: panel.resizable, + isRail: panel.isRail, + isHidden: panel.isHidden, + canResize: panel.canResize, + toggle: panel.toggle, + setOpen: panel.setOpen, + setExpandedWidth: panel.setWidth, + } +} + +export function useLayoutRenderState({ slots, left, right, isResizing }: UseLayoutRenderStateOptions) { + const leftAsideSlotProps = computed(() => createAsideSlotProps(left, 'left')) + const rightAsideSlotProps = computed(() => createAsideSlotProps(right, 'right')) + + const hasLeftAside = computed(() => hasRenderableSlot(slots['left-aside'], leftAsideSlotProps.value)) + const hasHeader = computed(() => hasRenderableSlot(slots.header)) + const hasFooter = computed(() => hasRenderableSlot(slots.footer)) + const hasRightAside = computed(() => hasRenderableSlot(slots['right-aside'], rightAsideSlotProps.value)) + + const layoutStyle = computed>(() => { + const style: Record = {} + const leftDockWidth = toPx(left.width) + const leftCollapsedWidth = toPx(left.collapsedWidth) + const rightDockWidth = toPx(right.width) + const rightCollapsedWidth = toPx(right.collapsedWidth) + + if (leftDockWidth) { + style['--left-dock-width'] = leftDockWidth + } + + if (leftCollapsedWidth) { + style['--left-collapsed-width'] = leftCollapsedWidth + } + + if (rightDockWidth) { + style['--right-dock-width'] = rightDockWidth + } + + if (rightCollapsedWidth) { + style['--right-collapsed-width'] = rightCollapsedWidth + } + + return style + }) + + const layoutClass = computed(() => ({ + 'tr-layout--left-dock': hasLeftAside.value && left.isDock, + 'tr-layout--left-drawer': hasLeftAside.value && left.isDrawer, + 'tr-layout--left-expanded': hasLeftAside.value && left.isOpen, + 'tr-layout--left-rail': hasLeftAside.value && left.isRail, + 'tr-layout--right-dock': hasRightAside.value && right.isDock, + 'tr-layout--right-drawer': hasRightAside.value && right.isDrawer, + 'tr-layout--right-expanded': hasRightAside.value && right.isOpen, + 'tr-layout--right-rail': hasRightAside.value && right.isRail, + 'tr-layout--resizing': toValue(isResizing), + })) + + return { + hasHeader, + hasFooter, + hasLeftAside, + hasRightAside, + leftAsideSlotProps, + rightAsideSlotProps, + layoutStyle, + layoutClass, + } +} diff --git a/packages/components/src/layout/composables/useLayoutRootState.ts b/packages/components/src/layout/composables/useLayoutRootState.ts new file mode 100644 index 000000000..9ad787d2e --- /dev/null +++ b/packages/components/src/layout/composables/useLayoutRootState.ts @@ -0,0 +1,216 @@ +import { computed, getCurrentInstance } from 'vue' +import type { + LayoutAsideProps, + LayoutAsideState, + LayoutEmits, + LayoutFloatingState, + LayoutMode, + LayoutPlacement, + LayoutProps, +} from '../index.type' +import type { + LayoutPanelState, + LayoutResolvedFloating, + LayoutRuntimeProps, + UseLayoutRootStateResult, +} from '../internal.type' +import { clamp } from '../utils/math' +import { getDefaultAsideMaxWidth, getDefaultAsideMinWidth, getDefaultAsideOpen } from '../utils/asideDefaults' +import { useControllableState } from '../../shared/composables/useControllableState' + +type EmitFn = (event: K, ...args: LayoutEmits[K]) => void + +const hasAsideField = (aside: LayoutAsideProps | undefined, field: keyof LayoutAsideProps): boolean => + aside !== undefined && Object.prototype.hasOwnProperty.call(aside, field) + +function hasFloatingStateProp(): boolean { + const rawProps = getCurrentInstance()?.vnode.props as Record | null | undefined + + if (!rawProps) { + return false + } + + return ( + Object.prototype.hasOwnProperty.call(rawProps, 'floatingState') || + Object.prototype.hasOwnProperty.call(rawProps, 'floating-state') + ) +} + +function resolveLayoutRuntimeProps(props: LayoutProps): LayoutRuntimeProps { + return { + get mode() { + return props.mode === 'floating' ? 'floating' : 'normal' + }, + get leftAside() { + return props.leftAside + }, + get rightAside() { + return props.rightAside + }, + get floatingState() { + return props.mode === 'floating' ? props.floatingState : undefined + }, + get defaultFloatingState() { + return props.mode === 'floating' ? props.defaultFloatingState : undefined + }, + get floatingOptions() { + return props.mode === 'floating' ? props.floatingOptions : undefined + }, + } +} + +function emitAsideStateChange(emit: EmitFn, placement: LayoutPlacement, value: LayoutAsideState): void { + if (placement === 'left') { + emit('left-aside-state-change', value) + return + } + + emit('right-aside-state-change', value) +} + +function isFloatingStateEqual(left: LayoutFloatingState | undefined, right: LayoutFloatingState | undefined): boolean { + return ( + left?.placement === right?.placement && + left?.offsetX === right?.offsetX && + left?.offsetY === right?.offsetY && + left?.width === right?.width && + left?.height === right?.height + ) +} + +function resolveFiniteNumber(value: number | undefined, fallback: number): number { + return value === undefined || !Number.isFinite(value) ? fallback : value +} + +function createLayoutAsideState( + placement: LayoutPlacement, + aside: () => LayoutAsideProps | undefined, + emit: EmitFn, +): LayoutPanelState { + const asideValue = computed(() => aside()) + const layoutMode = computed(() => asideValue.value?.mode ?? 'dock') + const collapsedWidth = computed(() => asideValue.value?.collapsedWidth) + const collapseEffect = computed(() => asideValue.value?.collapseEffect ?? 'overlay') + const resizable = computed(() => asideValue.value?.resizable ?? false) + const minWidth = computed(() => + resolveFiniteNumber(asideValue.value?.minExpandedWidth, getDefaultAsideMinWidth(placement)), + ) + const maxWidth = computed(() => { + const nextMaxWidth = resolveFiniteNumber(asideValue.value?.maxExpandedWidth, getDefaultAsideMaxWidth(placement)) + return Math.max(minWidth.value, nextMaxWidth) + }) + + const openState = useControllableState({ + value: () => asideValue.value?.open, + defaultValue: () => + hasAsideField(asideValue.value, 'defaultOpen') ? asideValue.value?.defaultOpen : getDefaultAsideOpen(placement), + isControlled: () => hasAsideField(asideValue.value, 'open'), + onChange: (nextOpen) => + emitAsideStateChange(emit, placement, { open: nextOpen, expandedWidth: resolvedWidth.value }), + }) + + const widthState = useControllableState({ + value: () => asideValue.value?.expandedWidth, + defaultValue: () => + hasAsideField(asideValue.value, 'defaultExpandedWidth') ? asideValue.value?.defaultExpandedWidth : undefined, + isControlled: () => hasAsideField(asideValue.value, 'expandedWidth'), + onChange: (nextWidth) => + emitAsideStateChange(emit, placement, { open: resolvedOpen.value, expandedWidth: nextWidth }), + }) + + const resolvedOpen = computed(() => openState.resolvedState.value ?? getDefaultAsideOpen(placement)) + const resolvedWidth = computed(() => { + const nextWidth = widthState.resolvedState.value + + if (nextWidth === undefined || !Number.isFinite(nextWidth)) { + return undefined + } + + return clamp(nextWidth, minWidth.value, maxWidth.value) + }) + + function setOpen(nextOpen: boolean): void { + if (resolvedOpen.value === nextOpen) { + return + } + + openState.commit(nextOpen) + } + + function setWidth(nextWidth: number): void { + const clampedWidth = clamp(nextWidth, minWidth.value, maxWidth.value) + if (resolvedWidth.value === clampedWidth) { + return + } + + widthState.commit(clampedWidth) + } + + return { + placement, + layoutMode, + isOpen: resolvedOpen, + width: resolvedWidth, + collapsedWidth, + collapseEffect, + minWidth, + maxWidth, + resizable, + setOpen, + setWidth, + } +} + +export function useLayoutRootState(props: LayoutProps, emit: EmitFn): UseLayoutRootStateResult { + const runtimeProps = resolveLayoutRuntimeProps(props) + const floatingStateProvided = hasFloatingStateProp() + + const floatingState = useControllableState({ + value: () => runtimeProps.floatingState, + defaultValue: () => runtimeProps.defaultFloatingState, + isControlled: floatingStateProvided, + onChange: (nextFloatingState) => nextFloatingState && emit('update:floatingState', nextFloatingState), + }) + + const resolvedMode = computed(() => runtimeProps.mode) + const resolvedFloatingState = computed(() => floatingState.resolvedState.value) + const resolvedFloating = computed(() => { + const nextFloatingState = resolvedFloatingState.value + const nextFloatingOptions = runtimeProps.floatingOptions + + if (!nextFloatingState && !nextFloatingOptions) { + return undefined + } + + return { + ...nextFloatingOptions, + ...nextFloatingState, + } + }) + + function initializeFloatingState(nextFloatingState: LayoutFloatingState): void { + if (isFloatingStateEqual(resolvedFloatingState.value, nextFloatingState)) { + return + } + + floatingState.commit(nextFloatingState, { notify: false }) + } + + function commitFloatingState(nextFloatingState: LayoutFloatingState): void { + if (isFloatingStateEqual(resolvedFloatingState.value, nextFloatingState)) { + return + } + + floatingState.commit(nextFloatingState) + } + + return { + resolvedMode, + resolvedFloatingState, + resolvedFloating, + commitFloatingState, + initializeFloatingState, + leftAside: createLayoutAsideState('left', () => runtimeProps.leftAside, emit), + rightAside: createLayoutAsideState('right', () => runtimeProps.rightAside, emit), + } +} diff --git a/packages/components/src/layout/index.ts b/packages/components/src/layout/index.ts new file mode 100644 index 000000000..4c398942d --- /dev/null +++ b/packages/components/src/layout/index.ts @@ -0,0 +1,54 @@ +import type { App } from 'vue' +import LayoutComp from './Layout.vue' +import LayoutAsideToggleComp from './LayoutAsideToggle.vue' +import LayoutMainComp from './LayoutMain.vue' + +export * from './index.type' + +LayoutMainComp.name = 'TrLayoutMain' + +const layoutMainInstall = function (app: App) { + app.component(LayoutMainComp.name!, LayoutMainComp) +} + +const LayoutMain = LayoutMainComp as typeof LayoutMainComp & { + install: typeof layoutMainInstall +} + +LayoutMain.install = layoutMainInstall + +LayoutAsideToggleComp.name = 'TrLayoutAsideToggle' + +const layoutAsideToggleInstall = function (app: App) { + app.component(LayoutAsideToggleComp.name!, LayoutAsideToggleComp) +} + +const LayoutAsideToggle = LayoutAsideToggleComp as typeof LayoutAsideToggleComp & { + install: typeof layoutAsideToggleInstall +} + +LayoutAsideToggle.install = layoutAsideToggleInstall + +LayoutComp.name = 'TrLayout' + +const layoutInstall = function (app: App) { + app.component(LayoutComp.name!, LayoutComp) + app.component(LayoutMain.name!, LayoutMain) + app.component(LayoutAsideToggle.name!, LayoutAsideToggle) +} + +type LayoutCompound = typeof LayoutComp & { + install: typeof layoutInstall + Main: typeof LayoutMain + AsideToggle: typeof LayoutAsideToggle +} + +const Layout = LayoutComp as LayoutCompound + +Layout.install = layoutInstall +Layout.Main = LayoutMain +Layout.AsideToggle = LayoutAsideToggle + +export { Layout, LayoutMain, LayoutAsideToggle } + +export default Layout diff --git a/packages/components/src/layout/index.type.ts b/packages/components/src/layout/index.type.ts new file mode 100644 index 000000000..e5d75e332 --- /dev/null +++ b/packages/components/src/layout/index.type.ts @@ -0,0 +1,119 @@ +import type { VNode } from 'vue' + +export type LayoutPlacement = 'left' | 'right' +export type LayoutAsideMode = 'dock' | 'drawer' +export type LayoutAsideCollapseEffect = 'overlay' | 'slide' +export type LayoutMode = 'normal' | 'floating' +export type LayoutFloatingPlacement = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center' + +export interface LayoutFloatingState { + placement?: LayoutFloatingPlacement + offsetX?: number + offsetY?: number + width?: number + height?: number +} + +export interface LayoutFloatingOptions { + draggable?: boolean + resizable?: boolean + minWidth?: number + maxWidth?: number + minHeight?: number + maxHeight?: number +} + +export type LayoutFloatingResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' + +export interface LayoutAsideResizeEventDetail { + placement: LayoutPlacement + width: number +} + +export type LayoutFloatingDragEventDetail = LayoutFloatingState + +export type LayoutFloatingResizeEventDetail = LayoutFloatingState & { + handle: LayoutFloatingResizeHandle +} + +export interface LayoutAsideProps { + mode?: LayoutAsideMode + open?: boolean + defaultOpen?: boolean + expandedWidth?: number + defaultExpandedWidth?: number + minExpandedWidth?: number + maxExpandedWidth?: number + collapsedWidth?: number + collapseEffect?: LayoutAsideCollapseEffect + resizable?: boolean +} + +export interface LayoutAsidePanelsProps { + leftAside?: LayoutAsideProps + rightAside?: LayoutAsideProps +} + +export interface LayoutNormalProps extends LayoutAsidePanelsProps { + mode?: 'normal' +} + +type LayoutFloatingStateControlProps = + | { + floatingState: LayoutFloatingState | undefined + defaultFloatingState?: never + } + | { + floatingState?: undefined + defaultFloatingState?: LayoutFloatingState + } + +export type LayoutFloatingProps = LayoutAsidePanelsProps & + LayoutFloatingStateControlProps & { + mode: 'floating' + floatingOptions?: LayoutFloatingOptions + } + +export type LayoutProps = LayoutNormalProps | LayoutFloatingProps + +export interface LayoutAsideState { + open: boolean + expandedWidth: number | undefined +} +export interface LayoutEmits { + 'left-aside-state-change': [value: LayoutAsideState] + 'right-aside-state-change': [value: LayoutAsideState] + 'update:floatingState': [value: LayoutFloatingState] + 'floating-drag-start': [detail: LayoutFloatingDragEventDetail] + 'floating-drag': [detail: LayoutFloatingDragEventDetail] + 'floating-drag-end': [detail: LayoutFloatingDragEventDetail] + 'floating-resize-start': [detail: LayoutFloatingResizeEventDetail] + 'floating-resize': [detail: LayoutFloatingResizeEventDetail] + 'floating-resize-end': [detail: LayoutFloatingResizeEventDetail] + 'aside-resize-start': [detail: LayoutAsideResizeEventDetail] + 'aside-resize': [detail: LayoutAsideResizeEventDetail] + 'aside-resize-end': [detail: LayoutAsideResizeEventDetail] +} + +export interface LayoutAsideSlotProps { + placement: LayoutPlacement + mode: LayoutAsideMode + open: boolean + expandedWidth: number | undefined + collapsedWidth: number | undefined + resizable: boolean + isRail: boolean + isHidden: boolean + canResize: boolean + toggle: () => void + setOpen: (next: boolean) => void + setExpandedWidth: (next: number) => void +} + +export interface LayoutSlots { + 'left-aside'?: (slotProps: LayoutAsideSlotProps) => VNode | VNode[] + header?: () => VNode | VNode[] + main?: () => VNode | VNode[] + footer?: () => VNode | VNode[] + 'right-aside'?: (slotProps: LayoutAsideSlotProps) => VNode | VNode[] +} diff --git a/packages/components/src/layout/internal.type.ts b/packages/components/src/layout/internal.type.ts new file mode 100644 index 000000000..7d825f786 --- /dev/null +++ b/packages/components/src/layout/internal.type.ts @@ -0,0 +1,96 @@ +import type { ComponentPublicInstance, ComputedRef, MaybeRefOrGetter } from 'vue' +import type { + LayoutAsideCollapseEffect, + LayoutAsideMode, + LayoutAsidePanelsProps, + LayoutFloatingOptions, + LayoutFloatingState, + LayoutMode, + LayoutPlacement, +} from './index.type' + +export type LayoutResolvedFloating = LayoutFloatingState & LayoutFloatingOptions + +export type LayoutFloatingRect = Omit< + LayoutResolvedFloating, + 'placement' | 'offsetX' | 'offsetY' | 'width' | 'height' +> & { + x: number + y: number + width: number + height: number +} + +export interface LayoutRuntimeProps extends LayoutAsidePanelsProps { + mode: LayoutMode + floatingState?: LayoutFloatingState + defaultFloatingState?: LayoutFloatingState + floatingOptions?: LayoutFloatingOptions +} + +export type LayoutMainScrollHostComponent = Pick + +export type LayoutMainScrollHost = HTMLElement | LayoutMainScrollHostComponent | null | undefined + +export interface LayoutAsideToggleProps { + placement: LayoutPlacement +} + +export interface LayoutMainProps { + scrollHost?: LayoutMainScrollHost +} + +type ToMaybeRefFields = { + [K in keyof T]: MaybeRefOrGetter +} + +interface LayoutPanelValue { + placement: LayoutPlacement + layoutMode: LayoutAsideMode + isOpen: boolean + width: number | undefined + collapsedWidth: number | undefined + collapseEffect: LayoutAsideCollapseEffect + minWidth: number + maxWidth: number + resizable: boolean +} + +interface LayoutPanelMutations { + setOpen: (nextOpen: boolean) => void + setWidth: (nextWidth: number) => void +} + +interface LayoutPanelDerived { + isDock: boolean + isDrawer: boolean + isRail: boolean + isHidden: boolean + canResize: boolean +} + +export type LayoutPanelState = ToMaybeRefFields & LayoutPanelMutations + +export interface LayoutPanelApi extends LayoutPanelValue, LayoutPanelDerived, LayoutPanelMutations { + isRegistered: boolean + open: () => void + close: () => void + toggle: () => void +} + +export interface LayoutContext { + left: LayoutPanelApi + right: LayoutPanelApi + isDrawerVisible: boolean + closeDrawers: () => void +} + +export interface UseLayoutRootStateResult { + resolvedMode: ComputedRef + resolvedFloatingState: ComputedRef + resolvedFloating: ComputedRef + commitFloatingState: (nextFloating: LayoutFloatingState) => void + initializeFloatingState: (nextFloating: LayoutFloatingState) => void + leftAside: LayoutPanelState + rightAside: LayoutPanelState +} diff --git a/packages/components/src/layout/utils/asideDefaults.ts b/packages/components/src/layout/utils/asideDefaults.ts new file mode 100644 index 000000000..026884513 --- /dev/null +++ b/packages/components/src/layout/utils/asideDefaults.ts @@ -0,0 +1,28 @@ +import type { LayoutPlacement } from '../index.type' + +const DEFAULT_ASIDE_OPEN = { + left: true, + right: false, +} as const + +const DEFAULT_ASIDE_MIN_WIDTH = { + left: 200, + right: 240, +} as const + +const DEFAULT_ASIDE_MAX_WIDTH = { + left: 560, + right: 640, +} as const + +export function getDefaultAsideOpen(placement: LayoutPlacement): boolean { + return DEFAULT_ASIDE_OPEN[placement] +} + +export function getDefaultAsideMinWidth(placement: LayoutPlacement): number { + return DEFAULT_ASIDE_MIN_WIDTH[placement] +} + +export function getDefaultAsideMaxWidth(placement: LayoutPlacement): number { + return DEFAULT_ASIDE_MAX_WIDTH[placement] +} diff --git a/packages/components/src/layout/utils/cssLength.ts b/packages/components/src/layout/utils/cssLength.ts new file mode 100644 index 000000000..58999d1c4 --- /dev/null +++ b/packages/components/src/layout/utils/cssLength.ts @@ -0,0 +1,74 @@ +export function toPx(value: number | undefined): string | undefined { + return value === undefined ? undefined : `${value}px` +} + +const PX_LENGTH_RE = /^(-?(?:\d+|\d*\.\d+))px$/i +const ZERO_LENGTH_RE = /^0(?:\.0+)?(?:[a-z%]+)?$/i + +const measurementMap = new WeakMap() + +function getMeasurementElement(rootEl: HTMLElement): HTMLDivElement { + const cached = measurementMap.get(rootEl) + if (cached) { + return cached + } + + const el = rootEl.ownerDocument.createElement('div') + el.style.position = 'absolute' + el.style.visibility = 'hidden' + el.style.pointerEvents = 'none' + el.style.inset = '0 auto auto 0' + el.style.width = '0px' + el.style.height = '0px' + el.style.padding = '0' + el.style.border = '0' + el.style.overflow = 'hidden' + rootEl.appendChild(el) + measurementMap.set(rootEl, el) + return el +} + +export function resolveCssLengthToPx( + value: number | string | undefined, + rootEl: HTMLElement | null | undefined, + fallback: number, + property: 'width' | 'height' = 'width', +): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value !== 'string' || !value.trim() || !rootEl) { + return fallback + } + + const normalized = value.trim().toLowerCase() + + if (ZERO_LENGTH_RE.test(normalized)) { + return 0 + } + + const pxMatch = normalized.match(PX_LENGTH_RE) + if (pxMatch) { + const parsed = Number.parseFloat(pxMatch[1]) + return Number.isFinite(parsed) ? parsed : fallback + } + + const measure = getMeasurementElement(rootEl) + measure.style.width = property === 'width' ? normalized : '0px' + measure.style.height = property === 'height' ? normalized : '0px' + + if (property === 'width' && !measure.style.width) { + return fallback + } + + if (property === 'height' && !measure.style.height) { + return fallback + } + + const size = measure.getBoundingClientRect()[property] + measure.style.width = '0px' + measure.style.height = '0px' + + return Number.isFinite(size) ? size : fallback +} diff --git a/packages/components/src/layout/utils/domInteraction.ts b/packages/components/src/layout/utils/domInteraction.ts new file mode 100644 index 000000000..c95cbbe25 --- /dev/null +++ b/packages/components/src/layout/utils/domInteraction.ts @@ -0,0 +1,21 @@ +export interface BodyInteractionState { + cursor: string + userSelect: string +} + +export function lockBodyInteraction(body: HTMLElement, cursor: string): BodyInteractionState { + const state = { + cursor: body.style.cursor, + userSelect: body.style.userSelect, + } + + body.style.cursor = cursor + body.style.userSelect = 'none' + + return state +} + +export function restoreBodyInteraction(body: HTMLElement, state: BodyInteractionState): void { + body.style.cursor = state.cursor + body.style.userSelect = state.userSelect +} diff --git a/packages/components/src/layout/utils/math.ts b/packages/components/src/layout/utils/math.ts new file mode 100644 index 000000000..299358185 --- /dev/null +++ b/packages/components/src/layout/utils/math.ts @@ -0,0 +1,3 @@ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} diff --git a/packages/components/src/layout/utils/slots.ts b/packages/components/src/layout/utils/slots.ts new file mode 100644 index 000000000..37855f3a2 --- /dev/null +++ b/packages/components/src/layout/utils/slots.ts @@ -0,0 +1,43 @@ +import { Comment, Fragment, Text, isVNode, type Slot } from 'vue' + +function hasRenderableValue(value: unknown): boolean { + if (value == null) { + return false + } + + if (typeof value === 'string') { + return value.trim().length > 0 + } + + return true +} + +function hasRenderableChildren(children: unknown): boolean { + if (!Array.isArray(children)) { + return hasRenderableValue(children) + } + + return children.some((child) => { + if (!isVNode(child)) { + return hasRenderableValue(child) + } + + if (child.type === Comment) { + return false + } + + if (child.type === Text) { + return hasRenderableValue(child.children) + } + + if (child.type === Fragment) { + return hasRenderableChildren(child.children) + } + + return true + }) +} + +export function hasRenderableSlot(slot?: Slot, slotProps?: object): boolean { + return hasRenderableChildren(slot?.(slotProps)) +} diff --git a/packages/components/src/layout/utils/surfaceGeometry.ts b/packages/components/src/layout/utils/surfaceGeometry.ts new file mode 100644 index 000000000..e1217cd6b --- /dev/null +++ b/packages/components/src/layout/utils/surfaceGeometry.ts @@ -0,0 +1,381 @@ +import type { LayoutFloatingPlacement, LayoutFloatingResizeHandle, LayoutFloatingState } from '../index.type' +import type { LayoutFloatingRect, LayoutResolvedFloating } from '../internal.type' +import { clamp } from './math' + +export interface FloatingBounds { + left: number + top: number + right: number + bottom: number +} + +export interface FloatingConstraints { + minWidth: number + maxWidth: number + minHeight: number + maxHeight: number +} + +export interface FloatingSnapshot { + placement: LayoutFloatingPlacement + rect: LayoutFloatingRect + bounds: FloatingBounds + constraints: FloatingConstraints + xMax: number + yMax: number +} + +export const DEFAULT_FLOATING_WIDTH = 420 +export const DEFAULT_FLOATING_HEIGHT = 560 +export const DEFAULT_FLOATING_GAP = 0 +export const DEFAULT_FLOATING_TOP = DEFAULT_FLOATING_GAP +export const DEFAULT_FLOATING_OFFSET = 24 +export const DEFAULT_MIN_FLOATING_WIDTH = 320 +export const DEFAULT_MIN_FLOATING_HEIGHT = 240 + +type FloatingRectLike = Pick & + Partial> +type FloatingConfig = LayoutFloatingState & + Partial> + +interface ViewportSize { + width: number + height: number +} + +interface ResolvedFloatingOffset { + x: number + y: number +} + +function resolveFloatingPlacement(config: Pick | undefined): LayoutFloatingPlacement { + return config?.placement ?? 'center' +} + +function isFloatingRect(value: LayoutFloatingRect | FloatingConfig): value is LayoutFloatingRect { + return value !== undefined && 'x' in value && 'y' in value +} + +function resolveViewportSize(): ViewportSize { + if (typeof window === 'undefined') { + return { + width: DEFAULT_FLOATING_WIDTH + DEFAULT_FLOATING_GAP * 2, + height: DEFAULT_FLOATING_HEIGHT + DEFAULT_FLOATING_GAP * 2, + } + } + + const viewport = window.visualViewport + + if (viewport) { + return { + width: viewport.width, + height: viewport.height, + } + } + + return { + width: window.innerWidth, + height: window.innerHeight, + } +} + +function getPlacementPosition( + placement: LayoutFloatingPlacement, + bounds: FloatingBounds, + width: number, + height: number, + offset: ResolvedFloatingOffset, +) { + switch (placement) { + case 'top-left': + return { + x: bounds.left + offset.x, + y: bounds.top + offset.y, + } + case 'top-right': + return { + x: bounds.right - width - offset.x, + y: bounds.top + offset.y, + } + case 'bottom-left': + return { + x: bounds.left + offset.x, + y: bounds.bottom - height - offset.y, + } + case 'bottom-right': + return { + x: bounds.right - width - offset.x, + y: bounds.bottom - height - offset.y, + } + case 'center': + default: + return { + x: (bounds.left + bounds.right - width) / 2, + y: (bounds.top + bounds.bottom - height) / 2, + } + } +} + +function resolveFloatingOffset(config: LayoutFloatingState | undefined): ResolvedFloatingOffset { + return { + x: config?.offsetX ?? DEFAULT_FLOATING_OFFSET, + y: config?.offsetY ?? DEFAULT_FLOATING_OFFSET, + } +} + +function resolveFloatingOffsetFromRect( + rect: LayoutFloatingRect, + bounds: FloatingBounds, + placement: LayoutFloatingPlacement, +): ResolvedFloatingOffset | null { + switch (placement) { + case 'top-left': + return { + x: rect.x - bounds.left, + y: rect.y - bounds.top, + } + case 'top-right': + return { + x: bounds.right - rect.width - rect.x, + y: rect.y - bounds.top, + } + case 'bottom-left': + return { + x: rect.x - bounds.left, + y: bounds.bottom - rect.height - rect.y, + } + case 'bottom-right': + return { + x: bounds.right - rect.width - rect.x, + y: bounds.bottom - rect.height - rect.y, + } + case 'center': + default: + return null + } +} + +function resolveNearestCornerPlacement(rect: LayoutFloatingRect, bounds: FloatingBounds): LayoutFloatingPlacement { + const centerX = rect.x + rect.width / 2 + const centerY = rect.y + rect.height / 2 + const viewportCenterX = (bounds.left + bounds.right) / 2 + const viewportCenterY = (bounds.top + bounds.bottom) / 2 + const horizontal = centerX <= viewportCenterX ? 'left' : 'right' + const vertical = centerY <= viewportCenterY ? 'top' : 'bottom' + + return `${vertical}-${horizontal}` as Exclude +} + +export function resolveViewportBounds(gap = DEFAULT_FLOATING_GAP, topGap = DEFAULT_FLOATING_TOP): FloatingBounds { + const viewport = resolveViewportSize() + + return { + left: gap, + top: topGap, + right: Math.max(gap, viewport.width - gap), + bottom: Math.max(topGap, viewport.height - gap), + } +} + +export function resolveFloatingConstraints(source?: Partial): FloatingConstraints { + const bounds = resolveViewportBounds() + const maxWidth = Math.max(1, bounds.right - bounds.left) + const maxHeight = Math.max(1, bounds.bottom - bounds.top) + const minWidth = clamp(source?.minWidth ?? DEFAULT_MIN_FLOATING_WIDTH, 1, maxWidth) + const minHeight = clamp(source?.minHeight ?? DEFAULT_MIN_FLOATING_HEIGHT, 1, maxHeight) + + return { + minWidth, + maxWidth: clamp(source?.maxWidth ?? maxWidth, minWidth, maxWidth), + minHeight, + maxHeight: clamp(source?.maxHeight ?? maxHeight, minHeight, maxHeight), + } +} + +export function clampFloatingRect( + rect: FloatingRectLike, + constraints = resolveFloatingConstraints(rect), + bounds = resolveViewportBounds(), +): LayoutFloatingRect { + const width = clamp(rect.width, constraints.minWidth, constraints.maxWidth) + const height = clamp(rect.height, constraints.minHeight, constraints.maxHeight) + const xMax = Math.max(bounds.left, bounds.right - width) + const yMax = Math.max(bounds.top, bounds.bottom - height) + + return { + x: clamp(rect.x, bounds.left, xMax), + y: clamp(rect.y, bounds.top, yMax), + width, + height, + draggable: rect.draggable ?? true, + resizable: rect.resizable ?? false, + minWidth: constraints.minWidth, + maxWidth: constraints.maxWidth, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + } +} + +export function clampFloatingRectByHandle( + rect: FloatingRectLike, + handle: LayoutFloatingResizeHandle, + constraints = resolveFloatingConstraints(rect), + bounds = resolveViewportBounds(), +): LayoutFloatingRect { + const right = rect.x + rect.width + const bottom = rect.y + rect.height + const availableWidthFromLeft = right - bounds.left + const availableHeightFromTop = bottom - bounds.top + + let width = rect.width + let height = rect.height + let x = rect.x + let y = rect.y + + if (handle.includes('w')) { + if (availableWidthFromLeft >= constraints.minWidth) { + width = clamp(rect.width, constraints.minWidth, Math.min(constraints.maxWidth, availableWidthFromLeft)) + x = right - width + } else { + width = constraints.minWidth + x = bounds.left + } + } else if (handle.includes('e')) { + width = clamp( + rect.width, + constraints.minWidth, + Math.min(constraints.maxWidth, Math.max(constraints.minWidth, bounds.right - rect.x)), + ) + x = rect.x + } + + if (handle.includes('n')) { + if (availableHeightFromTop >= constraints.minHeight) { + height = clamp(rect.height, constraints.minHeight, Math.min(constraints.maxHeight, availableHeightFromTop)) + y = bottom - height + } else { + height = constraints.minHeight + y = bounds.top + } + } else if (handle.includes('s')) { + height = clamp( + rect.height, + constraints.minHeight, + Math.min(constraints.maxHeight, Math.max(constraints.minHeight, bounds.bottom - rect.y)), + ) + y = rect.y + } + + return clampFloatingRect( + { + ...rect, + x, + y, + width, + height, + }, + constraints, + bounds, + ) +} + +export function resolveDefaultFloatingRect( + config?: FloatingConfig, + bounds = resolveViewportBounds(), +): LayoutFloatingRect { + const constraints = resolveFloatingConstraints(config) + const width = clamp(config?.width ?? DEFAULT_FLOATING_WIDTH, constraints.minWidth, constraints.maxWidth) + const height = clamp(config?.height ?? DEFAULT_FLOATING_HEIGHT, constraints.minHeight, constraints.maxHeight) + const placement = resolveFloatingPlacement(config) + const offset = resolveFloatingOffset(config) + const position = getPlacementPosition(placement, bounds, width, height, offset) + + return clampFloatingRect( + { + x: position.x, + y: position.y, + width, + height, + draggable: config?.draggable ?? true, + resizable: config?.resizable ?? false, + minWidth: config?.minWidth, + maxWidth: config?.maxWidth, + minHeight: config?.minHeight, + maxHeight: config?.maxHeight, + }, + constraints, + bounds, + ) +} + +export function normalizeFloatingRect(rectLike: LayoutFloatingRect | FloatingConfig | undefined): LayoutFloatingRect { + if (!rectLike) { + return resolveDefaultFloatingRect() + } + + if (isFloatingRect(rectLike)) { + return clampFloatingRect( + { + x: rectLike.x, + y: rectLike.y, + width: rectLike.width, + height: rectLike.height, + draggable: rectLike.draggable, + resizable: rectLike.resizable, + minWidth: rectLike.minWidth, + maxWidth: rectLike.maxWidth, + minHeight: rectLike.minHeight, + maxHeight: rectLike.maxHeight, + }, + resolveFloatingConstraints(rectLike), + ) + } + + return resolveDefaultFloatingRect(rectLike) +} + +export function resolveFloatingSnapshot( + config: LayoutFloatingRect | FloatingConfig | undefined, + source?: Pick, +): FloatingSnapshot { + const bounds = resolveViewportBounds() + const rect = normalizeFloatingRect(config) + const constraints = resolveFloatingConstraints(rect) + const normalizedRect = clampFloatingRect(rect, constraints, bounds) + + return { + placement: config && isFloatingRect(config) ? resolveFloatingPlacement(source) : resolveFloatingPlacement(config), + rect: normalizedRect, + bounds, + constraints, + xMax: Math.max(bounds.left, bounds.right - normalizedRect.width), + yMax: Math.max(bounds.top, bounds.bottom - normalizedRect.height), + } +} + +export function toCommittedFloatingState( + snapshot: FloatingSnapshot, + source?: Partial, + options?: { normalizeCenter?: boolean }, +): LayoutFloatingState { + const sourcePlacement = source?.placement ?? snapshot.placement + const placement = + options?.normalizeCenter && sourcePlacement === 'center' + ? resolveNearestCornerPlacement(snapshot.rect, snapshot.bounds) + : sourcePlacement + const offset = resolveFloatingOffsetFromRect(snapshot.rect, snapshot.bounds, placement) + + return { + placement, + ...(offset ? { offsetX: offset.x, offsetY: offset.y } : {}), + width: snapshot.rect.width, + height: snapshot.rect.height, + } +} + +export function areFloatingGeometryEqual( + left: Pick | undefined, + right: Pick | undefined, +): boolean { + return left?.x === right?.x && left?.y === right?.y && left?.width === right?.width && left?.height === right?.height +} diff --git a/packages/components/src/layout/utils/surfaceResize.ts b/packages/components/src/layout/utils/surfaceResize.ts new file mode 100644 index 000000000..bf4cfc5b2 --- /dev/null +++ b/packages/components/src/layout/utils/surfaceResize.ts @@ -0,0 +1,61 @@ +import type { LayoutFloatingResizeHandle } from '../index.type' +import type { LayoutFloatingRect } from '../internal.type' + +interface ResolveFloatingResizeRectOptions { + handle: LayoutFloatingResizeHandle + deltaX: number + deltaY: number + startRect: LayoutFloatingRect +} + +function applyNorth(rect: LayoutFloatingRect, deltaY: number): LayoutFloatingRect { + return { + ...rect, + y: rect.y + deltaY, + height: rect.height - deltaY, + } +} + +function applySouth(rect: LayoutFloatingRect, deltaY: number): LayoutFloatingRect { + return { + ...rect, + height: rect.height + deltaY, + } +} + +function applyEast(rect: LayoutFloatingRect, deltaX: number): LayoutFloatingRect { + return { + ...rect, + width: rect.width + deltaX, + } +} + +function applyWest(rect: LayoutFloatingRect, deltaX: number): LayoutFloatingRect { + return { + ...rect, + x: rect.x + deltaX, + width: rect.width - deltaX, + } +} + +export function resolveFloatingResizeRect(options: ResolveFloatingResizeRectOptions): LayoutFloatingRect { + let nextRect = { ...options.startRect } + + if (options.handle.includes('n')) { + nextRect = applyNorth(nextRect, options.deltaY) + } + + if (options.handle.includes('s')) { + nextRect = applySouth(nextRect, options.deltaY) + } + + if (options.handle.includes('e')) { + nextRect = applyEast(nextRect, options.deltaX) + } + + if (options.handle.includes('w')) { + nextRect = applyWest(nextRect, options.deltaX) + } + + return nextRect +} diff --git a/packages/components/src/shared/composables/index.ts b/packages/components/src/shared/composables/index.ts index fb703769a..3968ce1cf 100644 --- a/packages/components/src/shared/composables/index.ts +++ b/packages/components/src/shared/composables/index.ts @@ -1,5 +1,6 @@ export * from './createTeleport' export * from './useAutoScroll' +export * from './useControllableState' export * from './useSlotRefs' export * from './useTeleportTarget' export * from './useTouchDevice' diff --git a/packages/components/src/shared/composables/useControllableState.ts b/packages/components/src/shared/composables/useControllableState.ts new file mode 100644 index 000000000..200294d86 --- /dev/null +++ b/packages/components/src/shared/composables/useControllableState.ts @@ -0,0 +1,36 @@ +import { computed, shallowRef, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue' + +interface UseControllableStateOptions { + value: MaybeRefOrGetter + defaultValue?: MaybeRefOrGetter + isControlled: MaybeRefOrGetter + onChange?: (nextValue: T) => void +} + +interface UseControllableStateResult { + isControlled: ComputedRef + resolvedState: ComputedRef + commit: (nextValue: T, options?: { notify?: boolean }) => void +} + +export function useControllableState(options: UseControllableStateOptions): UseControllableStateResult { + const internalState = shallowRef(toValue(options.defaultValue)) + const isControlled = computed(() => toValue(options.isControlled)) + const resolvedState = computed(() => (isControlled.value ? toValue(options.value) : internalState.value)) + + function commit(nextValue: T, commitOptions?: { notify?: boolean }): void { + if (!isControlled.value) { + internalState.value = nextValue + } + + if (commitOptions?.notify !== false) { + options.onChange?.(nextValue) + } + } + + return { + isControlled, + resolvedState, + commit, + } +} diff --git a/packages/components/src/styles/components/index.css b/packages/components/src/styles/components/index.css index f0807bd75..a64493ab4 100644 --- a/packages/components/src/styles/components/index.css +++ b/packages/components/src/styles/components/index.css @@ -7,6 +7,7 @@ @import './anchor.less'; @import './prompt.less'; @import './prompts.less'; +@import './layout.less'; [data-tr-color-mode='light'] { color-scheme: light; diff --git a/packages/components/src/styles/components/layout.less b/packages/components/src/styles/components/layout.less new file mode 100644 index 000000000..8f8678062 --- /dev/null +++ b/packages/components/src/styles/components/layout.less @@ -0,0 +1,155 @@ +.tr-layout-vars() { + @layout-prefix: tr-layout; + @floating-prefix: tr-layout-frame; + @main-prefix: tr-layout-main; + + @layout-vars: { + bg: var(--tr-container-bg-default); + left-bg: var(--tr-container-bg-default); + right-bg: var(--tr-container-bg-default); + header-bg: var(--tr-container-bg-default); + main-bg: var(--tr-container-bg-default); + footer-bg: var(--tr-container-bg-default); + divider-color: var(--tr-border-color-disabled); + content-max-width: 960px; + inner-padding-inline: 16px; + inner-padding-block: 12px; + main-min-width: 320px; + panel-shadow: 0 20px 48px rgba(15, 23, 42, 0.18); + overlay-bg: rgba(15, 23, 42, 0.42); + }; + + @floating-vars: { + z-index: calc(var(--tr-z-index-drawer, 1000) + 1); + radius: 24px; + shadow: 0 28px 72px rgba(15, 23, 42, 0.2); + }; + + @main-vars: { + scrollbar-width: 12px; + scrollbar-thumb-bg: color-mix(in srgb, var(--tr-text-primary, #111827) 18%, transparent); + scrollbar-thumb-bg-hover: color-mix(in srgb, var(--tr-text-primary, #111827) 28%, transparent); + scrollbar-thumb-bg-active: color-mix(in srgb, var(--tr-text-primary, #111827) 36%, transparent); + }; + + @layout-private-vars: { + overlay-z-index: var(--tr-z-index-drawer, 1000); + transition-duration: 220ms; + transition-easing: ease; + left-dock-width: 300px; + right-dock-width: 320px; + }; + + @floating-frame-private-vars: { + border-color: color-mix(in srgb, var(--tr-text-primary, #111827) 6%, transparent); + outline-color: color-mix(in srgb, var(--tr-container-bg-default, #ffffff) 52%, transparent); + drag-bar-top: 8px; + drag-hit-width: 56px; + drag-hit-height: 18px; + drag-pill-width: 40px; + drag-pill-height: 5px; + drag-pill-bg: color-mix(in srgb, var(--tr-text-primary, #111827) 16%, transparent); + drag-pill-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66); + drag-hover-bg: color-mix(in srgb, var(--tr-container-bg-default, #ffffff) 88%, transparent); + drag-hover-border: color-mix(in srgb, var(--tr-text-primary, #111827) 8%, transparent); + drag-hover-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); + }; + + @resize-trigger-private-vars: { + trigger-size: 6px; + line-color: transparent; + line-hover-color: color-mix(in srgb, var(--tr-color-primary, #1476ff) 32%, transparent); + line-active-color: color-mix(in srgb, var(--tr-color-primary, #1476ff) 66%, transparent); + indicator-width: 6px; + indicator-height: 22px; + indicator-bg: var(--tr-container-bg-default, #ffffff); + indicator-border: color-mix(in srgb, var(--tr-text-primary, #111827) 10%, transparent); + indicator-active-bg: color-mix( + in srgb, + var(--tr-color-primary, #1476ff) 72%, + var(--tr-container-bg-default, #ffffff) + ); + indicator-active-border: color-mix( + in srgb, + var(--tr-color-primary, #1476ff) 88%, + var(--tr-container-bg-default, #ffffff) + ); + indicator-active-shadow: 0 0 0 4px color-mix(in srgb, var(--tr-color-primary-light, #deecff) 56%, transparent); + indicator-idle-opacity: 0; + indicator-idle-offset: 4px; + }; + + @floating-resize-private-vars: { + hit-area-size: 12px; + indicator-width: 6px; + indicator-height: 22px; + indicator-bg: var(--tr-container-bg-default, #ffffff); + indicator-border: color-mix(in srgb, var(--tr-text-primary, #111827) 10%, transparent); + indicator-active-bg: color-mix( + in srgb, + var(--tr-color-primary, #1476ff) 72%, + var(--tr-container-bg-default, #ffffff) + ); + indicator-active-border: color-mix( + in srgb, + var(--tr-color-primary, #1476ff) 88%, + var(--tr-container-bg-default, #ffffff) + ); + indicator-active-shadow: 0 0 0 4px color-mix(in srgb, var(--tr-color-primary-light, #deecff) 56%, transparent); + indicator-idle-opacity: 0; + indicator-hover-opacity: 1; + indicator-idle-offset: 6px; + }; + + @main-private-vars: { + scrollbar-inline-end: 4px; + scrollbar-thumb-inset: 2px; + }; + + :root, + [data-tr-theme] { + each(@layout-vars, { + --@{layout-prefix}-@{key}: @value; + }); + + each(@floating-vars, { + --@{floating-prefix}-@{key}: @value; + }); + + each(@main-vars, { + --@{main-prefix}-@{key}: @value; + }); + } + + .tr-layout { + each(@layout-private-vars, { + --@{key}: @value; + }); + } + + .tr-layout-frame { + each(@floating-frame-private-vars, { + --@{key}: @value; + }); + } + + .tr-layout__resize-trigger { + each(@resize-trigger-private-vars, { + --@{key}: @value; + }); + } + + .tr-layout-frame__resize-trigger { + each(@floating-resize-private-vars, { + --@{key}: @value; + }); + } + + .tr-layout-main { + each(@main-private-vars, { + --@{key}: @value; + }); + } +} + +.tr-layout-vars(); From 6170efc29dc44854f2dd5d9924d241211e9d688d Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Tue, 16 Jun 2026 00:22:46 -0700 Subject: [PATCH 2/6] refactor(layout): remove useLayoutPanel, introduce useLayoutProxyScrollbar and update layout state management --- packages/components/src/index.ts | 8 +- packages/components/src/layout/Layout.vue | 383 ++++++++---------- .../src/layout/LayoutAsideToggle.vue | 11 +- packages/components/src/layout/LayoutMain.vue | 95 ----- .../src/layout/LayoutProxyScrollbar.vue | 80 ++++ .../src/layout/components/AsideContent.vue | 56 ++- .../components/FloatingResizeTrigger.vue | 36 +- .../layout/composables/createLayoutContext.ts | 223 ---------- ...nteractions.ts => useLayoutAsideResize.ts} | 79 ++-- .../layout/composables/useLayoutContext.ts | 42 +- .../composables/useLayoutDrawerActions.ts | 93 +++++ .../layout/composables/useLayoutFloating.ts | 184 +++++++++ .../composables/useLayoutFloatingDrag.ts | 63 +++ .../composables/useLayoutFloatingResize.ts | 166 ++++++++ .../composables/useLayoutFloatingSurface.ts | 336 --------------- .../src/layout/composables/useLayoutPanel.ts | 51 --- ...crollbar.ts => useLayoutProxyScrollbar.ts} | 106 ++--- .../composables/useLayoutRenderState.ts | 61 +-- .../layout/composables/useLayoutRootState.ts | 205 ++++------ packages/components/src/layout/index.ts | 22 +- packages/components/src/layout/index.type.ts | 39 +- .../components/src/layout/internal.type.ts | 105 ++--- .../src/layout/utils/asideDefaults.ts | 9 + .../src/layout/utils/emitAsideEvents.ts | 53 +++ .../src/styles/components/layout.less | 65 ++- 25 files changed, 1250 insertions(+), 1321 deletions(-) delete mode 100644 packages/components/src/layout/LayoutMain.vue create mode 100644 packages/components/src/layout/LayoutProxyScrollbar.vue delete mode 100644 packages/components/src/layout/composables/createLayoutContext.ts rename packages/components/src/layout/composables/{useLayoutAsideInteractions.ts => useLayoutAsideResize.ts} (67%) create mode 100644 packages/components/src/layout/composables/useLayoutDrawerActions.ts create mode 100644 packages/components/src/layout/composables/useLayoutFloating.ts create mode 100644 packages/components/src/layout/composables/useLayoutFloatingDrag.ts create mode 100644 packages/components/src/layout/composables/useLayoutFloatingResize.ts delete mode 100644 packages/components/src/layout/composables/useLayoutFloatingSurface.ts delete mode 100644 packages/components/src/layout/composables/useLayoutPanel.ts rename packages/components/src/layout/composables/{useLayoutMainScrollbar.ts => useLayoutProxyScrollbar.ts} (62%) create mode 100644 packages/components/src/layout/utils/emitAsideEvents.ts diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 0116e1fa3..d457fe965 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -12,7 +12,7 @@ import DropdownMenu from './dropdown-menu' import Feedback from './feedback' import History from './history' import IconButton from './icon-button' -import { Layout, LayoutAsideToggle, LayoutMain } from './layout' +import { Layout, LayoutAsideToggle, LayoutProxyScrollbar } from './layout' import { Prompt, Prompts } from './prompts' import Sender from './sender' import SenderCompat from './sender-compat' @@ -84,7 +84,7 @@ const components = [ History, IconButton, Layout, - LayoutMain, + LayoutProxyScrollbar, LayoutAsideToggle, Prompt, Prompts, @@ -142,8 +142,8 @@ export { IconButton as TrIconButton, Layout, Layout as TrLayout, - LayoutMain, - LayoutMain as TrLayoutMain, + LayoutProxyScrollbar, + LayoutProxyScrollbar as TrLayoutProxyScrollbar, LayoutAsideToggle, LayoutAsideToggle as TrLayoutAsideToggle, Prompt, diff --git a/packages/components/src/layout/Layout.vue b/packages/components/src/layout/Layout.vue index 6676fdd8b..546bd05ff 100644 --- a/packages/components/src/layout/Layout.vue +++ b/packages/components/src/layout/Layout.vue @@ -1,14 +1,14 @@ diff --git a/packages/components/src/layout/LayoutProxyScrollbar.vue b/packages/components/src/layout/LayoutProxyScrollbar.vue new file mode 100644 index 000000000..06659b984 --- /dev/null +++ b/packages/components/src/layout/LayoutProxyScrollbar.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/components/src/layout/components/AsideContent.vue b/packages/components/src/layout/components/AsideContent.vue index 68f084c51..48d65d7a7 100644 --- a/packages/components/src/layout/components/AsideContent.vue +++ b/packages/components/src/layout/components/AsideContent.vue @@ -1,44 +1,66 @@